lct-08: каркас фронта, типизированный сокет, захват микрофона
Направление канала зашито в тип: у наблюдателя Out = never, отправить
нечего и нельзя; у преподавателя нет входящих. Разделение прав
архитектурное и на фронте тоже. Пока соединения нет, события не копятся:
истина на сервере, после переподключения придёт актуальное состояние.
Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра
всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено
числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц,
помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера
выключены: гарнитура эхо не даёт, а обработка портит речь для STT.
Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр
не того размера отбрасывается с одним предупреждением, а не 50 в секунду.
Логирование приложения не было настроено: uvicorn настраивает только
свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда
в логе не оказалось строки о принятых кадрах.
make lesson запускает занятие и печатает ссылки на экраны — пульта
преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем.
Путь из браузера с живым микрофоном не проверен: нужен человек, шаги
записаны в карточке.
2026-09-17 14:19:38 +03:00
|
|
|
|
// HTTP-клиент и хуки. Типы ответов HTTP в generated.ts пока не попадают —
|
|
|
|
|
|
// там только события WS; здесь описаны руками по docs/arch/CONTRACT.md#http-api.
|
|
|
|
|
|
|
2026-09-21 19:56:13 +03:00
|
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
lct-08: каркас фронта, типизированный сокет, захват микрофона
Направление канала зашито в тип: у наблюдателя Out = never, отправить
нечего и нельзя; у преподавателя нет входящих. Разделение прав
архитектурное и на фронте тоже. Пока соединения нет, события не копятся:
истина на сервере, после переподключения придёт актуальное состояние.
Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра
всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено
числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц,
помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера
выключены: гарнитура эхо не даёт, а обработка портит речь для STT.
Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр
не того размера отбрасывается с одним предупреждением, а не 50 в секунду.
Логирование приложения не было настроено: uvicorn настраивает только
свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда
в логе не оказалось строки о принятых кадрах.
make lesson запускает занятие и печатает ссылки на экраны — пульта
преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем.
Путь из браузера с живым микрофоном не проверен: нужен человек, шаги
записаны в карточке.
2026-09-17 14:19:38 +03:00
|
|
|
|
|
2026-09-26 20:59:50 +00:00
|
|
|
|
import type { Level, SessionMode, SessionReport, StationSnapshot } from "@/shared/types/generated";
|
|
|
|
|
|
|
|
|
|
|
|
import { reportErrorMessage, reportRetryDelay, shouldRetryReport } from "./report.mjs";
|
lct-08: каркас фронта, типизированный сокет, захват микрофона
Направление канала зашито в тип: у наблюдателя Out = never, отправить
нечего и нельзя; у преподавателя нет входящих. Разделение прав
архитектурное и на фронте тоже. Пока соединения нет, события не копятся:
истина на сервере, после переподключения придёт актуальное состояние.
Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра
всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено
числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц,
помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера
выключены: гарнитура эхо не даёт, а обработка портит речь для STT.
Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр
не того размера отбрасывается с одним предупреждением, а не 50 в секунду.
Логирование приложения не было настроено: uvicorn настраивает только
свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда
в логе не оказалось строки о принятых кадрах.
make lesson запускает занятие и печатает ссылки на экраны — пульта
преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем.
Путь из браузера с живым микрофоном не проверен: нужен человек, шаги
записаны в карточке.
2026-09-17 14:19:38 +03:00
|
|
|
|
|
|
|
|
|
|
export class ApiError extends Error {
|
|
|
|
|
|
constructor(
|
|
|
|
|
|
readonly status: number,
|
|
|
|
|
|
readonly code: string,
|
|
|
|
|
|
) {
|
|
|
|
|
|
super(`${status} ${code}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
|
|
|
|
const response = await fetch(path, {
|
|
|
|
|
|
...init,
|
|
|
|
|
|
headers: { "Content-Type": "application/json", ...init?.headers },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const body = await response.json().catch(() => ({}));
|
2026-09-26 18:12:27 +03:00
|
|
|
|
const detail = body.detail;
|
|
|
|
|
|
const message = typeof detail === "string"
|
|
|
|
|
|
? detail
|
|
|
|
|
|
: Array.isArray(detail)
|
|
|
|
|
|
? detail.map((item: { loc?: unknown[]; msg?: string }) => {
|
|
|
|
|
|
const path = item.loc?.slice(1).join(".");
|
|
|
|
|
|
return `${path ? `${path}: ` : ""}${item.msg ?? "некорректное значение"}`;
|
|
|
|
|
|
}).join("; ")
|
|
|
|
|
|
: response.statusText;
|
|
|
|
|
|
throw new ApiError(response.status, message);
|
lct-08: каркас фронта, типизированный сокет, захват микрофона
Направление канала зашито в тип: у наблюдателя Out = never, отправить
нечего и нельзя; у преподавателя нет входящих. Разделение прав
архитектурное и на фронте тоже. Пока соединения нет, события не копятся:
истина на сервере, после переподключения придёт актуальное состояние.
Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра
всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено
числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц,
помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера
выключены: гарнитура эхо не даёт, а обработка портит речь для STT.
Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр
не того размера отбрасывается с одним предупреждением, а не 50 в секунду.
Логирование приложения не было настроено: uvicorn настраивает только
свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда
в логе не оказалось строки о принятых кадрах.
make lesson запускает занятие и печатает ссылки на экраны — пульта
преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем.
Путь из браузера с живым микрофоном не проверен: нужен человек, шаги
записаны в карточке.
2026-09-17 14:19:38 +03:00
|
|
|
|
}
|
|
|
|
|
|
return response.json() as Promise<T>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface Health {
|
|
|
|
|
|
status: string;
|
|
|
|
|
|
models_ready: boolean;
|
|
|
|
|
|
embeddings_ready: boolean;
|
|
|
|
|
|
scenarios_loaded: number;
|
2026-09-24 01:10:49 +03:00
|
|
|
|
sessions_restored?: number;
|
lct-08: каркас фронта, типизированный сокет, захват микрофона
Направление канала зашито в тип: у наблюдателя Out = never, отправить
нечего и нельзя; у преподавателя нет входящих. Разделение прав
архитектурное и на фронте тоже. Пока соединения нет, события не копятся:
истина на сервере, после переподключения придёт актуальное состояние.
Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра
всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено
числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц,
помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера
выключены: гарнитура эхо не даёт, а обработка портит речь для STT.
Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр
не того размера отбрасывается с одним предупреждением, а не 50 в секунду.
Логирование приложения не было настроено: uvicorn настраивает только
свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда
в логе не оказалось строки о принятых кадрах.
make lesson запускает занятие и печатает ссылки на экраны — пульта
преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем.
Путь из браузера с живым микрофоном не проверен: нужен человек, шаги
записаны в карточке.
2026-09-17 14:19:38 +03:00
|
|
|
|
offline: boolean;
|
2026-09-21 19:56:13 +03:00
|
|
|
|
demo_no_db?: boolean;
|
lct-08: каркас фронта, типизированный сокет, захват микрофона
Направление канала зашито в тип: у наблюдателя Out = never, отправить
нечего и нельзя; у преподавателя нет входящих. Разделение прав
архитектурное и на фронте тоже. Пока соединения нет, события не копятся:
истина на сервере, после переподключения придёт актуальное состояние.
Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра
всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено
числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц,
помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера
выключены: гарнитура эхо не даёт, а обработка портит речь для STT.
Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр
не того размера отбрасывается с одним предупреждением, а не 50 в секунду.
Логирование приложения не было настроено: uvicorn настраивает только
свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда
в логе не оказалось строки о принятых кадрах.
make lesson запускает занятие и печатает ссылки на экраны — пульта
преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем.
Путь из браузера с живым микрофоном не проверен: нужен человек, шаги
записаны в карточке.
2026-09-17 14:19:38 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface ScenarioSummary {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
title: string;
|
2026-09-26 18:12:27 +03:00
|
|
|
|
outcome?: "card" | "consultation" | "transfer_region";
|
|
|
|
|
|
type?: string;
|
lct-08: каркас фронта, типизированный сокет, захват микрофона
Направление канала зашито в тип: у наблюдателя Out = never, отправить
нечего и нельзя; у преподавателя нет входящих. Разделение прав
архитектурное и на фронте тоже. Пока соединения нет, события не копятся:
истина на сервере, после переподключения придёт актуальное состояние.
Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра
всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено
числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц,
помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера
выключены: гарнитура эхо не даёт, а обработка портит речь для STT.
Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр
не того размера отбрасывается с одним предупреждением, а не 50 в секунду.
Логирование приложения не было настроено: uvicorn настраивает только
свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда
в логе не оказалось строки о принятых кадрах.
make lesson запускает занятие и печатает ссылки на экраны — пульта
преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем.
Путь из браузера с живым микрофоном не проверен: нужен человек, шаги
записаны в карточке.
2026-09-17 14:19:38 +03:00
|
|
|
|
level: Level;
|
2026-09-26 18:12:27 +03:00
|
|
|
|
topics?: string[];
|
lct-08: каркас фронта, типизированный сокет, захват микрофона
Направление канала зашито в тип: у наблюдателя Out = never, отправить
нечего и нельзя; у преподавателя нет входящих. Разделение прав
архитектурное и на фронте тоже. Пока соединения нет, события не копятся:
истина на сервере, после переподключения придёт актуальное состояние.
Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра
всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено
числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц,
помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера
выключены: гарнитура эхо не даёт, а обработка портит речь для STT.
Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр
не того размера отбрасывается с одним предупреждением, а не 50 в секунду.
Логирование приложения не было настроено: uvicorn настраивает только
свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда
в логе не оказалось строки о принятых кадрах.
make lesson запускает занятие и печатает ссылки на экраны — пульта
преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем.
Путь из браузера с живым микрофоном не проверен: нужен человек, шаги
записаны в карточке.
2026-09-17 14:19:38 +03:00
|
|
|
|
modes: SessionMode[];
|
2026-09-26 18:12:27 +03:00
|
|
|
|
signs?: string[];
|
|
|
|
|
|
incident_code?: string | null;
|
|
|
|
|
|
dds?: string | null;
|
|
|
|
|
|
ticket?: number | null;
|
|
|
|
|
|
position?: number | null;
|
|
|
|
|
|
ekp_group?: number | null;
|
|
|
|
|
|
can_manage?: boolean;
|
|
|
|
|
|
source?: "system" | "trainee";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface ScenarioSubmission {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
author_name: string;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
incident_type: string;
|
|
|
|
|
|
level: Level;
|
|
|
|
|
|
description: string;
|
|
|
|
|
|
address: string;
|
|
|
|
|
|
victims: number | null;
|
|
|
|
|
|
kio: Record<string, unknown> | null;
|
|
|
|
|
|
status: "pending" | "approved" | "rejected";
|
|
|
|
|
|
review_comment: string;
|
|
|
|
|
|
scenario_id: string | null;
|
|
|
|
|
|
created_at: string;
|
|
|
|
|
|
reviewed_at: string | null;
|
lct-08: каркас фронта, типизированный сокет, захват микрофона
Направление канала зашито в тип: у наблюдателя Out = never, отправить
нечего и нельзя; у преподавателя нет входящих. Разделение прав
архитектурное и на фронте тоже. Пока соединения нет, события не копятся:
истина на сервере, после переподключения придёт актуальное состояние.
Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра
всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено
числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц,
помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера
выключены: гарнитура эхо не даёт, а обработка портит речь для STT.
Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр
не того размера отбрасывается с одним предупреждением, а не 50 в секунду.
Логирование приложения не было настроено: uvicorn настраивает только
свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда
в логе не оказалось строки о принятых кадрах.
make lesson запускает занятие и печатает ссылки на экраны — пульта
преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем.
Путь из браузера с живым микрофоном не проверен: нужен человек, шаги
записаны в карточке.
2026-09-17 14:19:38 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface SessionInfo {
|
|
|
|
|
|
session_id: string;
|
|
|
|
|
|
scenario_id: string;
|
|
|
|
|
|
mode: SessionMode;
|
|
|
|
|
|
attempt: number;
|
|
|
|
|
|
trainee_id: string | null;
|
|
|
|
|
|
group_id: string | null;
|
2026-09-26 18:12:27 +03:00
|
|
|
|
started_at: string | null;
|
|
|
|
|
|
ended_at: string | null;
|
|
|
|
|
|
end_reason: string | null;
|
lct-08: каркас фронта, типизированный сокет, захват микрофона
Направление канала зашито в тип: у наблюдателя Out = never, отправить
нечего и нельзя; у преподавателя нет входящих. Разделение прав
архитектурное и на фронте тоже. Пока соединения нет, события не копятся:
истина на сервере, после переподключения придёт актуальное состояние.
Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра
всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено
числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц,
помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера
выключены: гарнитура эхо не даёт, а обработка портит речь для STT.
Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр
не того размера отбрасывается с одним предупреждением, а не 50 в секунду.
Логирование приложения не было настроено: uvicorn настраивает только
свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда
в логе не оказалось строки о принятых кадрах.
make lesson запускает занятие и печатает ссылки на экраны — пульта
преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем.
Путь из браузера с живым микрофоном не проверен: нужен человек, шаги
записаны в карточке.
2026-09-17 14:19:38 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
export interface ActiveSessionInfo {
|
|
|
|
|
|
session_id: string;
|
|
|
|
|
|
trainee_name: string | null;
|
|
|
|
|
|
scenario_id: string;
|
|
|
|
|
|
scenario_title: string;
|
|
|
|
|
|
mode: SessionMode;
|
|
|
|
|
|
exercise: "call" | "card" | "dds";
|
|
|
|
|
|
started_at: string | null;
|
|
|
|
|
|
elapsed_seconds: number;
|
|
|
|
|
|
dds_card_total: number;
|
|
|
|
|
|
dds_open_cards: number;
|
|
|
|
|
|
dds_overdue_cards: number;
|
|
|
|
|
|
dds_work_overdue_cards: number;
|
|
|
|
|
|
dds_statuses: Record<string, string>;
|
|
|
|
|
|
dds_snapshot: StationSnapshot | null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const useActiveSessions = () =>
|
|
|
|
|
|
useQuery({
|
|
|
|
|
|
queryKey: ["sessions", "active"],
|
|
|
|
|
|
queryFn: () => request<ActiveSessionInfo[]>("/api/sessions/active"),
|
|
|
|
|
|
refetchInterval: 2_000,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
lct-08: каркас фронта, типизированный сокет, захват микрофона
Направление канала зашито в тип: у наблюдателя Out = never, отправить
нечего и нельзя; у преподавателя нет входящих. Разделение прав
архитектурное и на фронте тоже. Пока соединения нет, события не копятся:
истина на сервере, после переподключения придёт актуальное состояние.
Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра
всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено
числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц,
помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера
выключены: гарнитура эхо не даёт, а обработка портит речь для STT.
Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр
не того размера отбрасывается с одним предупреждением, а не 50 в секунду.
Логирование приложения не было настроено: uvicorn настраивает только
свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда
в логе не оказалось строки о принятых кадрах.
make lesson запускает занятие и печатает ссылки на экраны — пульта
преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем.
Путь из браузера с живым микрофоном не проверен: нужен человек, шаги
записаны в карточке.
2026-09-17 14:19:38 +03:00
|
|
|
|
export const useHealth = () =>
|
|
|
|
|
|
useQuery({ queryKey: ["health"], queryFn: () => request<Health>("/api/health"), refetchInterval: 5_000 });
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
export const useScenarios = (enabled = true, roleScope = "instructor") =>
|
|
|
|
|
|
useQuery({
|
|
|
|
|
|
queryKey: ["scenarios", roleScope],
|
|
|
|
|
|
queryFn: () => request<ScenarioSummary[]>("/api/scenarios"),
|
|
|
|
|
|
enabled,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
export const useScenarioSubmissions = (enabled = true) =>
|
|
|
|
|
|
useQuery({
|
|
|
|
|
|
queryKey: ["scenario-submissions"],
|
|
|
|
|
|
queryFn: () => request<ScenarioSubmission[]>("/api/scenario-submissions"),
|
|
|
|
|
|
enabled,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
export const useCreateScenarioSubmission = () => {
|
|
|
|
|
|
const client = useQueryClient();
|
|
|
|
|
|
return useMutation({
|
|
|
|
|
|
mutationFn: (payload: { title: string; level: Level; kio: Record<string, unknown> }) => request<ScenarioSubmission>("/api/scenario-submissions", {
|
|
|
|
|
|
method: "POST", body: JSON.stringify(payload),
|
|
|
|
|
|
}),
|
|
|
|
|
|
onSuccess: () => client.invalidateQueries({ queryKey: ["scenario-submissions"] }),
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const useReviewScenarioSubmission = () => {
|
|
|
|
|
|
const client = useQueryClient();
|
|
|
|
|
|
return useMutation({
|
|
|
|
|
|
mutationFn: (payload: { id: string; decision: "approve" | "reject"; comment?: string }) =>
|
|
|
|
|
|
request<ScenarioSubmission>(`/api/scenario-submissions/${payload.id}/review`, {
|
|
|
|
|
|
method: "POST", body: JSON.stringify({ decision: payload.decision, comment: payload.comment ?? "" }),
|
|
|
|
|
|
}),
|
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
|
client.invalidateQueries({ queryKey: ["scenario-submissions"] });
|
|
|
|
|
|
client.invalidateQueries({ queryKey: ["scenarios"] });
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
lct-08: каркас фронта, типизированный сокет, захват микрофона
Направление канала зашито в тип: у наблюдателя Out = never, отправить
нечего и нельзя; у преподавателя нет входящих. Разделение прав
архитектурное и на фронте тоже. Пока соединения нет, события не копятся:
истина на сервере, после переподключения придёт актуальное состояние.
Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра
всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено
числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц,
помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера
выключены: гарнитура эхо не даёт, а обработка портит речь для STT.
Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр
не того размера отбрасывается с одним предупреждением, а не 50 в секунду.
Логирование приложения не было настроено: uvicorn настраивает только
свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда
в логе не оказалось строки о принятых кадрах.
make lesson запускает занятие и печатает ссылки на экраны — пульта
преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем.
Путь из браузера с живым микрофоном не проверен: нужен человек, шаги
записаны в карточке.
2026-09-17 14:19:38 +03:00
|
|
|
|
|
lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель:
задать сценарий и не вмешиваться, видеть работу курсанта вживую,
получить готовый разбор, показать группе. Кнопка запуска одна и крупная,
ссылки на АРМ курсанта и монитор печатаются рядом.
Разделение прав архитектурное и здесь: пульт смотрит через observe,
пишет через control, карточка курсанта у него только для чтения.
Две ловушки запуска: scenario.start уходил из эффекта и мог повториться
при перерисовке, а открытая из истории ссылка на идущее занятие
запустила бы его заново и сбросила. Запускается только занятие,
созданное кнопкой.
2026-09-17 21:34:01 +03:00
|
|
|
|
export const useSessions = (params: { trainee?: string; group?: string; mode?: SessionMode } = {}) => {
|
|
|
|
|
|
const query = new URLSearchParams(
|
|
|
|
|
|
Object.entries(params).filter(([, value]) => value) as [string, string][],
|
|
|
|
|
|
).toString();
|
|
|
|
|
|
return useQuery({
|
|
|
|
|
|
queryKey: ["sessions", query],
|
|
|
|
|
|
queryFn: () => request<SessionInfo[]>(`/api/sessions${query ? `?${query}` : ""}`),
|
|
|
|
|
|
refetchInterval: 15_000,
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-17 21:42:12 +03:00
|
|
|
|
export interface TraineeInfo {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
group: string | null;
|
2026-09-24 01:10:49 +03:00
|
|
|
|
service: string | null;
|
2026-09-17 21:42:12 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface Attempt {
|
|
|
|
|
|
session_id: string;
|
|
|
|
|
|
scenario_id: string;
|
|
|
|
|
|
mode: SessionMode;
|
|
|
|
|
|
attempt: number;
|
|
|
|
|
|
created_at: string;
|
|
|
|
|
|
score: number | null;
|
|
|
|
|
|
interview_ms: number | null;
|
|
|
|
|
|
facts_got: number | null;
|
|
|
|
|
|
facts_required: number | null;
|
|
|
|
|
|
hints: number | null;
|
|
|
|
|
|
codes: Record<string, number>;
|
2026-09-24 01:10:49 +03:00
|
|
|
|
recording_available: boolean;
|
2026-09-17 21:42:12 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface Profile {
|
|
|
|
|
|
trainee: TraineeInfo;
|
|
|
|
|
|
attempts: Attempt[];
|
|
|
|
|
|
competencies: Record<string, number>;
|
|
|
|
|
|
deltas: {
|
|
|
|
|
|
scenario_id: string;
|
|
|
|
|
|
from_attempt: number;
|
|
|
|
|
|
to_attempt: number;
|
|
|
|
|
|
score: number | null;
|
|
|
|
|
|
interview_ms: number | null;
|
|
|
|
|
|
facts_got: number | null;
|
|
|
|
|
|
}[];
|
2026-09-26 18:12:27 +03:00
|
|
|
|
recommendations: {
|
|
|
|
|
|
code: string;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
recommendation: string;
|
|
|
|
|
|
occurrences: number;
|
|
|
|
|
|
}[];
|
2026-09-17 21:42:12 +03:00
|
|
|
|
hints_total: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: классификатор ЕКП — признаки вместо выбора службы (lct-32)
Оператор системы-112 службу не выбирает: он проставляет формализованные
признаки происшествия, комбинация признаков даёт код ЕКП, а коду соответствует
список оповещения, который система собирает сама (docs/spec/DATASET.md).
Прежняя модель с полем dds из пяти значений оценивала действие, которого
в боевой работе нет.
- scripts/import_ekp.py (make ekp): книга заказчика → app/domain/ekp.json,
1283 кода, 61 служба, 23 группы. Разовый импорт, результат под гитом:
читать xlsx в рантайме — лишняя зависимость и полсекунды на старте.
- domain/ekp.py: справочник с ленивой загрузкой, каскад значений признаков,
список оповещения с модификаторами (нет доступа, угроза людям, пострадавшие,
газификация и ещё десяток).
- КИО: поля signs, incident_code, notify. Последние два только на чтение
и пересчитываются при каждой правке признаков; добавленная вручную служба
не теряется, удалить службу нельзя — как в боевом АРМ.
- GET /api/ekp/signs отдаёт один уровень признаков, а не дерево на полмегабайта.
- Карточка на фронте: три каскадных селектора вместо выбора службы.
- Оценка: метрика incident_signs (E2, маршрутизация). Для размеченных сценариев
dds_choice больше не считается — список оповещения производен от признаков,
и штрафовать за него отдельно значит наказать дважды за одну ошибку.
Разбор книги оказался основной работой: имя службы лежит то в первой строке
заголовка, то во второй, то склеено с модификатором; «Классификатор МЧС» —
заголовок группы колонок, а не служба. Правила разбора в докстринге импорта.
113 тестов зелёных (14 новых в tests/test_ekp.py), make typecheck чистый.
2026-09-19 20:11:50 +03:00
|
|
|
|
/** Значения следующего признака происшествия при уже выбранных (ЕКП). */
|
|
|
|
|
|
export interface SignsPage {
|
|
|
|
|
|
level: number;
|
|
|
|
|
|
values: string[];
|
|
|
|
|
|
incident: { code: string; type: string; notify: string[] } | null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 20:10:57 +03:00
|
|
|
|
export interface IncidentGroupsPage {
|
|
|
|
|
|
version: string;
|
|
|
|
|
|
groups: { id: number; title: string }[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const useIncidentGroups = () => useQuery({
|
|
|
|
|
|
queryKey: ["ekp-groups"], staleTime: Infinity,
|
|
|
|
|
|
queryFn: () => request<IncidentGroupsPage>("/api/ekp/groups"),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
export function useSigns(prefix: string[], group: number | null = null) {
|
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
|
if (group !== null) params.set("group", String(group));
|
|
|
|
|
|
for (const sign of prefix) params.append("prefix", sign);
|
|
|
|
|
|
const query = params.toString();
|
feat: классификатор ЕКП — признаки вместо выбора службы (lct-32)
Оператор системы-112 службу не выбирает: он проставляет формализованные
признаки происшествия, комбинация признаков даёт код ЕКП, а коду соответствует
список оповещения, который система собирает сама (docs/spec/DATASET.md).
Прежняя модель с полем dds из пяти значений оценивала действие, которого
в боевой работе нет.
- scripts/import_ekp.py (make ekp): книга заказчика → app/domain/ekp.json,
1283 кода, 61 служба, 23 группы. Разовый импорт, результат под гитом:
читать xlsx в рантайме — лишняя зависимость и полсекунды на старте.
- domain/ekp.py: справочник с ленивой загрузкой, каскад значений признаков,
список оповещения с модификаторами (нет доступа, угроза людям, пострадавшие,
газификация и ещё десяток).
- КИО: поля signs, incident_code, notify. Последние два только на чтение
и пересчитываются при каждой правке признаков; добавленная вручную служба
не теряется, удалить службу нельзя — как в боевом АРМ.
- GET /api/ekp/signs отдаёт один уровень признаков, а не дерево на полмегабайта.
- Карточка на фронте: три каскадных селектора вместо выбора службы.
- Оценка: метрика incident_signs (E2, маршрутизация). Для размеченных сценариев
dds_choice больше не считается — список оповещения производен от признаков,
и штрафовать за него отдельно значит наказать дважды за одну ошибку.
Разбор книги оказался основной работой: имя службы лежит то в первой строке
заголовка, то во второй, то склеено с модификатором; «Классификатор МЧС» —
заголовок группы колонок, а не служба. Правила разбора в докстринге импорта.
113 тестов зелёных (14 новых в tests/test_ekp.py), make typecheck чистый.
2026-09-19 20:11:50 +03:00
|
|
|
|
return useQuery({
|
2026-09-21 20:10:57 +03:00
|
|
|
|
queryKey: ["ekp-signs", group, prefix],
|
feat: классификатор ЕКП — признаки вместо выбора службы (lct-32)
Оператор системы-112 службу не выбирает: он проставляет формализованные
признаки происшествия, комбинация признаков даёт код ЕКП, а коду соответствует
список оповещения, который система собирает сама (docs/spec/DATASET.md).
Прежняя модель с полем dds из пяти значений оценивала действие, которого
в боевой работе нет.
- scripts/import_ekp.py (make ekp): книга заказчика → app/domain/ekp.json,
1283 кода, 61 служба, 23 группы. Разовый импорт, результат под гитом:
читать xlsx в рантайме — лишняя зависимость и полсекунды на старте.
- domain/ekp.py: справочник с ленивой загрузкой, каскад значений признаков,
список оповещения с модификаторами (нет доступа, угроза людям, пострадавшие,
газификация и ещё десяток).
- КИО: поля signs, incident_code, notify. Последние два только на чтение
и пересчитываются при каждой правке признаков; добавленная вручную служба
не теряется, удалить службу нельзя — как в боевом АРМ.
- GET /api/ekp/signs отдаёт один уровень признаков, а не дерево на полмегабайта.
- Карточка на фронте: три каскадных селектора вместо выбора службы.
- Оценка: метрика incident_signs (E2, маршрутизация). Для размеченных сценариев
dds_choice больше не считается — список оповещения производен от признаков,
и штрафовать за него отдельно значит наказать дважды за одну ошибку.
Разбор книги оказался основной работой: имя службы лежит то в первой строке
заголовка, то во второй, то склеено с модификатором; «Классификатор МЧС» —
заголовок группы колонок, а не служба. Правила разбора в докстринге импорта.
113 тестов зелёных (14 новых в tests/test_ekp.py), make typecheck чистый.
2026-09-19 20:11:50 +03:00
|
|
|
|
// Справочник не меняется между занятиями — перезапрашивать незачем.
|
|
|
|
|
|
staleTime: Infinity,
|
|
|
|
|
|
queryFn: () => request<SignsPage>(`/api/ekp/signs${query ? `?${query}` : ""}`),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
export const useTrainees = (enabled = true) =>
|
|
|
|
|
|
useQuery({
|
|
|
|
|
|
queryKey: ["trainees"],
|
|
|
|
|
|
queryFn: () => request<TraineeInfo[]>("/api/trainees"),
|
|
|
|
|
|
enabled,
|
|
|
|
|
|
});
|
2026-09-17 21:42:12 +03:00
|
|
|
|
|
2026-09-21 19:56:13 +03:00
|
|
|
|
export interface GroupInfo {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface GroupAnalytics {
|
|
|
|
|
|
group: GroupInfo;
|
|
|
|
|
|
enrolled_trainees: number;
|
|
|
|
|
|
active_trainees: number;
|
|
|
|
|
|
scored_attempts: number;
|
|
|
|
|
|
average_score: number | null;
|
|
|
|
|
|
errors: {
|
|
|
|
|
|
code: string;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
affected_trainees: number;
|
|
|
|
|
|
occurrences: number;
|
|
|
|
|
|
rate_percent: number;
|
|
|
|
|
|
recommendation: string;
|
|
|
|
|
|
}[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
export interface GroupInsight {
|
|
|
|
|
|
summary: string;
|
|
|
|
|
|
priorities: string[];
|
|
|
|
|
|
source: "local_qwen";
|
|
|
|
|
|
personal_data_sent: false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const useGroups = (enabled = true) =>
|
|
|
|
|
|
useQuery({
|
|
|
|
|
|
queryKey: ["groups"],
|
|
|
|
|
|
queryFn: () => request<GroupInfo[]>("/api/groups"),
|
|
|
|
|
|
enabled,
|
|
|
|
|
|
});
|
2026-09-21 19:56:13 +03:00
|
|
|
|
|
|
|
|
|
|
export function useCreateGroup() {
|
|
|
|
|
|
const cache = useQueryClient();
|
|
|
|
|
|
return useMutation({
|
|
|
|
|
|
mutationFn: (name: string) => request<GroupInfo>("/api/groups", {
|
|
|
|
|
|
method: "POST", body: JSON.stringify({ name }),
|
|
|
|
|
|
}),
|
|
|
|
|
|
onSuccess: () => void cache.invalidateQueries({ queryKey: ["groups"] }),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function useAssignTrainee() {
|
|
|
|
|
|
const cache = useQueryClient();
|
|
|
|
|
|
return useMutation({
|
|
|
|
|
|
mutationFn: ({ groupId, traineeId }: { groupId: string; traineeId: string }) =>
|
|
|
|
|
|
request<GroupInfo>(`/api/groups/${groupId}/trainees/${traineeId}`, { method: "PUT" }),
|
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
|
void cache.invalidateQueries({ queryKey: ["trainees"] });
|
|
|
|
|
|
void cache.invalidateQueries({ queryKey: ["group-analytics"] });
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const useGroupAnalytics = (groupId: string | null) =>
|
|
|
|
|
|
useQuery({
|
|
|
|
|
|
queryKey: ["group-analytics", groupId],
|
|
|
|
|
|
queryFn: () => request<GroupAnalytics>(`/api/groups/${groupId}/analytics`),
|
|
|
|
|
|
enabled: Boolean(groupId),
|
|
|
|
|
|
refetchInterval: 15_000,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
export function useGroupInsight() {
|
|
|
|
|
|
return useMutation({
|
|
|
|
|
|
mutationFn: (groupId: string) => request<GroupInsight>(
|
|
|
|
|
|
`/api/groups/${groupId}/analytics/insight`, { method: "POST" },
|
|
|
|
|
|
),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 21:42:12 +03:00
|
|
|
|
export const useProfile = (traineeId: string | null) =>
|
|
|
|
|
|
useQuery({
|
|
|
|
|
|
queryKey: ["profile", traineeId],
|
|
|
|
|
|
queryFn: () => request<Profile>(`/api/trainees/${traineeId}/profile`),
|
|
|
|
|
|
enabled: Boolean(traineeId),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-26 20:59:50 +00:00
|
|
|
|
const errorStatus = (error: unknown) => (error instanceof ApiError ? error.status : null);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Отчёт сессии. `ready = false` — запроса нет и отчёт не показывается: его
|
|
|
|
|
|
* просят после `score.ready` или у уже завершённой сессии. 409 (оценка ещё
|
|
|
|
|
|
* считается) повторяется сам с ограниченным числом попыток.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function useSessionReport(sessionId: string | null, ready: boolean) {
|
|
|
|
|
|
const enabled = Boolean(sessionId) && ready;
|
|
|
|
|
|
const query = useQuery({
|
|
|
|
|
|
queryKey: ["session-report", sessionId],
|
|
|
|
|
|
queryFn: () => request<SessionReport>(`/api/sessions/${sessionId}/report`),
|
|
|
|
|
|
enabled,
|
|
|
|
|
|
retry: (failureCount, error) => shouldRetryReport(failureCount, errorStatus(error)),
|
|
|
|
|
|
retryDelay: reportRetryDelay,
|
|
|
|
|
|
});
|
|
|
|
|
|
return {
|
|
|
|
|
|
report: enabled ? query.data ?? null : null,
|
|
|
|
|
|
loading: enabled && query.isFetching,
|
|
|
|
|
|
error: enabled && query.isError ? reportErrorMessage(errorStatus(query.error)) : "",
|
|
|
|
|
|
refetch: query.refetch,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
lct-08: каркас фронта, типизированный сокет, захват микрофона
Направление канала зашито в тип: у наблюдателя Out = never, отправить
нечего и нельзя; у преподавателя нет входящих. Разделение прав
архитектурное и на фронте тоже. Пока соединения нет, события не копятся:
истина на сервере, после переподключения придёт актуальное состояние.
Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра
всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено
числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц,
помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера
выключены: гарнитура эхо не даёт, а обработка портит речь для STT.
Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр
не того размера отбрасывается с одним предупреждением, а не 50 в секунду.
Логирование приложения не было настроено: uvicorn настраивает только
свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда
в логе не оказалось строки о принятых кадрах.
make lesson запускает занятие и печатает ссылки на экраны — пульта
преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем.
Путь из браузера с живым микрофоном не проверен: нужен человек, шаги
записаны в карточке.
2026-09-17 14:19:38 +03:00
|
|
|
|
export const useCreateSession = () =>
|
|
|
|
|
|
useMutation({
|
|
|
|
|
|
mutationFn: (body: { scenario_id: string; mode: SessionMode; trainee?: string; group?: string }) =>
|
|
|
|
|
|
request<SessionInfo>("/api/sessions", { method: "POST", body: JSON.stringify(body) }),
|
|
|
|
|
|
});
|
2026-09-24 01:10:49 +03:00
|
|
|
|
|
|
|
|
|
|
export interface LearningMaterial {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
description: string;
|
|
|
|
|
|
level: Level;
|
|
|
|
|
|
kind: "text" | "file";
|
|
|
|
|
|
body: string;
|
|
|
|
|
|
scenario_id: string | null;
|
|
|
|
|
|
file_name: string | null;
|
|
|
|
|
|
media_type: string | null;
|
|
|
|
|
|
file_size: number;
|
|
|
|
|
|
file_sha256: string | null;
|
|
|
|
|
|
active: boolean;
|
|
|
|
|
|
created_by: string;
|
|
|
|
|
|
created_at: string;
|
|
|
|
|
|
assigned_at: string | null;
|
|
|
|
|
|
completed_at: string | null;
|
|
|
|
|
|
assignment_count: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface MaterialCreateInput {
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
description: string;
|
|
|
|
|
|
level: Level;
|
|
|
|
|
|
kind: "text" | "file";
|
|
|
|
|
|
body: string;
|
|
|
|
|
|
scenario_id: string | null;
|
|
|
|
|
|
file_name?: string;
|
|
|
|
|
|
media_type?: string;
|
|
|
|
|
|
content_base64?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const useMaterials = (includeArchived = false) => useQuery({
|
|
|
|
|
|
queryKey: ["materials", includeArchived],
|
|
|
|
|
|
queryFn: () => request<LearningMaterial[]>(
|
|
|
|
|
|
`/api/materials${includeArchived ? "?include_archived=true" : ""}`,
|
|
|
|
|
|
),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function invalidateMaterials(cache: ReturnType<typeof useQueryClient>) {
|
|
|
|
|
|
return cache.invalidateQueries({ queryKey: ["materials"] });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function useCreateMaterial() {
|
|
|
|
|
|
const cache = useQueryClient();
|
|
|
|
|
|
return useMutation({
|
|
|
|
|
|
mutationFn: (body: MaterialCreateInput) => request<LearningMaterial>("/api/materials", {
|
|
|
|
|
|
method: "POST", body: JSON.stringify(body),
|
|
|
|
|
|
}),
|
|
|
|
|
|
onSuccess: () => void invalidateMaterials(cache),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function useUpdateMaterial() {
|
|
|
|
|
|
const cache = useQueryClient();
|
|
|
|
|
|
return useMutation({
|
|
|
|
|
|
mutationFn: ({ id, patch }: { id: string; patch: Partial<LearningMaterial> }) =>
|
|
|
|
|
|
request<LearningMaterial>(`/api/materials/${id}`, {
|
|
|
|
|
|
method: "PATCH", body: JSON.stringify(patch),
|
|
|
|
|
|
}),
|
|
|
|
|
|
onSuccess: () => void invalidateMaterials(cache),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function useArchiveMaterial() {
|
|
|
|
|
|
const cache = useQueryClient();
|
|
|
|
|
|
return useMutation({
|
|
|
|
|
|
mutationFn: (id: string) => request<LearningMaterial>(`/api/materials/${id}`, {
|
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
|
}),
|
|
|
|
|
|
onSuccess: () => void invalidateMaterials(cache),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function useAssignMaterial() {
|
|
|
|
|
|
const cache = useQueryClient();
|
|
|
|
|
|
return useMutation({
|
|
|
|
|
|
mutationFn: ({ materialId, traineeId }: { materialId: string; traineeId: string }) =>
|
|
|
|
|
|
request<LearningMaterial>(`/api/materials/${materialId}/assign/${traineeId}`, {
|
|
|
|
|
|
method: "PUT",
|
|
|
|
|
|
}),
|
|
|
|
|
|
onSuccess: () => void invalidateMaterials(cache),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function useAssignMaterialGroup() {
|
|
|
|
|
|
const cache = useQueryClient();
|
|
|
|
|
|
return useMutation({
|
|
|
|
|
|
mutationFn: ({ materialId, groupId }: { materialId: string; groupId: string }) =>
|
|
|
|
|
|
request<{ material_id: string; assigned: number }>(
|
|
|
|
|
|
`/api/materials/${materialId}/assign-group/${groupId}`, { method: "PUT" },
|
|
|
|
|
|
),
|
|
|
|
|
|
onSuccess: () => void invalidateMaterials(cache),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function useCompleteMaterial() {
|
|
|
|
|
|
const cache = useQueryClient();
|
|
|
|
|
|
return useMutation({
|
|
|
|
|
|
mutationFn: (id: string) => request<LearningMaterial>(`/api/materials/${id}/complete`, {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
}),
|
|
|
|
|
|
onSuccess: () => void invalidateMaterials(cache),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function useStartMaterialPractice() {
|
|
|
|
|
|
return useMutation({
|
|
|
|
|
|
mutationFn: (id: string) => request<{
|
|
|
|
|
|
session_id: string;
|
|
|
|
|
|
scenario_id: string;
|
|
|
|
|
|
mode: "self";
|
|
|
|
|
|
exercise: "card";
|
|
|
|
|
|
path: string;
|
|
|
|
|
|
}>(`/api/materials/${id}/start`, { method: "POST" }),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|