Complete DDS training workflow and delivery package
This commit is contained in:
parent
68dd83c7c2
commit
4c4b91064f
229 changed files with 11969 additions and 1024 deletions
|
|
@ -10,7 +10,8 @@ import { useLogout, useMe } from "@/features/auth/useAuth";
|
|||
import type { Role } from "@/shared/types/generated";
|
||||
|
||||
import {
|
||||
useAudit, useBackups, useCreateBackup, useCreateUser, usePatchUser, useStatus, useUsers,
|
||||
useAudit, useBackups, useCreateBackup, useCreateUser, useDiagnostics, usePatchUser, useStatus,
|
||||
useUsers,
|
||||
} from "./api";
|
||||
|
||||
const ROLE_LABELS: Record<Role, string> = {
|
||||
|
|
@ -31,6 +32,8 @@ export function Admin() {
|
|||
<button type="button" onClick={() => logout.mutate()}>Выйти</button>
|
||||
</p>
|
||||
<Status />
|
||||
<Diagnostics />
|
||||
<p><a href="/api/admin/config.xml">Скачать XML-конфигурацию рабочих мест</a></p>
|
||||
<Users />
|
||||
<Backups />
|
||||
<Audit />
|
||||
|
|
@ -38,6 +41,65 @@ export function Admin() {
|
|||
);
|
||||
}
|
||||
|
||||
function bytes(value: number | null) {
|
||||
if (value === null) return "нет данных ОС";
|
||||
if (value >= 1024 ** 3) return `${(value / 1024 ** 3).toFixed(1)} ГБ`;
|
||||
return `${Math.round(value / 1024 ** 2)} МБ`;
|
||||
}
|
||||
|
||||
function Diagnostics() {
|
||||
const report = useDiagnostics();
|
||||
const metrics = report.data?.metrics;
|
||||
const systemEvents = report.data?.recent_system_events ?? [];
|
||||
const failedActions = report.data?.failed_actions_24h ?? [];
|
||||
return (
|
||||
<>
|
||||
<h2>Нагрузка и диагностика</h2>
|
||||
{metrics && (
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
<tr><th>CPU backend</th><td>{metrics.cpu_percent.toFixed(1)}% · {metrics.cpu_cores} ядер на сервере</td></tr>
|
||||
<tr><th>Нагрузка сервера, 1 мин</th><td>{metrics.load_1m_percent === null ? "нет счётчика ОС" : `${metrics.load_1m_percent.toFixed(1)}%`}</td></tr>
|
||||
<tr><th>Память backend</th><td>{bytes(metrics.rss_bytes)}</td></tr>
|
||||
<tr><th>Свободная память сервера</th><td>{bytes(metrics.memory_available_bytes)} из {bytes(metrics.memory_total_bytes)}</td></tr>
|
||||
<tr><th>Свободно на диске</th><td>{bytes(metrics.disk_free_bytes)} из {bytes(metrics.disk_total_bytes)}</td></tr>
|
||||
<tr><th>Занятия</th><td>активно {metrics.active_sessions}; завершено за 24 ч {metrics.completed_sessions_24h}; восстановлено после запуска {metrics.restored_sessions}</td></tr>
|
||||
<tr><th>Процесс</th><td>работает {Math.floor(metrics.uptime_seconds / 60)} мин; потоков {metrics.threads}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{report.isError && <p className="warn">Диагностика недоступна: {String(report.error)}</p>}
|
||||
<p><a href="/api/admin/diagnostics.json">Скачать отчёт об ошибках и сбоях JSON</a></p>
|
||||
<h3>Сбои действий за 24 часа</h3>
|
||||
{failedActions.length === 0 ? <p>Зафиксированных сбоев действий нет.</p> : (
|
||||
<table className="grid">
|
||||
<thead><tr><th>Когда</th><th>Действие</th><th>Кто</th><th>Подробности</th></tr></thead>
|
||||
<tbody>{failedActions.map((event, index) => (
|
||||
<tr key={`${event.at}-${index}`}>
|
||||
<th>{new Date(event.at).toLocaleString("ru-RU")}</th>
|
||||
<td>{event.action}</td><td>{event.actor}</td><td>{event.detail}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
)}
|
||||
<details>
|
||||
<summary>Предупреждения процесса ({systemEvents.length})</summary>
|
||||
{systemEvents.length === 0 ? <p>Предупреждений после запуска нет.</p> : (
|
||||
<table className="grid">
|
||||
<thead><tr><th>Когда</th><th>Уровень</th><th>Источник</th><th>Сообщение</th></tr></thead>
|
||||
<tbody>{systemEvents.map((event, index) => (
|
||||
<tr key={`${event.at}-${index}`}>
|
||||
<th>{new Date(event.at).toLocaleString("ru-RU")}</th>
|
||||
<td>{event.level}</td><td>{event.source}</td><td>{event.message}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
)}
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Status() {
|
||||
const status = useStatus();
|
||||
return (
|
||||
|
|
@ -104,21 +166,22 @@ function Users() {
|
|||
<tbody>
|
||||
<tr>
|
||||
<th>Логин</th>
|
||||
<td><input value={form.login} onChange={(e) => setForm({ ...form, login: e.target.value })} /></td>
|
||||
<td><input value={form.login} maxLength={80} onChange={(e) => setForm({ ...form, login: e.target.value })} /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>ФИО</th>
|
||||
<td><input value={form.full_name} onChange={(e) => setForm({ ...form, full_name: e.target.value })} /></td>
|
||||
<td><input value={form.full_name} maxLength={120} onChange={(e) => setForm({ ...form, full_name: e.target.value })} /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Пароль</th>
|
||||
<td>
|
||||
<input
|
||||
type="password"
|
||||
maxLength={1024}
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>{" "}
|
||||
не короче восьми знаков
|
||||
от восьми до 1024 знаков
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
|
@ -182,20 +245,24 @@ function Backups() {
|
|||
|
||||
function Audit() {
|
||||
const [action, setAction] = useState("");
|
||||
const audit = useAudit(action);
|
||||
const [actor, setActor] = useState("");
|
||||
const [offset, setOffset] = useState(0);
|
||||
const audit = useAudit(action, actor, offset);
|
||||
const pageSize = 200;
|
||||
const rows = audit.data ?? [];
|
||||
return (
|
||||
<>
|
||||
<h2>Журнал действий</h2>
|
||||
<p>
|
||||
Фильтр по действию:{" "}
|
||||
<input value={action} placeholder="lesson.start" onChange={(e) => setAction(e.target.value)} />
|
||||
Действие: <input value={action} placeholder="login.failed" onChange={(e) => { setAction(e.target.value); setOffset(0); }} />
|
||||
{" "}Пользователь: <input value={actor} placeholder="логин" onChange={(e) => { setActor(e.target.value); setOffset(0); }} />
|
||||
</p>
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr><th>Когда</th><th>Кто</th><th>Действие</th><th>Над чем</th><th>Подробности</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(audit.data ?? []).map((row, index) => (
|
||||
{rows.slice(0, pageSize).map((row, index) => (
|
||||
<tr key={index}>
|
||||
<th>{new Date(row.at).toLocaleString("ru-RU")}</th>
|
||||
<td>{row.actor}</td>
|
||||
|
|
@ -206,6 +273,11 @@ function Audit() {
|
|||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
<button type="button" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - pageSize))}>Назад</button>{" "}
|
||||
<span>Записи {rows.length ? offset + 1 : 0}–{offset + Math.min(rows.length, pageSize)}</span>{" "}
|
||||
<button type="button" disabled={rows.length <= pageSize} onClick={() => setOffset(offset + pageSize)}>Раньше</button>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,32 @@ export interface AuditRow {
|
|||
at: string; actor: string; role: string; action: string; object_id: string | null; detail: string;
|
||||
}
|
||||
export interface Backup { name: string; size_bytes: number; at: string }
|
||||
export interface RuntimeMetrics {
|
||||
at: string;
|
||||
uptime_seconds: number;
|
||||
cpu_percent: number;
|
||||
load_1m_percent: number | null;
|
||||
cpu_cores: number;
|
||||
rss_bytes: number | null;
|
||||
memory_total_bytes: number | null;
|
||||
memory_available_bytes: number | null;
|
||||
disk_total_bytes: number;
|
||||
disk_free_bytes: number;
|
||||
threads: number;
|
||||
active_sessions: number;
|
||||
restored_sessions: number;
|
||||
completed_sessions_24h: number;
|
||||
}
|
||||
export interface DiagnosticEvent { at: string; level: string; source: string; message: string }
|
||||
export interface FailureEvent {
|
||||
at: string; actor: string; action: string; object_id: string | null; detail: string;
|
||||
}
|
||||
export interface DiagnosticReport {
|
||||
generated_at: string;
|
||||
metrics: RuntimeMetrics;
|
||||
recent_system_events: DiagnosticEvent[];
|
||||
failed_actions_24h: FailureEvent[];
|
||||
}
|
||||
|
||||
async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
|
|
@ -44,10 +70,22 @@ export const useStatus = () =>
|
|||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
export const useAudit = (action: string) =>
|
||||
export const useDiagnostics = () =>
|
||||
useQuery({
|
||||
queryKey: ["admin-audit", action],
|
||||
queryFn: () => call<AuditRow[]>(`/api/admin/audit${action ? `?action=${encodeURIComponent(action)}` : ""}`),
|
||||
queryKey: ["admin-diagnostics"],
|
||||
queryFn: () => call<DiagnosticReport>("/api/admin/diagnostics"),
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
export const useAudit = (action: string, actor: string, offset: number) =>
|
||||
useQuery({
|
||||
queryKey: ["admin-audit", action, actor, offset],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ limit: "201", offset: String(offset) });
|
||||
if (action) params.set("action", action);
|
||||
if (actor) params.set("actor", actor);
|
||||
return call<AuditRow[]>(`/api/admin/audit?${params.toString()}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const useBackups = () =>
|
||||
|
|
|
|||
|
|
@ -1,36 +1,40 @@
|
|||
// Учебное АРМ ДДС. Звонок бригаде имитируется внутри приложения, без SIP.
|
||||
// Учебное АРМ ДДС. Проверяемый текстовый протокол дополняется настоящим
|
||||
// локальным SIP/WebRTC-телефоном; оба контура явно подписаны в интерфейсе.
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { GROUPS } from "@/features/kio-card/fields";
|
||||
import { KioCard } from "@/features/kio-card/KioCard";
|
||||
import { applyState, empty as emptyCard, readPath, type CardState } from "@/features/kio-card/merge";
|
||||
import { SipPhone } from "@/features/sip/SipPhone";
|
||||
import { sessionIdFromUrl } from "@/shared/api/session";
|
||||
import { stationChannel, type ChannelStatus } from "@/shared/api/ws";
|
||||
import type { ServiceStatus, SessionReport, StationSnapshot, StationToServer } from "@/shared/types/generated";
|
||||
import type { DdsQueueCard, ServiceStatus, SessionReport, StationSnapshot, StationToServer } from "@/shared/types/generated";
|
||||
import { ALARMING, CARD_STATUS_LABELS, SERVICE_STATUS_LABELS } from "./statuses";
|
||||
|
||||
const ACK_LIMIT_S = 30;
|
||||
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 CompletedCard { card_id: string; scenario_id: string; score_auto: number }
|
||||
interface DdsSnapshot extends StationSnapshot {
|
||||
recipient_services?: string[];
|
||||
managed_service?: string | null;
|
||||
crew_options?: string[];
|
||||
crew_selected?: string | null;
|
||||
phone_reports?: PhoneReport[];
|
||||
phone_lines?: PhoneLine[];
|
||||
phone_pending?: PhonePending | null;
|
||||
card_id?: string | null;
|
||||
card_index?: number;
|
||||
card_total?: number;
|
||||
reply_text?: string;
|
||||
completed_cards?: CompletedCard[];
|
||||
queue_cards?: DdsQueueCard[];
|
||||
}
|
||||
const REPORT_LABELS: Record<ReportPhase, string> = {
|
||||
dispatched: "Доклад о выезде", arrived: "Доклад о прибытии",
|
||||
working: "Доклад о ходе работ", completed: "Доклад о завершении работ",
|
||||
};
|
||||
const FIELDS = GROUPS.flatMap((group) =>
|
||||
group.fields.filter((item) => !item.readOnly).map((item) => ({ path: item.path, label: item.label })),
|
||||
);
|
||||
|
||||
function field(card: CardState, path: string): string {
|
||||
const value = readPath(card.server, path);
|
||||
return value === null || value === undefined || value === "" ? "—" : String(value);
|
||||
|
|
@ -60,19 +64,19 @@ export function Dds() {
|
|||
const [station, setStation] = useState<DdsSnapshot | null>(null);
|
||||
const [lastReport, setLastReport] = useState<PhoneReport | null>(null);
|
||||
const [dialing, setDialing] = useState(false);
|
||||
const [brief, setBrief] = useState({ address: "", incident: "", request: "" });
|
||||
const [checkText, setCheckText] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [ended, setEnded] = useState(false);
|
||||
const [report, setReport] = useState<SessionReport | null>(null);
|
||||
const [reportState, setReportState] = useState<"idle" | "loading" | "ready" | "error">("idle");
|
||||
const [reportError, setReportError] = useState("");
|
||||
const [missing, setMissing] = useState<string[]>([]);
|
||||
const [bounceComment, setBounceComment] = useState("");
|
||||
const [bounced, setBounced] = useState(false);
|
||||
const [zone, setZone] = useState<boolean | null>(null);
|
||||
const [reply, setReply] = useState({ text: "", dirty: false });
|
||||
const [advancePending, setAdvancePending] = useState(false);
|
||||
const channel = useRef<ReturnType<typeof stationChannel> | null>(null);
|
||||
const activeCardId = useRef<string | null>(null);
|
||||
const openCardAfterSwitch = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
|
@ -104,6 +108,13 @@ export function Dds() {
|
|||
if (event.type === "station.state") {
|
||||
const snapshot = event.snapshot as DdsSnapshot;
|
||||
setStation(snapshot);
|
||||
if (snapshot.card_id == null && snapshot.pending_cards_count > 0) {
|
||||
activeCardId.current = null;
|
||||
setCard(null);
|
||||
setView("registry");
|
||||
setPane(null);
|
||||
setAdvancePending(false);
|
||||
}
|
||||
setActiveService((previous) => previous && snapshot.services.includes(previous)
|
||||
? previous : snapshot.services[0] ?? null);
|
||||
setReply((previous) => {
|
||||
|
|
@ -121,13 +132,13 @@ export function Dds() {
|
|||
setReply({ text: "", dirty: false });
|
||||
setLastReport(null);
|
||||
setDialing(false);
|
||||
setMissing([]);
|
||||
setBounceComment("");
|
||||
setBounced(false);
|
||||
setBrief({ address: "", incident: "", request: "" });
|
||||
setCheckText("");
|
||||
setZone(null);
|
||||
setReport(null);
|
||||
setReportState("idle");
|
||||
setView("registry");
|
||||
setView(openCardAfterSwitch.current ? "card" : "registry");
|
||||
openCardAfterSwitch.current = false;
|
||||
setPane(null);
|
||||
setActiveService(null);
|
||||
}
|
||||
|
|
@ -145,8 +156,11 @@ export function Dds() {
|
|||
else if (event.type === "phone.report") {
|
||||
setLastReport(event);
|
||||
setDialing(false);
|
||||
setBrief({ address: "", incident: "", request: "" });
|
||||
setCheckText("");
|
||||
setError("");
|
||||
}
|
||||
else if (event.type === "phone.line") setDialing(false);
|
||||
},
|
||||
}).connect();
|
||||
channel.current = ch;
|
||||
|
|
@ -173,18 +187,21 @@ export function Dds() {
|
|||
|
||||
const reports = station?.phone_reports ?? [];
|
||||
const selectedCrew = station?.crew_selected ?? "";
|
||||
const latest = lastReport ?? reports.at(-1) ?? null;
|
||||
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 isConnected = status === "open";
|
||||
const cardId = card ? String(readPath(card.server, "card_id") ?? "") : "";
|
||||
const cardIndex = station?.card_index ?? 1;
|
||||
const cardTotal = station?.card_total ?? 1;
|
||||
const completedCards = station?.completed_cards ?? [];
|
||||
const activeIsCompleted = completedCards.some((item) => item.card_id === cardId);
|
||||
const queueCards = station?.queue_cards ?? [];
|
||||
const query = search.trim().toLocaleLowerCase("ru-RU");
|
||||
const visibleCompletedCards = completedCards.filter((item) =>
|
||||
!query || `${item.card_id} ${item.scenario_id}`.toLocaleLowerCase("ru-RU").includes(query));
|
||||
const showActive = card && !activeIsCompleted && (!query ||
|
||||
[cardId, field(card, "address"), field(card, "description"), field(card, "incident_type")]
|
||||
const visibleQueueCards = queueCards.filter((item) => !query ||
|
||||
[item.card_id, item.address ?? "", item.description ?? "", item.incident_type ?? "", item.title]
|
||||
.some((value) => value.toLocaleLowerCase("ru-RU").includes(query)));
|
||||
const crewAccepted = Boolean(station?.services.some((service) =>
|
||||
selectedCrew.startsWith(`${service} —`) &&
|
||||
|
|
@ -271,23 +288,55 @@ export function Dds() {
|
|||
<td data-label="Тип происшествия">Сценарий {item.scenario_id}</td><td data-label="Постр.">—</td><td data-label="Адрес">—</td>
|
||||
<td data-label="Статус службы">Отработана · {item.score_auto.toFixed(0)} / 100</td>
|
||||
</tr>)}
|
||||
{showActive && card && <tr className="dds-selected" aria-current="true">
|
||||
<td data-label="Связи"><button type="button" className="dds-row-open" onClick={() => setView("card")} aria-label="Открыть карточку">▾</button></td>
|
||||
<td data-label="ЧС">{field(card, "is_emergency") === "true" ? "Да" : "Нет"}</td>
|
||||
{visibleQueueCards.map((item) => {
|
||||
const received = new Date(item.received_at);
|
||||
const liveElapsed = item.timer_stopped ? item.elapsed_ms / 1000 : Math.max(
|
||||
item.elapsed_ms / 1000, (clockNow.getTime() - received.getTime()) / 1000,
|
||||
);
|
||||
const openCard = () => {
|
||||
if (item.active) {
|
||||
setView("card");
|
||||
return;
|
||||
}
|
||||
openCardAfterSwitch.current = true;
|
||||
if (!send({ type: "card.open", card_id: item.card_id })) {
|
||||
openCardAfterSwitch.current = false;
|
||||
}
|
||||
};
|
||||
return <tr key={item.card_id} className={item.active ? "dds-selected" : ""}
|
||||
aria-current={item.active ? "true" : undefined}>
|
||||
<td data-label="Связи"><button type="button" className="dds-row-open" onClick={openCard} aria-label="Открыть карточку">▾</button></td>
|
||||
<td data-label="ЧС">—</td>
|
||||
<td data-label="Опер.">0</td><td data-label="АРМ">ДДС</td>
|
||||
<td data-label="Номер"><button type="button" className="dds-row-link" onClick={() => setView("card")}>{cardId.slice(0, 8).toUpperCase()}</button></td>
|
||||
<td data-label="Дата">{receivedAt ? new Date(receivedAt).toLocaleDateString("ru-RU") : "—"}</td>
|
||||
<td data-label="Время">{receivedAt ? new Date(receivedAt).toLocaleTimeString("ru-RU") : "—"}</td>
|
||||
<td data-label="Тип происшествия">{field(card, "incident_code")} · {field(card, "incident_type")}
|
||||
<small>Описание: {field(card, "description")}</small></td>
|
||||
<td data-label="Постр.">{field(card, "victims_count")}</td>
|
||||
<td data-label="Адрес">{field(card, "address")}</td>
|
||||
<td data-label="Статус службы">{station?.services.map((service) => `${service}: ${SERVICE_STATUS_LABELS[station.statuses[service]]}`).join("; ") || "Добавлена"}</td>
|
||||
</tr>}
|
||||
{!showActive && !visibleCompletedCards.length && <tr><td colSpan={11} className="dds-empty">
|
||||
<td data-label="Номер"><button type="button" className="dds-row-link" onClick={openCard}>{item.card_id.slice(0, 8).toUpperCase()}</button></td>
|
||||
<td data-label="Дата">{received.toLocaleDateString("ru-RU")}</td>
|
||||
<td data-label="Время">{received.toLocaleTimeString("ru-RU")}</td>
|
||||
<td data-label="Тип происшествия"><span className="dds-cell-value">
|
||||
<span>{item.title} · {item.incident_type || "тип не указан"}</span>
|
||||
<small>Описание: {item.description || "—"}</small>
|
||||
</span></td>
|
||||
<td data-label="Постр.">{item.victims_count ?? "—"}</td>
|
||||
<td data-label="Адрес">{item.address || "—"}</td>
|
||||
<td data-label="Статус службы"><span className="dds-cell-value">
|
||||
<span>{item.managed_service || "ДДС"}: {SERVICE_STATUS_LABELS[item.service_status]}</span>
|
||||
<small className={!item.timer_stopped && liveElapsed * 1000 > item.limit_ms ? "state-violated" : ""}>
|
||||
{item.timer_stopped ? "решение за" : "до первичного решения"} {liveElapsed.toFixed(0)} с
|
||||
</small>
|
||||
</span></td>
|
||||
</tr>})}
|
||||
{!visibleQueueCards.length && !visibleCompletedCards.length && <tr><td colSpan={11} className="dds-empty">
|
||||
{query ? "По запросу ничего не найдено." : "Ожидание готовой карточки от преподавателя или оператора 112…"}</td></tr>}
|
||||
</tbody></table></div>
|
||||
{cardTotal > cardIndex && <p className="dds-hint">Остальные {cardTotal - cardIndex} карточки поступят после завершения текущей.</p>}
|
||||
{queueCards.length > 1 && <p className="dds-hint">Одновременно в работе {queueCards.length} карточек. Таймер каждой идёт независимо от того, какая строка открыта.</p>}
|
||||
{station && station.pending_cards_count > 0 && <p className="dds-hint" role="status" aria-live="polite">
|
||||
{station.next_arrival_in_seconds === null
|
||||
? `Осталось карточек: ${station.pending_cards_count}. Все карточки уже поступили; необработанные находятся в реестре.`
|
||||
: station.next_arrival_in_seconds > 0
|
||||
? `Осталось карточек: ${station.pending_cards_count}. Следующее поступление примерно через ${station.next_arrival_in_seconds} с.`
|
||||
: queueCards.filter((item) => !item.active).length >= station.max_waiting_cards
|
||||
? `Осталось карточек: ${station.pending_cards_count}. Очередь ожидания заполнена (${station.max_waiting_cards}); новые поступят, когда освободится место.`
|
||||
: `Осталось карточек: ${station.pending_cards_count}. Ожидается следующее поступление.`}
|
||||
</p>}
|
||||
</section>
|
||||
</>}
|
||||
|
||||
|
|
@ -298,7 +347,7 @@ export function Dds() {
|
|||
<div className="dds-call-off"><span className="dds-call-icon">☎</span><strong>Отключение</strong><small>записи звонков · список SMS</small></div>
|
||||
<div><span className="dds-call-icon">☎</span><small>АОН</small><strong>{field(card, "caller_number")}</strong></div>
|
||||
<div><span className="dds-call-icon">☎</span><small>предоставленный</small><strong>{field(card, "caller_contact")}</strong></div>
|
||||
<div><span className="dds-call-icon">☎</span><small>телефон на место</small><strong>{field(card, "caller_contact")}</strong></div>
|
||||
<div><span className="dds-call-icon">☎</span><small>телефон на место</small><strong>{field(card, "phone_on_scene")}</strong></div>
|
||||
<div className="dds-call-incident"><strong>Происшествие {cardId.slice(0, 8).toUpperCase()}</strong>
|
||||
<small>Сохр. {receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "—"}</small>
|
||||
<small>Опер., АРМ ДДС</small></div>
|
||||
|
|
@ -314,7 +363,8 @@ export function Dds() {
|
|||
<div className="dds-case-classification"><strong>Происшествие {field(card, "incident_code")}</strong>
|
||||
<div><b>{field(card, "description")}</b></div>
|
||||
<div>Класс: <b>{field(card, "incident_type")}</b></div>
|
||||
<div>Список оповещения: <b>{station?.services.join(", ") || field(card, "notify")}</b></div></div>
|
||||
<div>Получатели карточки: <b>{station?.recipient_services?.join(", ") || field(card, "notify")}</b></div>
|
||||
<div>Ваша ДДС: <b>{station?.managed_service || "служба не назначена"}</b></div></div>
|
||||
</div>
|
||||
<div className="dds-case-stage">
|
||||
{pane && <div className={`dds-case-drawer dds-case-drawer-${pane}`}>
|
||||
|
|
@ -348,49 +398,75 @@ export function Dds() {
|
|||
{station.services.filter((service) => service === (activeService ?? station.services[0])).map((service) =>
|
||||
<StatusRow key={`${cardId}:${service}`} service={service}
|
||||
current={station.statuses[service]} available={station.available[service] ?? []}
|
||||
disabled={!isConnected || ended} reports={reports.filter((report) => report.service === service)}
|
||||
phoneExercise={from === "учебный сценарий"}
|
||||
disabled={!isConnected || ended}
|
||||
crewOptions={station.crew_options?.filter((crew) => crew.startsWith(`${service} —`)) ?? []}
|
||||
selectedCrew={selectedCrew.startsWith(`${service} —`) ? selectedCrew : ""}
|
||||
onCrew={(crew) => send({ type: "crew.select", crew })}
|
||||
onSet={(next, comment) => channel.current?.send({ type: "card.status", service, status: next, comment })} />)}
|
||||
</> : <p className="dds-empty">Ожидаем состав служб по карточке.</p>}
|
||||
<p className="dds-hint">Сначала примите карточку или укажите причину отказа. Последующие статусы в занятии ДДС подтверждаются докладами по телефону.</p>
|
||||
<p className="dds-hint">Сначала примите карточку или укажите причину отказа. Последующие статусы ставятся по фактически полученной информации; телефон — один из возможных каналов.</p>
|
||||
</section>}
|
||||
{pane === "journal" && station && station.log.length > 0 && <section className="dds-panel"><div className="dds-panel-title"><h2>Журнал реагирования</h2></div>
|
||||
<div className="dds-table-wrap"><table className="dds-registry"><thead><tr><th>Время</th><th>Служба</th><th>Статус</th><th>Комментарий</th></tr></thead>
|
||||
<tbody>{station.log.map((entry, index) => <tr key={`${entry.at}-${index}`}><td>{time(entry.at)}</td><td>{entry.service}</td>
|
||||
<td>{SERVICE_STATUS_LABELS[entry.status]}</td><td>{entry.comment || "—"}</td></tr>)}</tbody></table></div></section>}
|
||||
</div><aside className="dds-right">
|
||||
{pane === "phone" && <section className="dds-panel dds-phone"><div className="dds-panel-title"><h2>IP-телефон</h2><span>Учебная имитация</span></div>
|
||||
<p className="dds-hint">Выберите бригаду вручную, свяжитесь со старшим группы и внесите его доклад в карточку.</p>
|
||||
{pane === "phone" && <section className="dds-panel dds-phone"><div className="dds-panel-title"><h2>IP-телефон</h2><span>SIP/WebRTC + учебный протокол</span></div>
|
||||
<SipPhone defaultExtension="6101" defaultTarget="6102" title="Реальный локальный канал ДДС" />
|
||||
<p className="dds-hint">На втором рабочем месте откройте <a href="/phone" target="_blank" rel="noreferrer">телефон старшего группы</a> и зарегистрируйте 6102. Пароли 6101/6102 выдаёт локальный администратор командой <code>make sip-credentials</code>.</p>
|
||||
<h3 className="dds-protocol-title">Учебный протокол доклада</h3>
|
||||
<p className="dds-hint">Связь используется при необходимости. Разговор и учебный доклад сохраняются в журнале, но не блокируют изменение статуса.</p>
|
||||
<label htmlFor="dds-crew">Дежурная бригада</label>
|
||||
<select id="dds-crew" value={selectedCrew} disabled={!isConnected || ended || !station?.crew_options?.length}
|
||||
onChange={(event) => send({ type: "crew.select", crew: event.target.value })}>
|
||||
<option value="">Выберите бригаду</option>
|
||||
{station?.crew_options?.map((crew) => <option key={crew} value={crew}>{crew}</option>)}
|
||||
</select>
|
||||
<button type="button" className="dds-dial" disabled={!crewAccepted || !isConnected || ended || dialing}
|
||||
<button type="button" className="dds-dial" disabled={!crewAccepted || !isConnected || ended || dialing || Boolean(phoneCall)}
|
||||
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">
|
||||
<strong>● Соединение установлено · {phoneCall.crew}</strong>
|
||||
{phoneCall.phase === "dispatched" ? <div className="dds-phone-form">
|
||||
<p>Передайте сведения из карточки и задачу бригаде. Доклад о выезде появится только после передачи.</p>
|
||||
<label htmlFor="dds-phone-address">Полный адрес из карточки</label>
|
||||
<input id="dds-phone-address" value={brief.address} onChange={(event) => setBrief({ ...brief, address: event.target.value })} placeholder="Улица, дом, при наличии квартира и этаж" />
|
||||
<label htmlFor="dds-phone-incident">Характер происшествия</label>
|
||||
<textarea id="dds-phone-incident" rows={2} value={brief.incident} onChange={(event) => setBrief({ ...brief, incident: event.target.value })} placeholder="Что случилось и важные обстоятельства" />
|
||||
<label htmlFor="dds-phone-request">Что требуется от бригады</label>
|
||||
<input id="dds-phone-request" value={brief.request} onChange={(event) => setBrief({ ...brief, request: event.target.value })} placeholder="Подтвердите выезд и доложите о прибытии" />
|
||||
<button type="button" disabled={!isConnected || ended || !brief.address.trim() || !brief.incident.trim() || !brief.request.trim()}
|
||||
onClick={() => send({ type: "phone.brief", ...brief })}>Передать сообщение</button>
|
||||
</div> : <div className="dds-phone-form">
|
||||
<p>Уточните обстановку у старшего группы. Ответ станет основанием для следующего статуса.</p>
|
||||
<label htmlFor="dds-phone-check">Ваш вопрос старшему</label>
|
||||
<textarea id="dds-phone-check" rows={2} value={checkText} onChange={(event) => setCheckText(event.target.value)} placeholder="Сообщите, пожалуйста, текущее состояние работ по карточке" />
|
||||
<button type="button" disabled={!isConnected || ended || checkText.trim().length < 8}
|
||||
onClick={() => send({ type: "phone.check", text: checkText })}>Спросить обстановку</button>
|
||||
</div>}
|
||||
<button type="button" className="dds-phone-hangup" onClick={() => send({ type: "phone.hangup" })}>Завершить без доклада</button>
|
||||
</div>}
|
||||
{phoneLines.length > 0 && <div className="dds-phone-transcript" aria-live="polite"><strong>Учебный разговор</strong>
|
||||
{phoneLines.map((line, index) => <p key={`${line.at}-${index}`}><small>{time(line.at)} · {line.speaker === "crew" ? "Старший группы" : "Диспетчер"}</small><br />{line.text}</p>)}
|
||||
</div>}
|
||||
{latest ? <div className="dds-report" aria-live="polite"><small>{REPORT_LABELS[latest.phase]} · {time(latest.at)}</small>
|
||||
<strong>{latest.crew}</strong><p>{latest.text}</p>
|
||||
<span>Прослушайте доклад и поставьте соответствующий статус реагирования.</span></div>
|
||||
: <div className="dds-phone-idle">Доклады поступят после исходящего учебного звонка.</div>}
|
||||
: <div className="dds-phone-idle">Доклад появится после передачи сведений и ответа старшего группы.</div>}
|
||||
{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>}
|
||||
</section>}
|
||||
{pane === "reply" && <section className="dds-panel dds-reply">
|
||||
<div className="dds-panel-title"><h2>Ответ и комментарий диспетчера</h2>
|
||||
<div className="dds-panel-title"><h2>Рабочая заметка диспетчера</h2>
|
||||
<span>{reply.dirty ? "Не сохранено" : "Сохранено"}</span></div>
|
||||
<label htmlFor="dds-reply-text">Свободный текст по текущей карточке</label>
|
||||
<label htmlFor="dds-reply-text">Необязательная заметка по текущей карточке</label>
|
||||
<textarea id="dds-reply-text" rows={7} value={reply.text}
|
||||
disabled={!isConnected || ended || advancePending}
|
||||
placeholder="Что сообщено старшему группы, какие сведения получены и почему принято решение"
|
||||
onChange={(event) => setReply({ text: event.target.value, dirty: true })} />
|
||||
<p className="dds-hint">Ответ сохраняется в журнале текущей карточки. Автооценка проверяет его наличие и минимальную полноту, но не грамотность или фактическую верность каждого предложения.</p>
|
||||
<p className="dds-hint">Заметка сохраняется в журнале, но не влияет на оценку. Обязательный комментарий вводится непосредственно при статусе отказа.</p>
|
||||
<button type="button" disabled={!cardId || !reply.dirty || !isConnected || ended || advancePending}
|
||||
onClick={() => { if (send({ type: "card.reply", card_id: cardId, text: reply.text })) setError(""); }}>
|
||||
Сохранить ответ</button>
|
||||
|
|
@ -400,20 +476,13 @@ export function Dds() {
|
|||
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: true }); setZone(true); }}>Наша</button>
|
||||
<button type="button" disabled={zone !== null || !isConnected || ended}
|
||||
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: false }); setZone(false); }}>Не наша</button></div>
|
||||
<details className="dds-details"><summary>Вернуть карточку на уточнение</summary>
|
||||
<div className="bounce-fields">{FIELDS.map((item) => <label key={item.path}><input type="checkbox" checked={missing.includes(item.path)}
|
||||
onChange={() => setMissing((previous) => previous.includes(item.path) ? previous.filter((path) => path !== item.path) : [...previous, item.path])} /> {item.label}</label>)}</div>
|
||||
<input type="text" value={bounceComment} placeholder="Что необходимо уточнить" onChange={(event) => setBounceComment(event.target.value)} />
|
||||
<button type="button" disabled={!missing.length || bounced || !isConnected || ended} onClick={() => {
|
||||
channel.current?.send({ type: "card.bounce", missing_fields: missing, comment: bounceComment }); setBounced(true);
|
||||
}}>Вернуть оператору</button>
|
||||
</details>
|
||||
<p className="dds-hint">ДДС не исправляет и не возвращает заполненную карточку: её качество контролируют оператор и служба контроля 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;
|
||||
if (send({ type: "card.next", card_id: cardId })) setAdvancePending(true);
|
||||
}}>
|
||||
{cardIndex < cardTotal ? "Завершить карточку и открыть следующую" : "Завершить карточку и занятие"}
|
||||
{queueCards.length > 1 ? "Закрыть карточку" : "Закрыть карточку и занятие"}
|
||||
</button>
|
||||
{cardTotal > 1 && <button type="button" disabled={!isConnected || ended || advancePending}
|
||||
onClick={() => { if (window.confirm("Завершить занятие досрочно? Остальные карточки не будут пройдены.")) send({ type: "station.finish" }); }}>
|
||||
|
|
@ -434,25 +503,20 @@ export function Dds() {
|
|||
onClick={() => {
|
||||
if (reply.dirty && !send({ type: "card.reply", card_id: cardId, text: reply.text })) return;
|
||||
if (send({ type: "card.next", card_id: cardId })) setAdvancePending(true);
|
||||
}}><b>✓ Завершить</b><small>{cardIndex < cardTotal ? "Следующая карточка" : "Занятие"}</small></button>
|
||||
}}><b>✓ Завершить</b><small>{queueCards.length > 1 ? "Закрыть карточку" : "Занятие"}</small></button>
|
||||
</nav>
|
||||
</div>}
|
||||
</main>;
|
||||
}
|
||||
|
||||
function StatusRow({ service, current, available, disabled, reports, phoneExercise, crewOptions, selectedCrew, onCrew, onSet }: {
|
||||
function StatusRow({ service, current, available, disabled, crewOptions, selectedCrew, onCrew, onSet }: {
|
||||
service: string; current: ServiceStatus; available: ServiceStatus[]; disabled: boolean;
|
||||
reports: PhoneReport[]; phoneExercise: boolean; crewOptions: string[]; selectedCrew: string;
|
||||
crewOptions: string[]; selectedCrew: string;
|
||||
onCrew: (crew: string) => void; onSet: (status: ServiceStatus, comment: string) => void;
|
||||
}) {
|
||||
const [chosen, setChosen] = useState<ServiceStatus | "">("");
|
||||
const [comment, setComment] = useState("");
|
||||
const needsComment = chosen === "declined" || chosen === "refused";
|
||||
const reportNeeded: Partial<Record<ServiceStatus, ReportPhase>> = {
|
||||
responding: "dispatched", arrived: "arrived", working: "working", completed: "completed",
|
||||
};
|
||||
const matchingReport = !chosen || !phoneExercise || !reportNeeded[chosen] ||
|
||||
reports.some((report) => report.phase === reportNeeded[chosen]);
|
||||
if (!available.length) return <p className="dds-hint">{serviceTitle(service)}: {SERVICE_STATUS_LABELS[current]}. Дальнейшие статусы недоступны.</p>;
|
||||
return <div className="dds-status-editor">
|
||||
<label>Статус
|
||||
|
|
@ -475,10 +539,9 @@ function StatusRow({ service, current, available, disabled, reports, phoneExerci
|
|||
onChange={(event) => setComment(event.target.value)} />
|
||||
</label>
|
||||
<button type="button" className="dds-status-save" aria-label="Сохранить статус"
|
||||
disabled={disabled || !chosen || (needsComment && !comment.trim()) || !matchingReport}
|
||||
disabled={disabled || !chosen || (needsComment && !comment.trim())}
|
||||
onClick={() => { if (chosen) { onSet(chosen, comment.trim()); setChosen(""); setComment(""); } }}>✓</button>
|
||||
<button type="button" className="dds-status-cancel" aria-label="Отменить изменение статуса"
|
||||
onClick={() => { setChosen(""); setComment(""); }}>×</button>
|
||||
{chosen && !matchingReport && <span className="dds-status-warning state-warn">Сначала получите доклад по телефону.</span>}
|
||||
</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAssignTrainee, useCreateGroup, useGroupAnalytics, useGroups, useTrainees } from "@/shared/api/http";
|
||||
import { useAssignTrainee, useCreateGroup, useGroupAnalytics, useGroupInsight, useGroups, useTrainees } from "@/shared/api/http";
|
||||
|
||||
export function Groups() {
|
||||
const groups = useGroups();
|
||||
|
|
@ -11,6 +11,7 @@ export function Groups() {
|
|||
const [groupName, setGroupName] = useState("");
|
||||
const [traineeId, setTraineeId] = useState("");
|
||||
const report = useGroupAnalytics(groupId);
|
||||
const insight = useGroupInsight();
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupId && groups.data?.length) setGroupId(groups.data[0].id);
|
||||
|
|
@ -69,9 +70,22 @@ export function Groups() {
|
|||
</p>
|
||||
<p className="ref">
|
||||
Доли ошибок считаются среди курсантов с оценёнными попытками; повторные попытки
|
||||
одного человека не увеличивают долю. Рекомендации сформированы по правилам,
|
||||
не нейросетью.
|
||||
одного человека не увеличивают долю. Табличные рекомендации всегда формируются
|
||||
воспроизводимыми правилами.
|
||||
</p>
|
||||
<section className="group-insight">
|
||||
<button type="button" disabled={!report.data.scored_attempts || insight.isPending}
|
||||
onClick={() => groupId && insight.mutate(groupId)}>
|
||||
{insight.isPending ? "Qwen анализирует агрегаты…" : "Сформировать локальный ИИ-инсайт"}
|
||||
</button>
|
||||
<span className="ref">В модель передаются только агрегаты без ФИО и попыток отдельных курсантов.</span>
|
||||
{insight.data && <div>
|
||||
<strong>Вывод Qwen</strong>
|
||||
<p>{insight.data.summary}</p>
|
||||
<ol>{insight.data.priorities.map((item) => <li key={item}>{item}</li>)}</ol>
|
||||
</div>}
|
||||
{insight.isError && <p className="warn">ИИ-инсайт не сформирован; табличная аналитика остаётся доступна.</p>}
|
||||
</section>
|
||||
<table className="grid">
|
||||
<thead><tr><th>Типичная ошибка</th><th>Курсанты</th><th>Рекомендация</th></tr></thead>
|
||||
<tbody>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { useObserve } from "@/features/observe/useObserve";
|
|||
import { InterviewTimer, Timers } from "@/features/timers/Timers";
|
||||
import { useIncidentGroups, useScenarios, useSessions, useTrainees } from "@/shared/api/http";
|
||||
import { sessionIdFromUrl } from "@/shared/api/session";
|
||||
import type { SessionMode } from "@/shared/types/generated";
|
||||
import type { SessionMode, SessionReport } from "@/shared/types/generated";
|
||||
|
||||
const MODES: { value: SessionMode; label: string }[] = [
|
||||
{ value: "training", label: "тренировочный" },
|
||||
|
|
@ -34,6 +34,14 @@ export function Instructor() {
|
|||
const [extraScenarioIds, setExtraScenarioIds] = useState<string[]>([]);
|
||||
const [mode, setMode] = useState<SessionMode>("training");
|
||||
const [exercise, setExercise] = useState<"dds" | "call" | "card" | "card_dds">("dds");
|
||||
const [decisionTimeLimit, setDecisionTimeLimit] = useState(30);
|
||||
const [allowedErrors, setAllowedErrors] = useState(0);
|
||||
const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true);
|
||||
const [scoreWeightsText, setScoreWeightsText] = useState("{}");
|
||||
const [scoreWeightsError, setScoreWeightsError] = useState("");
|
||||
const [lessonScoreWeights, setLessonScoreWeights] = useState<Record<string, number>>({});
|
||||
const [ddsArrivalIntervalSeconds, setDdsArrivalIntervalSeconds] = useState(60);
|
||||
const [ddsMaxWaiting, setDdsMaxWaiting] = useState(3);
|
||||
const [trainee, setTrainee] = useState("");
|
||||
const [traineeId, setTraineeId] = useState("");
|
||||
const [noteFor, setNoteFor] = useState<string | null>(null);
|
||||
|
|
@ -41,6 +49,11 @@ export function Instructor() {
|
|||
// ссылка на идущее занятие не должна запускать его заново и сбрасывать.
|
||||
const toStart = useRef<string | null>(null);
|
||||
const [noteText, setNoteText] = useState("");
|
||||
const [overrideReport, setOverrideReport] = useState<SessionReport | null>(null);
|
||||
const [overrideScore, setOverrideScore] = useState("");
|
||||
const [overrideComment, setOverrideComment] = useState("");
|
||||
const [overrideError, setOverrideError] = useState("");
|
||||
const [overrideBusy, setOverrideBusy] = useState(false);
|
||||
|
||||
const scenarios = useScenarios();
|
||||
const incidentGroups = useIncidentGroups();
|
||||
|
|
@ -49,12 +62,27 @@ export function Instructor() {
|
|||
const session = useObserve(sessionId);
|
||||
const control = useControl(sessionId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session.report) return;
|
||||
setOverrideReport(null);
|
||||
setOverrideScore(String(session.report.score_final));
|
||||
setOverrideComment(session.report.override_comment ?? "");
|
||||
}, [session.report?.session_id, session.report?.score_final, session.report?.override_comment]);
|
||||
|
||||
const visibleScenarios = (scenarios.data ?? []).filter((item) =>
|
||||
(!ticketFilter || item.ticket === Number(ticketFilter)) &&
|
||||
(!categoryFilter || item.ekp_group === Number(categoryFilter)) &&
|
||||
(!scenarioSearch.trim() || `${item.title} ${item.id}`.toLocaleLowerCase("ru-RU")
|
||||
.includes(scenarioSearch.trim().toLocaleLowerCase("ru-RU")))
|
||||
).sort((a, b) => (a.ticket ?? 99) - (b.ticket ?? 99) || (a.position ?? 0) - (b.position ?? 0));
|
||||
const selectedTicketScenarios = ticketFilter
|
||||
? (scenarios.data ?? []).filter((item) => item.ticket === Number(ticketFilter))
|
||||
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
|
||||
: [];
|
||||
const [wholeTicket, setWholeTicket] = useState(false);
|
||||
const canStartSelectedExercise = exercise === "dds" && wholeTicket
|
||||
? selectedTicketScenarios.length === 3
|
||||
: Boolean(scenarioId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visibleScenarios.some((item) => item.id === scenarioId)) {
|
||||
|
|
@ -64,6 +92,21 @@ export function Instructor() {
|
|||
|
||||
function startLesson() {
|
||||
if (trainees.data?.length && !traineeId) return;
|
||||
let parsedWeights: unknown;
|
||||
try {
|
||||
parsedWeights = JSON.parse(scoreWeightsText);
|
||||
} catch {
|
||||
setScoreWeightsError("Введите корректный JSON, например {\"dds_ack\": 2}.");
|
||||
return;
|
||||
}
|
||||
if (!parsedWeights || typeof parsedWeights !== "object" || Array.isArray(parsedWeights) ||
|
||||
Object.values(parsedWeights).some((value) => typeof value !== "number" ||
|
||||
!Number.isFinite(value) || value < 0 || value > 10)) {
|
||||
setScoreWeightsError("Задайте объект «ключ метрики: вес от 0 до 10».");
|
||||
return;
|
||||
}
|
||||
setScoreWeightsError("");
|
||||
setLessonScoreWeights(parsedWeights as Record<string, number>);
|
||||
const id = crypto.randomUUID();
|
||||
// Номер занятия живёт в адресе: монитор и АРМ курсанта открываются ссылкой.
|
||||
window.history.replaceState(null, "", `/instructor?session=${id}`);
|
||||
|
|
@ -71,18 +114,64 @@ export function Instructor() {
|
|||
setSessionId(id);
|
||||
}
|
||||
|
||||
async function saveScoreOverride(report: SessionReport) {
|
||||
const score = Number(overrideScore);
|
||||
if (!overrideScore.trim() || !Number.isFinite(score) || score < 0 || score > 100 || !overrideComment.trim()) {
|
||||
setOverrideError("Укажите оценку от 0 до 100 и обязательное обоснование.");
|
||||
return;
|
||||
}
|
||||
setOverrideBusy(true);
|
||||
setOverrideError("");
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${report.session_id}/report`, {
|
||||
method: "PATCH",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ score_final: score, comment: overrideComment.trim() }),
|
||||
});
|
||||
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 : "Не удалось сохранить корректировку.");
|
||||
}
|
||||
const updated = data as SessionReport;
|
||||
setOverrideReport(updated);
|
||||
setOverrideScore(String(updated.score_final));
|
||||
setOverrideComment(updated.override_comment ?? "");
|
||||
} catch (cause) {
|
||||
setOverrideError(cause instanceof Error ? cause.message : "Не удалось сохранить корректировку.");
|
||||
} finally {
|
||||
setOverrideBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Занятие запускается, когда канал управления открылся.
|
||||
useEffect(() => {
|
||||
if (!sessionId || control.status !== "open" || toStart.current !== sessionId) return;
|
||||
toStart.current = null;
|
||||
const selected = trainees.data?.find((item) => item.id === traineeId);
|
||||
const primaryScenarioId = exercise === "dds" && wholeTicket
|
||||
? selectedTicketScenarios[0]?.id ?? scenarioId : scenarioId;
|
||||
const scenarioIds = exercise === "dds"
|
||||
? [scenarioId, ...extraScenarioIds.filter((id) => id !== scenarioId)] : undefined;
|
||||
control.start(scenarioId, mode, selected?.name || trainee || "Демо-курсант",
|
||||
? (wholeTicket
|
||||
? selectedTicketScenarios.map((item) => item.id)
|
||||
: [scenarioId, ...extraScenarioIds.filter((id) => id !== scenarioId)])
|
||||
.filter((id, index, all) => id && all.indexOf(id) === index)
|
||||
: undefined;
|
||||
control.start(primaryScenarioId, mode, selected?.name || trainee || "Демо-курсант",
|
||||
exercise === "card_dds" ? "card" : exercise,
|
||||
undefined, selected?.id, scenarioIds && scenarioIds.length > 1 ? scenarioIds : undefined,
|
||||
exercise === "card_dds");
|
||||
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data, exercise]);
|
||||
undefined, selected?.id, selected?.service ?? undefined,
|
||||
scenarioIds && scenarioIds.length > 1 ? scenarioIds : undefined,
|
||||
ddsArrivalIntervalSeconds, ddsMaxWaiting,
|
||||
exercise === "card_dds", {
|
||||
decision_time_limit_seconds: decisionTimeLimit,
|
||||
allowed_errors: allowedErrors,
|
||||
require_correct_grammar: requireCorrectGrammar,
|
||||
score_weights: lessonScoreWeights,
|
||||
});
|
||||
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data,
|
||||
exercise, decisionTimeLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
|
||||
ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights]);
|
||||
|
||||
const links = sessionId
|
||||
? {
|
||||
|
|
@ -97,6 +186,10 @@ export function Instructor() {
|
|||
<StandStatus />
|
||||
<ModeBanner mode={session.mode ?? mode} />
|
||||
<h1>Рабочее место преподавателя</h1>
|
||||
<p className="workspace-links">
|
||||
<a href="#ai-scenario-editor">Перейти к ИИ-формирователю сценария ↓</a>
|
||||
<a href="/materials">Учебные материалы и назначения →</a>
|
||||
</p>
|
||||
|
||||
<div className="instructor-layout">
|
||||
<section>
|
||||
|
|
@ -112,14 +205,17 @@ export function Instructor() {
|
|||
{incidentGroups.data?.groups.map((group) =>
|
||||
<option key={group.id} value={group.id}>{group.title}</option>)}
|
||||
</select>
|
||||
<select aria-label="Билет заказчика" value={ticketFilter} onChange={(event) => setTicketFilter(event.target.value)}>
|
||||
<select aria-label="Билет заказчика" value={ticketFilter} onChange={(event) => {
|
||||
setTicketFilter(event.target.value);
|
||||
setWholeTicket(false);
|
||||
}}>
|
||||
<option value="">Все 32 билета</option>
|
||||
{Array.from({ length: 32 }, (_, index) => <option key={index} value={index + 1}>Билет {index + 1}</option>)}
|
||||
</select>
|
||||
<input type="search" aria-label="Поиск сценария" value={scenarioSearch}
|
||||
placeholder="Название или ID карточки" onChange={(event) => setScenarioSearch(event.target.value)} />
|
||||
</div>
|
||||
<select value={scenarioId} onChange={(event) => {
|
||||
<select value={scenarioId} disabled={exercise === "dds" && wholeTicket} onChange={(event) => {
|
||||
setScenarioId(event.target.value);
|
||||
setExtraScenarioIds((previous) => previous.filter((id) => id !== event.target.value));
|
||||
}}>
|
||||
|
|
@ -131,9 +227,22 @@ export function Instructor() {
|
|||
))}
|
||||
</select>
|
||||
<small className="ref">{visibleScenarios.length} из {scenarios.data?.length ?? 0} карточек (96 — билеты заказчика)</small>
|
||||
{exercise === "dds" && <details className="scenario-queue">
|
||||
{exercise === "dds" && ticketFilter && <label className="mode-choice">
|
||||
<input type="checkbox" checked={wholeTicket && selectedTicketScenarios.length === 3}
|
||||
disabled={selectedTicketScenarios.length !== 3}
|
||||
onChange={(event) => {
|
||||
if (event.target.checked && selectedTicketScenarios[0]) {
|
||||
setScenarioId(selectedTicketScenarios[0].id);
|
||||
setExtraScenarioIds([]);
|
||||
}
|
||||
setWholeTicket(event.target.checked);
|
||||
}} />
|
||||
{` Запустить весь билет ${ticketFilter} целиком (3 вызова)`}
|
||||
{selectedTicketScenarios.length !== 3 && <small className="ref"> — в библиотеке найдено {selectedTicketScenarios.length} из 3 вызовов</small>}
|
||||
</label>}
|
||||
{exercise === "dds" && !wholeTicket && <details className="scenario-queue">
|
||||
<summary>Дополнительные карточки ({extraScenarioIds.length})</summary>
|
||||
<p className="ref">Карточки поступят последовательно после завершения текущей.</p>
|
||||
<p className="ref">Интервал и максимум ожидающих задаются в критериях занятия. Таймер каждой карточки начинается при поступлении и идёт, пока курсант занят другой.</p>
|
||||
{scenarios.data?.filter((scenario) => scenario.id !== scenarioId).map((scenario) =>
|
||||
<label key={scenario.id} className="mode-choice">
|
||||
<input type="checkbox" checked={extraScenarioIds.includes(scenario.id)} onChange={() =>
|
||||
|
|
@ -179,7 +288,7 @@ export function Instructor() {
|
|||
<select value={traineeId} onChange={(event) => setTraineeId(event.target.value)}>
|
||||
<option value="">Выберите учётную запись</option>
|
||||
{trainees.data.map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.name}{item.group ? ` · ${item.group}` : ""}</option>
|
||||
<option key={item.id} value={item.id}>{item.name}{item.group ? ` · ${item.group}` : ""}{item.service ? ` · ${item.service}` : " · служба не назначена"}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
|
|
@ -191,11 +300,57 @@ export function Instructor() {
|
|||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Критерии занятия</th>
|
||||
<td>
|
||||
{exercise === "dds" && <>
|
||||
<label className="lesson-criterion">
|
||||
Интервал поступления карточек, секунд (0 — без задержки; действует лимит ожидания)
|
||||
<input aria-label="Интервал поступления карточек ДДС, секунд" type="number" min={0} max={300}
|
||||
value={ddsArrivalIntervalSeconds} onChange={(event) => setDdsArrivalIntervalSeconds(
|
||||
Math.max(0, Math.min(300, Number(event.target.value) || 0)))} />
|
||||
</label>
|
||||
<label className="lesson-criterion">
|
||||
Максимум ожидающих карточек
|
||||
<input aria-label="Максимум ожидающих карточек ДДС" type="number" min={1} max={10}
|
||||
value={ddsMaxWaiting} onChange={(event) => setDdsMaxWaiting(
|
||||
Math.max(1, Math.min(10, Number(event.target.value) || 1)))} />
|
||||
</label>
|
||||
</>}
|
||||
<label className="lesson-criterion">
|
||||
Решение ДДС, секунд
|
||||
<input aria-label="Лимит решения ДДС, секунд" type="number" min={5} max={300}
|
||||
value={decisionTimeLimit} onChange={(event) => setDecisionTimeLimit(
|
||||
Math.max(5, Math.min(300, Number(event.target.value) || 30)))} />
|
||||
</label>
|
||||
<label className="lesson-criterion">
|
||||
Допустимо нарушенных метрик
|
||||
<input aria-label="Допустимое число ошибок" type="number" min={0} max={50}
|
||||
value={allowedErrors} onChange={(event) => setAllowedErrors(
|
||||
Math.max(0, Math.min(50, Number(event.target.value) || 0)))} />
|
||||
</label>
|
||||
{exercise !== "dds" && <label className="mode-choice">
|
||||
<input type="checkbox" checked={requireCorrectGrammar}
|
||||
onChange={(event) => setRequireCorrectGrammar(event.target.checked)} />
|
||||
Грамматика карточки оператора 112 входит в оценку
|
||||
</label>}
|
||||
<label className="lesson-criterion">
|
||||
Переопределения весов метрик (JSON; 0 отключает метрику)
|
||||
<textarea aria-label="Переопределения весов метрик" rows={3}
|
||||
value={scoreWeightsText}
|
||||
onChange={(event) => { setScoreWeightsText(event.target.value); setScoreWeightsError(""); }}
|
||||
placeholder={'{"address": 3, "dds_ack": 2}'} spellCheck={false} />
|
||||
<small className="ref">Оставьте {"{}"} для стандартной шкалы. Диапазон каждого веса — 0–10; допустимые ключи проверяются сервером.</small>
|
||||
</label>
|
||||
{scoreWeightsError && <p className="violated" role="alert">{scoreWeightsError}</p>}
|
||||
<small className="ref">Нормативы ГОСТ для приёма вызова не изменяются.</small>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
<button type="button" className="start" onClick={startLesson}
|
||||
disabled={!scenarioId || trainees.isLoading || Boolean(trainees.data?.length && !traineeId)}>
|
||||
disabled={!canStartSelectedExercise || trainees.isLoading || Boolean(trainees.data?.length && !traineeId)}>
|
||||
Начать занятие
|
||||
</button>
|
||||
</p>
|
||||
|
|
@ -209,7 +364,10 @@ export function Instructor() {
|
|||
{sessionId && !session.ended && (
|
||||
exercise === "call" && <Director onInject={control.inject} disabled={control.status !== "open"} error={session.error} />
|
||||
)}
|
||||
{links && (
|
||||
{links && !session.title && !session.error && (
|
||||
<p className="ref" role="status">Запускаем занятие и готовим ссылки на рабочие места…</p>
|
||||
)}
|
||||
{links && session.title && (
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
<tr><th>АРМ курсанта</th><td><a href={exercise === "dds" ? links.dds : links.trainee}>открыть {exercise === "dds" ? "ДДС" : "112"}</a></td></tr>
|
||||
|
|
@ -284,12 +442,37 @@ export function Instructor() {
|
|||
</section>
|
||||
</div>
|
||||
|
||||
<ScenarioEditor scenarios={scenarios.data ?? []} onApproved={async (id) => {
|
||||
setScenarioId(id);
|
||||
<ScenarioEditor scenarios={scenarios.data ?? []} selectedScenarioId={scenarioId} onApproved={async (id) => {
|
||||
setTicketFilter("");
|
||||
setCategoryFilter("");
|
||||
setScenarioSearch("");
|
||||
await scenarios.refetch();
|
||||
}} />
|
||||
setScenarioId(id);
|
||||
}} onLibraryChanged={async () => { await scenarios.refetch(); }} />
|
||||
|
||||
{session.report && <Debrief report={session.report} />}
|
||||
{session.report && <>
|
||||
<section className="debrief">
|
||||
<h2>Корректировка итоговой оценки</h2>
|
||||
<p className="ref">Автооценка {session.report.score_auto.toFixed(1)} / 100. Изменение сохраняется в журнале с вашим логином и обоснованием.</p>
|
||||
<label>Итоговая оценка, 0–100
|
||||
<input type="number" min={0} max={100} step={0.1}
|
||||
value={overrideScore}
|
||||
onChange={(event) => setOverrideScore(event.target.value)} />
|
||||
</label>
|
||||
<label>Обязательное обоснование
|
||||
<textarea rows={3} maxLength={2000} value={overrideComment}
|
||||
onChange={(event) => setOverrideComment(event.target.value)}
|
||||
placeholder="Почему итоговая оценка отличается от автоматической" />
|
||||
</label>
|
||||
<button type="button" disabled={overrideBusy || !overrideComment.trim()}
|
||||
onClick={() => void saveScoreOverride(session.report!)}>
|
||||
{overrideBusy ? "Сохраняем…" : "Сохранить оценку с аудитом"}
|
||||
</button>
|
||||
{overrideError && <p className="violated" role="alert">{overrideError}</p>}
|
||||
</section>
|
||||
<Debrief report={overrideReport?.session_id === session.report.session_id
|
||||
? overrideReport : session.report} />
|
||||
</>}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
229
frontend/src/pages/materials/Materials.tsx
Normal file
229
frontend/src/pages/materials/Materials.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import { useState, type FormEvent } from "react";
|
||||
|
||||
import { useMe } from "@/features/auth/useAuth";
|
||||
import {
|
||||
useArchiveMaterial,
|
||||
useAssignMaterial,
|
||||
useAssignMaterialGroup,
|
||||
useCompleteMaterial,
|
||||
useCreateMaterial,
|
||||
useGroups,
|
||||
useMaterials,
|
||||
useScenarios,
|
||||
useStartMaterialPractice,
|
||||
useTrainees,
|
||||
useUpdateMaterial,
|
||||
type LearningMaterial,
|
||||
} from "@/shared/api/http";
|
||||
import type { Level } from "@/shared/types/generated";
|
||||
|
||||
async function encodeFile(file: File): Promise<string> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
let binary = "";
|
||||
for (let offset = 0; offset < bytes.length; offset += 32_768) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 32_768));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function size(value: number): string {
|
||||
if (value < 1024) return `${value} Б`;
|
||||
return `${(value / 1024).toFixed(value < 1024 * 100 ? 1 : 0)} КБ`;
|
||||
}
|
||||
|
||||
export function Materials() {
|
||||
const me = useMe();
|
||||
const instructor = me.data?.role === "instructor";
|
||||
const trainee = me.data?.role === "trainee";
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const materials = useMaterials(showArchived && !trainee);
|
||||
const scenarios = useScenarios();
|
||||
const trainees = useTrainees(instructor);
|
||||
const groups = useGroups(instructor);
|
||||
const create = useCreateMaterial();
|
||||
const update = useUpdateMaterial();
|
||||
const archive = useArchiveMaterial();
|
||||
const assign = useAssignMaterial();
|
||||
const assignGroup = useAssignMaterialGroup();
|
||||
const complete = useCompleteMaterial();
|
||||
const startPractice = useStartMaterialPractice();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [level, setLevel] = useState<Level>("L1");
|
||||
const [kind, setKind] = useState<"text" | "file">("text");
|
||||
const [body, setBody] = useState("");
|
||||
const [scenarioId, setScenarioId] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [traineeId, setTraineeId] = useState("");
|
||||
const [groupId, setGroupId] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [editing, setEditing] = useState<Pick<LearningMaterial,
|
||||
"id" | "title" | "description" | "level" | "body" | "scenario_id"> | null>(null);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setNotice("");
|
||||
const content = file ? await encodeFile(file) : undefined;
|
||||
create.mutate({
|
||||
title,
|
||||
description,
|
||||
level,
|
||||
kind,
|
||||
body: kind === "text" ? body : "",
|
||||
scenario_id: scenarioId || null,
|
||||
file_name: file?.name,
|
||||
media_type: file?.type || "application/octet-stream",
|
||||
content_base64: content,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setTitle(""); setDescription(""); setBody(""); setFile(null);
|
||||
setNotice("Материал сохранён локально.");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return <main className="page materials-page">
|
||||
<header className="materials-header">
|
||||
<div>
|
||||
<h1>Учебные материалы</h1>
|
||||
<p>Локальная справочная база и назначения курсантам без внешних сервисов.</p>
|
||||
</div>
|
||||
<a href={instructor ? "/instructor" : "/profile"}>← Назад к рабочему месту</a>
|
||||
</header>
|
||||
|
||||
{instructor && <section className="material-create">
|
||||
<h2>Создать материал</h2>
|
||||
<form onSubmit={(event) => void submit(event)}>
|
||||
<label>Название<input value={title} required minLength={3}
|
||||
onChange={(event) => setTitle(event.target.value)} /></label>
|
||||
<label>Описание<input value={description}
|
||||
onChange={(event) => setDescription(event.target.value)} /></label>
|
||||
<div className="material-create-row">
|
||||
<label>Сложность<select value={level} onChange={(event) => setLevel(event.target.value as Level)}>
|
||||
<option value="L1">L1 · базовый</option><option value="L2">L2 · рабочий</option>
|
||||
<option value="L3">L3 · сложный</option>
|
||||
</select></label>
|
||||
<label>Связанная практика<select value={scenarioId} onChange={(event) => setScenarioId(event.target.value)}>
|
||||
<option value="">без сценария</option>
|
||||
{scenarios.data?.map((scenario) => <option key={scenario.id} value={scenario.id}>
|
||||
{scenario.title} · {scenario.level}
|
||||
</option>)}
|
||||
</select></label>
|
||||
</div>
|
||||
<div className="mode-row">
|
||||
<label className="mode-choice"><input type="radio" checked={kind === "text"}
|
||||
onChange={() => setKind("text")} /> Текстовая памятка</label>
|
||||
<label className="mode-choice"><input type="radio" checked={kind === "file"}
|
||||
onChange={() => setKind("file")} /> Локальный файл</label>
|
||||
</div>
|
||||
{kind === "text"
|
||||
? <label>Содержание<textarea value={body} required rows={7}
|
||||
onChange={(event) => setBody(event.target.value)} /></label>
|
||||
: <label>Файл до 5 МБ<input type="file" required
|
||||
onChange={(event) => setFile(event.target.files?.[0] ?? null)} /></label>}
|
||||
<button type="submit" disabled={create.isPending || !title || (kind === "text" ? !body : !file)}>
|
||||
{create.isPending ? "Сохраняем…" : "Сохранить материал"}
|
||||
</button>
|
||||
{notice && <span className="state-ok">{notice}</span>}
|
||||
{create.isError && <span className="violated">Не удалось сохранить материал.</span>}
|
||||
</form>
|
||||
</section>}
|
||||
|
||||
<section>
|
||||
<div className="materials-list-head">
|
||||
<h2>{trainee ? "Назначено мне" : "Библиотека материалов"}</h2>
|
||||
{instructor && <label className="mode-choice"><input type="checkbox" checked={showArchived}
|
||||
onChange={(event) => setShowArchived(event.target.checked)} /> Показать архив</label>}
|
||||
</div>
|
||||
|
||||
{instructor && <div className="material-assignment-bar">
|
||||
<label>Курсант<select value={traineeId} onChange={(event) => setTraineeId(event.target.value)}>
|
||||
<option value="">выберите курсанта</option>
|
||||
{trainees.data?.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select></label>
|
||||
<label>Группа<select value={groupId} onChange={(event) => setGroupId(event.target.value)}>
|
||||
<option value="">выберите группу</option>
|
||||
{groups.data?.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select></label>
|
||||
<span>Выберите получателя, затем нажмите «Назначить» у материала.</span>
|
||||
</div>}
|
||||
|
||||
<div className="material-cards">
|
||||
{materials.data?.map((item) => <article key={item.id} className={!item.active ? "material-card archived" : "material-card"}>
|
||||
<header><span className="material-level">{item.level}</span><h3>{item.title}</h3>
|
||||
{item.completed_at && <strong className="state-ok">✓ изучено</strong>}</header>
|
||||
{editing?.id === item.id ? <div className="material-edit">
|
||||
<label>Название<input value={editing.title}
|
||||
onChange={(event) => setEditing({ ...editing, title: event.target.value })} /></label>
|
||||
<label>Описание<input value={editing.description}
|
||||
onChange={(event) => setEditing({ ...editing, description: event.target.value })} /></label>
|
||||
<div className="material-create-row">
|
||||
<label>Сложность<select value={editing.level}
|
||||
onChange={(event) => setEditing({ ...editing, level: event.target.value as Level })}>
|
||||
<option value="L1">L1</option><option value="L2">L2</option><option value="L3">L3</option>
|
||||
</select></label>
|
||||
<label>Практика<select value={editing.scenario_id ?? ""}
|
||||
onChange={(event) => setEditing({ ...editing, scenario_id: event.target.value || null })}>
|
||||
<option value="">без сценария</option>
|
||||
{scenarios.data?.map((scenario) => <option key={scenario.id} value={scenario.id}>{scenario.title}</option>)}
|
||||
</select></label>
|
||||
</div>
|
||||
{item.kind === "text" && <label>Содержание<textarea rows={6} value={editing.body}
|
||||
onChange={(event) => setEditing({ ...editing, body: event.target.value })} /></label>}
|
||||
<div className="material-edit-actions">
|
||||
<button type="button" disabled={!editing.title.trim() || (item.kind === "text" && !editing.body.trim()) || update.isPending}
|
||||
onClick={() => update.mutate({ id: item.id, patch: {
|
||||
title: editing.title, description: editing.description, level: editing.level,
|
||||
body: editing.body, scenario_id: editing.scenario_id,
|
||||
} }, { onSuccess: () => setEditing(null) })}>
|
||||
Сохранить изменения
|
||||
</button>
|
||||
<button type="button" onClick={() => setEditing(null)}>Отмена</button>
|
||||
</div>
|
||||
</div> : <>
|
||||
{item.description && <p>{item.description}</p>}
|
||||
{item.kind === "text" && <div className="material-body">{item.body}</div>}
|
||||
</>}
|
||||
{item.kind === "file" && <p>
|
||||
<a href={`/api/materials/${item.id}/download`}>Скачать {item.file_name}</a>
|
||||
<span className="ref"> · {size(item.file_size)} · SHA-256 {item.file_sha256?.slice(0, 12)}…</span>
|
||||
</p>}
|
||||
{item.scenario_id && <p className="ref">Связанная практика: {item.scenario_id}</p>}
|
||||
{instructor && <footer>
|
||||
<span>Назначено: {item.assignment_count}</span>
|
||||
<button type="button" onClick={() => setEditing({
|
||||
id: item.id, title: item.title, description: item.description,
|
||||
level: item.level, body: item.body, scenario_id: item.scenario_id,
|
||||
})}>Редактировать</button>
|
||||
{item.active && <button type="button" disabled={!traineeId || assign.isPending}
|
||||
onClick={() => assign.mutate({ materialId: item.id, traineeId })}>Назначить курсанту</button>}
|
||||
{item.active && <button type="button" disabled={!groupId || assignGroup.isPending}
|
||||
onClick={() => assignGroup.mutate({ materialId: item.id, groupId })}>Назначить группе</button>}
|
||||
{item.active
|
||||
? <button type="button" className="danger" onClick={() => archive.mutate(item.id)}>В архив</button>
|
||||
: <button type="button" onClick={() => update.mutate({ id: item.id, patch: { active: true } })}>Восстановить</button>}
|
||||
</footer>}
|
||||
{trainee && !item.completed_at && <footer>
|
||||
<button type="button" onClick={() => complete.mutate(item.id)} disabled={complete.isPending}>
|
||||
Отметить изученным
|
||||
</button>
|
||||
</footer>}
|
||||
{trainee && item.scenario_id && <footer>
|
||||
<button type="button" className="primary" disabled={startPractice.isPending}
|
||||
onClick={() => startPractice.mutate(item.id, {
|
||||
onSuccess: (started) => window.location.assign(started.path),
|
||||
})}>
|
||||
Начать самостоятельную практику
|
||||
</button>
|
||||
{startPractice.isError && <span className="violated">Практика сейчас недоступна.</span>}
|
||||
</footer>}
|
||||
</article>)}
|
||||
</div>
|
||||
{!materials.isLoading && materials.data?.length === 0 && <p className="ref">
|
||||
{trainee ? "Преподаватель пока не назначил материалы." : "Материалов пока нет."}
|
||||
</p>}
|
||||
{materials.isError && <p className="violated">Материалы не загрузились.</p>}
|
||||
</section>
|
||||
</main>;
|
||||
}
|
||||
9
frontend/src/pages/phone/Phone.tsx
Normal file
9
frontend/src/pages/phone/Phone.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { SipPhone } from "@/features/sip/SipPhone";
|
||||
|
||||
export function Phone() {
|
||||
return <main className="page phone-page">
|
||||
<h1>IP-телефон старшего группы</h1>
|
||||
<p>Откройте этот экран на втором рабочем месте, зарегистрируйте номер 6102 и примите вызов ДДС 6101.</p>
|
||||
<SipPhone defaultExtension="6102" defaultTarget="6101" title="Телефон старшего группы" />
|
||||
</main>;
|
||||
}
|
||||
|
|
@ -4,9 +4,10 @@
|
|||
// планирования подготовки. Работа над ошибками превращается в измеримый цикл:
|
||||
// посмотрел разбор → попробовал снова → увидел дельту (docs/product/MODES.md).
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { COMPETENCY_LABELS, Radar } from "@/features/debrief/Radar";
|
||||
import { useMe } from "@/features/auth/useAuth";
|
||||
import { clock } from "@/features/timers/clock";
|
||||
import { useProfile, useTrainees } from "@/shared/api/http";
|
||||
|
||||
|
|
@ -25,14 +26,22 @@ function sign(value: number | null, format: (value: number) => string): string {
|
|||
export function Profile() {
|
||||
const url = new URLSearchParams(location.search);
|
||||
const [traineeId, setTraineeId] = useState<string | null>(url.get("trainee"));
|
||||
const trainees = useTrainees();
|
||||
const me = useMe();
|
||||
const isTrainee = me.data?.role === "trainee";
|
||||
const trainees = useTrainees(!isTrainee);
|
||||
const profile = useProfile(traineeId);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTrainee && me.data?.trainee_id) setTraineeId(me.data.trainee_id);
|
||||
}, [isTrainee, me.data?.trainee_id]);
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<h1>Профиль курсанта</h1>
|
||||
|
||||
<p>
|
||||
<p><a href="/materials">Открыть назначенные учебные материалы →</a></p>
|
||||
|
||||
{!isTrainee && <p>
|
||||
<select value={traineeId ?? ""} onChange={(event) => setTraineeId(event.target.value || null)}>
|
||||
<option value="">— выберите курсанта —</option>
|
||||
{trainees.data?.map((trainee) => (
|
||||
|
|
@ -41,7 +50,7 @@ export function Profile() {
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
</p>
|
||||
</p>}
|
||||
|
||||
{profile.data && (
|
||||
<div className="columns">
|
||||
|
|
@ -59,6 +68,11 @@ export function Profile() {
|
|||
<p className="ref">
|
||||
радар — среднее по попыткам; подсказок использовано всего: {profile.data.hints_total}
|
||||
</p>
|
||||
{profile.data.attempts.some((attempt) => attempt.score !== null) && (
|
||||
<p><a href={`/api/trainees/${profile.data.trainee.id}/certificate.pdf`}>
|
||||
Скачать сертификат PDF
|
||||
</a></p>
|
||||
)}
|
||||
|
||||
<h3>Дельта попыток</h3>
|
||||
<table className="grid">
|
||||
|
|
@ -100,6 +114,11 @@ export function Profile() {
|
|||
` · ${Object.entries(attempt.codes).map(([code, count]) => `${code}×${count}`).join(", ")}`}
|
||||
</div>
|
||||
<a href={`/wall?session=${attempt.session_id}`}>разбор</a>
|
||||
{attempt.recording_available && <>
|
||||
{" · "}<a href={`/api/sessions/${attempt.session_id}/recording.wav`}>
|
||||
запись WAV
|
||||
</a>
|
||||
</>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Reference in a new issue