You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1345 lines
47 KiB

import { useEffect, useState } from 'react';
import { useNavigate, useParams, Link, useOutletContext } from 'react-router-dom';
import { api } from '../api';
import { formatTestAuthorLabel } from '../utils/formatUserName';
function fmtDt(iso) {
if (!iso) {
return '—';
}
try {
return new Date(iso).toLocaleString('ru-RU', {
dateStyle: 'short',
timeStyle: 'short',
});
} catch {
return '—';
}
}
function newKey() {
return globalThis.crypto?.randomUUID?.() ?? `k-${Date.now()}-${Math.random()}`;
}
function createEmptyQuestion() {
return {
key: newKey(),
text: '',
hasMultipleAnswers: false,
options: [
{ key: newKey(), text: '', isCorrect: true },
{ key: newKey(), text: '', isCorrect: false },
],
};
}
/**
* @param {{ title: string, subtitle?: string, defaultOpen?: boolean, id?: string, children: import('react').ReactNode }} p
*/
function AccSection({ title, subtitle, defaultOpen, id, children }) {
return (
<div className="cabinet-brick">
<details className="cabinet-disclosure" defaultOpen={defaultOpen} id={id || undefined}>
<summary className="cabinet-disclosure__summary">
<span className="cabinet-disclosure__summary-text">
<span className="cabinet-disclosure__summary-title font-headline">{title}</span>
{subtitle ? <span className="cabinet-disclosure__summary-sub">{subtitle}</span> : null}
</span>
</summary>
<div className="cabinet-disclosure__body">{children}</div>
</details>
</div>
);
}
function mapEditorToDraftQuestions(ed) {
if (!ed?.questions?.length) {
return [createEmptyQuestion()];
}
return ed.questions.map((q) => ({
key: String(q.id),
id: q.id,
text: q.text ?? '',
hasMultipleAnswers: !!q.hasMultipleAnswers,
options: (q.options || []).map((o) => ({
key: String(o.id),
id: o.id,
text: o.text ?? '',
isCorrect: !!o.isCorrect,
})),
}));
}
export default function TestDetail() {
const { id } = useParams();
const nav = useNavigate();
const { user, assignmentUi } = useOutletContext() || {};
const [data, setData] = useState(null);
/** V.8: сотрудник / не-автор — краткая карточка без редактора */
const [taker, setTaker] = useState(null);
const [chain, setChain] = useState(null);
const [err, setErr] = useState(null);
const [draftTitle, setDraftTitle] = useState('');
const [draftDescription, setDraftDescription] = useState('');
const [draftPassing, setDraftPassing] = useState('70');
const [draftQuestions, setDraftQuestions] = useState(() => [createEmptyQuestion()]);
const [draftStatus, setDraftStatus] = useState('');
const [deactivateBusy, setDeactivateBusy] = useState(false);
const [importPreview, setImportPreview] = useState(null);
const [importErr, setImportErr] = useState(null);
const [importBusy, setImportBusy] = useState(false);
const [aiTestBusy, setAiTestBusy] = useState(false);
const [aiQBusy, setAiQBusy] = useState(null);
/** Параметры блока «Сгенерировать тест (ИИ)» (редизайн формы редактора) */
const [aiGenTopic, setAiGenTopic] = useState('');
const [aiQuestionsCount, setAiQuestionsCount] = useState(7);
const [aiAnswersCount, setAiAnswersCount] = useState(3);
const [assignSearch, setAssignSearch] = useState('');
const [assignSearchApplied, setAssignSearchApplied] = useState('');
const [assignDept, setAssignDept] = useState('__all__');
const [assignClinic, setAssignClinic] = useState('all');
const [assignPeople, setAssignPeople] = useState([]);
const [assignDepts, setAssignDepts] = useState([]);
const [assignSelected, setAssignSelected] = useState(() => new Set());
const [assignMsg, setAssignMsg] = useState('');
const [assignErr, setAssignErr] = useState(null);
const [assignLoadBusy, setAssignLoadBusy] = useState(false);
const [attemptsList, setAttemptsList] = useState(undefined);
const [attemptsErr, setAttemptsErr] = useState(null);
async function load() {
setErr(null);
setData(null);
setTaker(null);
setChain(null);
try {
const sum = await api(`/api/tests/${id}/summary`);
if (!sum.isAuthor) {
const c = await api(`/api/tests/${id}/chain-info`);
setTaker({ summary: sum, chain: c });
return;
}
const [v, c, ed] = await Promise.all([
api(`/api/tests/${id}/versions`),
api(`/api/tests/${id}/chain-info`),
api(`/api/tests/${id}/editor`),
]);
setData(v);
setChain(c);
if (ed?.test) {
setDraftTitle(ed.test.title || '');
setAiGenTopic((ed.test.title || '').trim());
setDraftDescription(ed.test.description || '');
const th = ed.test.passingThreshold;
setDraftPassing(
th !== undefined && th !== null && String(th) !== '' ? String(th) : '70'
);
setDraftQuestions(mapEditorToDraftQuestions(ed));
}
} catch (e) {
if (e.status === 401) {
nav('/login');
return;
}
setErr(e.message);
}
}
useEffect(() => {
load();
}, [id, nav]);
useEffect(() => {
if (!data) {
setAttemptsList(undefined);
setAttemptsErr(null);
return;
}
let cancelled = false;
setAttemptsErr(null);
(async () => {
try {
const r = await api(`/api/tests/${id}/attempts`);
if (!cancelled) {
setAttemptsList(r.attempts || []);
}
} catch (e) {
if (!cancelled) {
if (e.status === 403 || e.status === 404) {
setAttemptsList(null);
} else {
setAttemptsErr(e.message);
setAttemptsList(null);
}
}
}
})();
return () => {
cancelled = true;
};
}, [data, id]);
useEffect(() => {
const t = setTimeout(() => setAssignSearchApplied(assignSearch), 400);
return () => clearTimeout(t);
}, [assignSearch]);
useEffect(() => {
if (!assignmentUi || !data) {
return;
}
let cancelled = false;
(async () => {
setAssignErr(null);
setAssignLoadBusy(true);
try {
const params = new URLSearchParams();
if (assignSearchApplied.trim()) {
params.set('q', assignSearchApplied.trim());
}
if (assignDept && assignDept !== '__all__') {
params.set('department', assignDept);
}
params.set('clinic', assignClinic);
const r = await api(`/api/auth/dev/assignment-directory?${params.toString()}`);
if (cancelled) {
return;
}
setAssignPeople(r.people || []);
setAssignDepts(r.departments || []);
setAssignSelected(new Set());
} catch (e) {
if (!cancelled) {
setAssignErr(e.message);
}
} finally {
if (!cancelled) {
setAssignLoadBusy(false);
}
}
})();
return () => {
cancelled = true;
};
}, [assignmentUi, data, id, assignSearchApplied, assignDept, assignClinic]);
function assignPersonKey(p) {
return `${p.staffId ?? '—'}|${p.clinicUserId ?? '—'}`;
}
function toggleAssignPerson(p) {
const k = assignPersonKey(p);
setAssignSelected((prev) => {
const next = new Set(prev);
if (next.has(k)) {
next.delete(k);
} else {
next.add(k);
}
return next;
});
}
function selectAllVisible() {
setAssignSelected((prev) => {
const next = new Set(prev);
for (const p of assignPeople) {
next.add(assignPersonKey(p));
}
return next;
});
}
async function postAssign() {
const selectedRows = assignPeople.filter((p) =>
assignSelected.has(assignPersonKey(p))
);
if (selectedRows.length === 0) {
return;
}
setAssignMsg('');
setAssignErr(null);
const userIds = [];
const staffIds = [];
for (const p of selectedRows) {
if (p.clinicUserId) {
userIds.push(p.clinicUserId);
} else if (p.staffId != null) {
staffIds.push(p.staffId);
}
}
if (userIds.length === 0 && staffIds.length === 0) {
setAssignErr('Не выбраны сотрудники с валидным staff_id.');
return;
}
try {
const out = await api(`/api/tests/${id}/assign`, {
method: 'POST',
body: JSON.stringify({ userIds, staffIds }),
});
setAssignMsg(
out.count != null
? `Назначено: ${out.count} сотр.`
: 'Назначение сохранено.'
);
setAssignSelected(new Set());
} catch (e) {
setAssignErr(e.message);
}
}
async function saveDraft() {
setDraftStatus('…');
try {
const questions = draftQuestions.map((q, i) => ({
text: (q.text || '').trim() || 'Вопрос',
question_order: i + 1,
hasMultipleAnswers: q.hasMultipleAnswers,
options: q.options.map((o, j) => ({
text: (o.text || '').trim() || 'Вариант',
isCorrect: o.isCorrect,
option_order: j + 1,
})),
}));
const out = await api(`/api/tests/${id}/draft`, {
method: 'POST',
body: JSON.stringify({
title: (draftTitle || '').trim() || 'Без названия',
description: (draftDescription || '').trim() || null,
passingThreshold: (() => {
const n = Number(draftPassing);
return Number.isFinite(n) ? n : undefined;
})(),
questions,
}),
});
setDraftStatus(out.forked ? 'Сохранено как новая версия' : 'Сохранено');
load();
} catch (e) {
setDraftStatus(e.message);
}
}
function normalizeGeneratedQuestionOptions(q, targetCount) {
const n = Math.min(12, Math.max(2, targetCount));
const raw = (q?.options || []).map((o) => ({
key: newKey(),
text: (o.text || '').trim() || 'Вариант',
isCorrect: !!o.isCorrect,
}));
const out = raw.slice(0, n);
while (out.length < n) {
out.push({ key: newKey(), text: '', isCorrect: false });
}
if (!out.some((o) => o.isCorrect)) {
out[0] = { ...out[0], isCorrect: true };
}
return out;
}
async function runAiGenerateTest() {
if (aiTestBusy || !id) {
return;
}
const nQ = Math.min(30, Math.max(1, Math.floor(Number(aiQuestionsCount)) || 7));
const nA = Math.min(8, Math.max(2, Math.floor(Number(aiAnswersCount)) || 3));
const topic = (aiGenTopic || draftTitle || '').trim() || 'Тест';
setDraftStatus('');
setAiTestBusy(true);
try {
const shape = Array.from({ length: nQ }, () => ({
optionsCount: nA,
hasMultipleAnswers: false,
}));
const out = await api(`/api/tests/${id}/ai/generate-test`, {
method: 'POST',
body: JSON.stringify({
testTitle: topic,
testDescription: draftDescription,
shape,
}),
});
if (out.draft) {
setDraftTitle((out.draft.title || '').trim() || 'Без названия');
setDraftDescription(
out.draft.description != null && String(out.draft.description).trim()
? String(out.draft.description).trim()
: ''
);
const qs = (out.draft.questions || []).map((q) => ({
key: newKey(),
text: (q.text || '').trim() || 'Вопрос',
hasMultipleAnswers: !!q.hasMultipleAnswers,
options: normalizeGeneratedQuestionOptions(q, nA),
}));
if (qs.length) {
setDraftQuestions(qs);
}
setDraftStatus('Тексты и верные варианты сгенерированы по сетке. Проверьте и сохраните.');
}
} catch (e) {
setDraftStatus(e.message || 'Ошибка');
} finally {
setAiTestBusy(false);
}
}
async function runAiGenerateQuestion(qi) {
if (aiQBusy != null || !id) {
return;
}
setDraftStatus('');
const q = draftQuestions[qi];
if (!q) {
return;
}
setAiQBusy(qi);
try {
const out = await api(`/api/tests/${id}/ai/generate-question`, {
method: 'POST',
body: JSON.stringify({
testTitle: draftTitle,
testDescription: draftDescription,
questionText: (q.text || '').trim(),
optionsCount: Math.max(2, Math.min(12, q.options?.length || 2)),
hasMultipleAnswers: q.hasMultipleAnswers,
}),
});
if (out.mode === 'rephrase' && out.text) {
setDraftQuestions((prev) =>
prev.map((row, i) => (i === qi ? { ...row, text: out.text } : row))
);
setDraftStatus('Вопрос переформулирован. При необходимости сохраните.');
} else if (out.mode === 'full') {
setDraftQuestions((prev) =>
prev.map((row, i) =>
i === qi
? {
...row,
key: newKey(),
text: (out.text || '').trim() || 'Вопрос',
hasMultipleAnswers: !!out.hasMultipleAnswers,
options: (out.options || []).map((o) => ({
key: newKey(),
text: (o.text || '').trim() || 'Вариант',
isCorrect: !!o.isCorrect,
})),
}
: row
)
);
setDraftStatus('Вопрос и варианты сгенерированы. Проверьте и сохраните.');
}
} catch (e) {
setDraftStatus(e.message || 'Ошибка');
} finally {
setAiQBusy(null);
}
}
async function onImportFile(e) {
const f = e.target.files?.[0];
e.target.value = '';
if (!f) {
return;
}
setImportErr(null);
setImportPreview(null);
setImportBusy(true);
try {
const fd = new FormData();
fd.append('file', f);
const out = await api('/api/tests/import/document', {
method: 'POST',
body: fd,
});
setImportPreview(out);
} catch (er) {
setImportErr(er.message);
} finally {
setImportBusy(false);
}
}
function applyExtractedToQuestion() {
const t = importPreview?.extractedText;
if (!t) {
return;
}
const chunk = t.length > 2000 ? `${t.slice(0, 2000)}` : t;
setDraftQuestions((prev) => {
if (!prev.length) {
const q = createEmptyQuestion();
q.text = chunk;
return [q];
}
const next = [...prev];
next[0] = { ...next[0], text: chunk };
return next;
});
}
function applyGeneratedDraft() {
const d = importPreview?.generation?.draft;
if (!d) {
return;
}
setDraftTitle((d.title || '').trim() || 'Без названия');
setAiGenTopic((d.title || '').trim());
setDraftDescription(
d.description != null && String(d.description).trim() ? String(d.description).trim() : ''
);
const qs = (d.questions || []).map((q) => {
const opts = (q.options || []).map((o) => ({
key: newKey(),
text: (o.text || '').trim() || 'Вариант',
isCorrect: !!o.isCorrect,
}));
if (opts.length < 2) {
return {
key: newKey(),
text: (q.text || '').trim() || 'Вопрос',
hasMultipleAnswers: !!q.hasMultipleAnswers,
options: [
{ key: newKey(), text: 'Вариант 1', isCorrect: true },
{ key: newKey(), text: 'Вариант 2', isCorrect: false },
],
};
}
return {
key: newKey(),
text: (q.text || '').trim() || 'Вопрос',
hasMultipleAnswers: !!q.hasMultipleAnswers,
options: opts,
};
});
setDraftQuestions(qs.length ? qs : [createEmptyQuestion()]);
setDraftStatus('Черновик из LLM перенесён в редактор. Сохраните при необходимости.');
}
function addQuestion() {
setDraftQuestions((prev) => [...prev, createEmptyQuestion()]);
}
function removeQuestion(index) {
setDraftQuestions((prev) => prev.filter((_, i) => i !== index));
}
function addOption(qIndex) {
setDraftQuestions((prev) => {
const next = prev.map((q, i) =>
i === qIndex
? {
...q,
options: [...q.options, { key: newKey(), text: '', isCorrect: false }],
}
: q
);
return next;
});
}
function removeOption(qIndex, oIndex) {
setDraftQuestions((prev) => {
const next = prev.map((q, i) => {
if (i !== qIndex) {
return q;
}
if (q.options.length <= 1) {
return q;
}
return { ...q, options: q.options.filter((_, j) => j !== oIndex) };
});
return next;
});
}
async function activateVersion(vid) {
if (!window.confirm('Сделать эту версию активной? Новые попытки пойдут по выбранной версии.')) {
return;
}
try {
await api(`/api/tests/${id}/versions/${vid}/activate`, {
method: 'POST',
body: JSON.stringify({}),
});
load();
} catch (e) {
setErr(e.message);
}
}
async function setChainVisible(chainActive) {
const act = chainActive ? 'снова показывать' : 'скрывать';
if (!window.confirm(`Цепочка будет ${act} в общем списке тестов. Продолжить?`)) {
return;
}
setDeactivateBusy(true);
setErr(null);
try {
await api(`/api/tests/${id}`, {
method: 'PATCH',
body: JSON.stringify({ chainActive }),
});
await load();
} catch (e) {
setErr(e.message);
} finally {
setDeactivateBusy(false);
}
}
if (err) {
return <p className="error-text">{err}</p>;
}
if (!data && !taker) {
return <p className="text-muted">Загрузка</p>;
}
if (taker) {
const { test: t, hasActiveVersion } = taker.summary;
const title = t?.title || 'Тест';
return (
<div>
<p className="link-back">
<Link to="/tests"> к списку</Link>
</p>
<h1
className="font-headline"
style={{ fontSize: '1.35rem', marginTop: 0, marginBottom: '0.35rem' }}
>
{title}
</h1>
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.4rem', fontSize: 14 }}>
{formatTestAuthorLabel(user, t?.createdBy, t?.authorFullName)}
</p>
{t?.description && (
<p className="text-muted" style={{ marginTop: 0, marginBottom: '1rem' }}>
{t.description}
</p>
)}
<p className="text-muted" style={{ marginTop: 0, marginBottom: '1rem' }}>
Порог для зачёта: <strong>{t?.passingThreshold ?? '—'}%</strong>
</p>
{!hasActiveVersion && (
<div className="callout callout--warning" role="status">
Активная версия недоступна. Обратитесь к автору теста.
</div>
)}
</div>
);
}
const { test, versions, hasAttempts } = data;
const title = test?.title || 'Тест';
const assignSelectedInList = assignPeople.filter((p) =>
assignSelected.has(assignPersonKey(p))
);
return (
<div className="test-detail-page test-detail-page--with-fixed-actions">
<div className="cabinet-brick cabinet-brick--hero">
<p className="link-back" style={{ marginTop: 0 }}>
<Link to="/tests"> к списку</Link>
</p>
<h1
className="font-headline"
style={{ fontSize: '1.35rem', marginTop: 0, marginBottom: '0.35rem' }}
>
{title}
</h1>
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.5rem', fontSize: 14 }}>
{formatTestAuthorLabel(user, test?.createdBy, test?.authorFullName)}
</p>
<p className="muted" style={{ marginBottom: 0 }}>
Обновлён: {fmtDt(test?.updatedAt || test?.createdAt)}
{test?.chainActive === false && (
<span className="error-text" style={{ marginLeft: '0.75rem' }}>
· скрыт из списка
</span>
)}
</p>
{test?.chainActive === false && (
<div className="callout callout--warning" role="status" style={{ marginTop: '0.75rem' }}>
Скрыт из общего списка.
</div>
)}
{chain?.hasAnyAttempt && (
<div className="callout callout--warning" role="status" style={{ marginTop: '0.75rem' }}>
При сохранении будет создана новая версия теста.
</div>
)}
</div>
<AccSection
title="О тесте"
subtitle="Название, описание и порог зачёта"
defaultOpen
>
<div className="draft-block">
<label className="form-label" htmlFor="draft-title">
Название
</label>
<input
id="draft-title"
className="form-input"
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
/>
<label className="form-label" htmlFor="draft-desc" style={{ marginTop: '0.75rem' }}>
Описание
</label>
<textarea
id="draft-desc"
className="form-input"
rows={2}
value={draftDescription}
onChange={(e) => setDraftDescription(e.target.value)}
/>
<label className="form-label" htmlFor="draft-pass" style={{ marginTop: '0.75rem' }}>
Порог зачёта, %
</label>
<input
id="draft-pass"
type="number"
className="form-input"
min={0}
max={100}
inputMode="numeric"
value={draftPassing}
onChange={(e) => setDraftPassing(e.target.value)}
style={{ maxWidth: 120 }}
/>
</div>
</AccSection>
<AccSection
title="Вопросы"
subtitle="Тексты, варианты и при необходимости загрузка из файла"
defaultOpen
>
<div className="draft-block">
<div className="test-detail-ai-panel" aria-label="Параметры генерации теста с ИИ">
<p
className="font-headline"
style={{ fontSize: '0.95rem', marginTop: 0, marginBottom: '0.65rem' }}
>
Генерация сетки вопросов (ИИ)
</p>
<label className="form-label" htmlFor="ai-gen-topic">
Тема
</label>
<input
id="ai-gen-topic"
className="form-input"
value={aiGenTopic}
onChange={(e) => setAiGenTopic(e.target.value)}
placeholder="По умолчанию — как в «Название»; при необходимости уточните"
/>
<div
className="inline-actions"
style={{ marginTop: '0.75rem', flexWrap: 'wrap', alignItems: 'flex-end', gap: '0.9rem' }}
>
<div>
<label className="form-label" htmlFor="ai-n-questions" style={{ display: 'block' }}>
Вопросов
</label>
<input
id="ai-n-questions"
type="number"
className="form-input"
min={1}
max={30}
inputMode="numeric"
value={aiQuestionsCount}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
if (!Number.isFinite(n) || n < 1) {
return;
}
setAiQuestionsCount(Math.min(30, Math.max(1, n)));
}}
style={{ maxWidth: 90 }}
/>
</div>
<div>
<label className="form-label" htmlFor="ai-n-answers" style={{ display: 'block' }}>
Вариантов
</label>
<input
id="ai-n-answers"
type="number"
className="form-input"
min={2}
max={8}
inputMode="numeric"
value={aiAnswersCount}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
if (!Number.isFinite(n) || n < 2) {
return;
}
setAiAnswersCount(Math.min(8, Math.max(2, n)));
}}
style={{ maxWidth: 90 }}
/>
</div>
<button
type="button"
className="btn btn-ghost"
style={{ marginTop: '0.1rem' }}
disabled={aiTestBusy}
onClick={runAiGenerateTest}
>
{aiTestBusy ? 'Генерация…' : 'Сгенерировать тест (ИИ)'}
</button>
</div>
</div>
{draftQuestions.map((q, qi) => (
<div
key={q.key}
className={`question-editor-block${qi === 0 ? ' question-editor-block--first' : ''}`}
>
<div className="question-editor-block__header">
<label
className="form-label question-editor-block__title"
htmlFor={`qtext-${q.key}`}
>
Вопрос {qi + 1}
</label>
<button
type="button"
className="btn btn-ghost btn--sm question-editor-block__ai-btn"
disabled={aiQBusy != null}
onClick={() => runAiGenerateQuestion(qi)}
>
{aiQBusy === qi ? '…' : 'Сгенерировать вопрос (ИИ)'}
</button>
</div>
<textarea
id={`qtext-${q.key}`}
className="form-input"
rows={2}
value={q.text}
onChange={(e) => {
const v = e.target.value;
setDraftQuestions((prev) =>
prev.map((row, i) => (i === qi ? { ...row, text: v } : row))
);
}}
/>
<label
className="form-label"
style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}
>
<input
type="checkbox"
checked={q.hasMultipleAnswers}
onChange={(e) => {
const v = e.target.checked;
setDraftQuestions((prev) =>
prev.map((row, i) => (i === qi ? { ...row, hasMultipleAnswers: v } : row))
);
}}
/>
Несколько верных ответов
</label>
<p className="muted" style={{ marginBottom: 6, fontSize: 13 }}>
Варианты
</p>
<ul className="question-options-list" style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{q.options.map((o, oi) => (
<li key={o.key} className="question-option-row">
<input
key={`${o.key}-${q.hasMultipleAnswers ? 'm' : 's'}`}
className="question-option-row__mark"
type={q.hasMultipleAnswers ? 'checkbox' : 'radio'}
name={q.hasMultipleAnswers ? undefined : `q-${q.key}-correct`}
checked={o.isCorrect}
onChange={(e) => {
const v = e.target.checked;
setDraftQuestions((prev) =>
prev.map((row, i) => {
if (i !== qi) {
return row;
}
if (!row.hasMultipleAnswers && v) {
return {
...row,
options: row.options.map((op, j) => ({
...op,
isCorrect: j === oi,
})),
};
}
const options = row.options.map((op, j) =>
j === oi ? { ...op, isCorrect: v } : op
);
return { ...row, options };
})
);
}}
title="Верный ответ"
aria-label="Пометить как верный"
/>
<input
className="form-input question-option-row__text"
value={o.text}
onChange={(e) => {
const v = e.target.value;
setDraftQuestions((prev) =>
prev.map((row, i) => {
if (i !== qi) {
return row;
}
return {
...row,
options: row.options.map((op, j) => (j === oi ? { ...op, text: v } : op)),
};
})
);
}}
placeholder="Текст варианта"
/>
{q.options.length > 1 && (
<button
type="button"
className="question-option-remove"
onClick={() => removeOption(qi, oi)}
title="Удалить вариант"
aria-label="Удалить вариант"
>
<span className="material-symbols-outlined" aria-hidden>
close
</span>
</button>
)}
</li>
))}
</ul>
<div className="question-editor__footer">
<button
type="button"
className="btn btn-ghost btn--sm"
onClick={() => addOption(qi)}
>
+ вариант
</button>
{draftQuestions.length > 1 && (
<button
type="button"
className="btn btn-ghost btn--sm"
onClick={() => removeQuestion(qi)}
>
Удалить вопрос
</button>
)}
</div>
</div>
))}
<div className="test-detail-add-question">
<button
type="button"
className="btn btn-ghost question-editor__add-question"
onClick={addQuestion}
>
+ вопрос
</button>
</div>
<div className="test-detail-subsection test-detail-subsection--import">
<h3 className="test-detail-subsection__title">Документ в вопросы</h3>
<p className="muted test-detail-hint" style={{ marginTop: 0 }}>
PDF, Word или текст вставьте в черновик вопросов.
</p>
<div
className="import-file-row import-file-row--block"
style={{ marginBottom: importPreview || importErr ? '0.5rem' : 0, marginTop: 0 }}
>
<input
id="import-test-file"
className="import-file-input"
type="file"
accept=".pdf,.docx,.txt,.md,application/pdf"
onChange={onImportFile}
disabled={importBusy}
aria-label="Выбрать файл для импорта"
/>
<label
htmlFor="import-test-file"
className={`btn btn-ghost import-file-label${importBusy ? ' is-busy' : ''}`}
style={importBusy ? { pointerEvents: 'none' } : undefined}
>
{importBusy ? 'Обработка…' : 'Выбрать файл'}
</label>
</div>
{importErr && (
<p className="error-text" role="alert">
{importErr}
</p>
)}
{importPreview && (
<div className="draft-block" style={{ marginTop: '0.65rem', marginBottom: 0 }}>
<p className="muted" style={{ marginTop: 0, marginBottom: 0 }}>
{importPreview.generation?.message}
</p>
{importPreview.generation?.textPreview && !importPreview.generation?.available && (
<pre
className="form-input"
style={{
maxHeight: 180,
overflow: 'auto',
fontSize: 13,
marginTop: 8,
whiteSpace: 'pre-wrap',
}}
>
{importPreview.generation.textPreview}
{importPreview.textLength > 4000 ? '…' : ''}
</pre>
)}
{importPreview.generation?.draft && (
<div className="inline-actions" style={{ marginTop: 8, marginBottom: 0 }}>
<button
type="button"
className="btn btn-ghost"
onClick={applyGeneratedDraft}
>
Применить сгенерированный черновик
</button>
</div>
)}
{importPreview.extractedText && (
<div className="inline-actions" style={{ marginTop: 8, marginBottom: 0 }}>
<button
type="button"
className="btn btn-ghost"
onClick={applyExtractedToQuestion}
>
Вставить в первый вопрос (до 2000 симв.)
</button>
</div>
)}
</div>
)}
</div>
</div>
</AccSection>
<div className="cabinet-brick editor-actions-flow" style={{ marginTop: 0 }}>
<div
className="actions-bar"
style={{ marginBottom: draftStatus ? '0.35rem' : 0 }}
>
<button type="button" className="btn btn-primary" onClick={saveDraft}>
Сохранить черновик
</button>
<Link to="/tests" className="btn btn-ghost">
К списку
</Link>
</div>
{draftStatus && (
<p className="muted editor-actions-status--flow" style={{ marginTop: 0, marginBottom: 0 }}>
{draftStatus}
</p>
)}
</div>
<AccSection
title="История"
subtitle="Версии теста и кто проходил"
defaultOpen={false}
>
<div className="test-detail-subsection test-detail-subsection--tight">
<h3 className="test-detail-subsection__title">Версии</h3>
<ul className="version-card-list" aria-label="Список версий теста">
{versions.map((r) => (
<li key={r.id} className="surface-card version-card-list__item">
<div className="version-card-list__row">
<div className="version-card-list__main">
<div className="version-card-list__title-line">
<span className="font-headline" style={{ fontSize: '1rem' }}>
v{r.version}
</span>
{r.is_active && (
<span className="code-inline" style={{ fontSize: '0.7rem' }}>
текущая
</span>
)}
</div>
<p className="muted mono" style={{ margin: '0.4rem 0 0', fontSize: '0.8rem' }}>
{fmtDt(r.created_at)}
</p>
<p className="muted" style={{ margin: '0.2rem 0 0', fontSize: '0.8rem' }}>
Активна: {r.is_active ? 'да' : 'нет'}
</p>
</div>
{!r.is_active && (
<button
type="button"
className="btn btn-ghost btn--sm version-card-list__action"
onClick={() => activateVersion(r.id)}
>
Сделать активной
</button>
)}
</div>
</li>
))}
</ul>
</div>
{attemptsList !== undefined && (
<div className="test-detail-subsection">
<h3 className="test-detail-subsection__title">Прохождения</h3>
{attemptsList == null && (
<p
className={attemptsErr ? 'error-text' : 'text-muted'}
style={{ margin: 0 }}
role={attemptsErr ? 'alert' : undefined}
>
{attemptsErr || 'Список прогонов сейчас недоступен.'}
</p>
)}
{attemptsList && attemptsList.length > 0 && (
<ul className="attempts-card-list" aria-label="Список прогонов">
{attemptsList.map((a) => {
const when = a.completedAt
? fmtDt(a.completedAt)
: a.startedAt
? fmtDt(a.startedAt)
: '—';
const result =
a.status === 'completed' && a.totalQuestions != null ? (
<>
{a.correctCount}/{a.totalQuestions}
{a.passed ? ' · зачёт' : ' · незачёт'}
</>
) : (
a.status
);
return (
<li key={a.id} className="surface-card attempts-card-list__item">
<div className="attempts-card-list__row">
<div className="attempts-card-list__main">
<p
className="muted attempts-card-list__when mono"
style={{ margin: 0, fontSize: '0.8rem' }}
>
{when}
</p>
<p
className="attempts-card-list__who"
style={{ margin: '0.35rem 0 0', fontWeight: 600 }}
>
{a.attempterName || '—'}
{a.attempterLogin && (
<span className="code-inline" style={{ fontSize: 11, marginLeft: 6 }}>
{a.attempterLogin}
</span>
)}
</p>
<p className="muted" style={{ margin: '0.25rem 0 0', fontSize: '0.85rem' }}>
v{a.testVersion} ·{' '}
<span className="attempts-card-list__result">{result}</span>
</p>
</div>
{a.status === 'completed' && (
<Link
to={`/tests/${id}/attempts/${a.id}/review`}
className="btn btn-ghost btn--sm attempts-card-list__action"
>
Разбор
</Link>
)}
</div>
</li>
);
})}
</ul>
)}
{attemptsList && attemptsList.length === 0 && (
<p className="text-muted" style={{ margin: 0 }}>
Пока нет зарегистрированных прогонов.
</p>
)}
</div>
)}
</AccSection>
<AccSection
title="Показ в каталоге"
subtitle={
assignmentUi && data
? 'Видимость в списке и выдача сотрудникам'
: 'Показать или скрыть в общем списке'
}
defaultOpen={false}
>
<div className="test-detail-subsection test-detail-subsection--tight">
<h3 className="test-detail-subsection__title">Видимость</h3>
<p className="test-detail-hint" style={{ marginTop: 0 }}>
Скрытые тесты в общем списке не показываются; ссылку на тест по-прежнему можно открыть.
</p>
<div className="publication-visibility__actions" style={{ marginTop: 4 }}>
{test?.chainActive !== false ? (
<button
type="button"
className="btn btn-ghost"
disabled={deactivateBusy}
onClick={() => setChainVisible(false)}
>
Скрыть из списка
</button>
) : (
<button
type="button"
className="btn btn-ghost"
disabled={deactivateBusy}
onClick={() => setChainVisible(true)}
>
Снова показать в списке
</button>
)}
</div>
</div>
{assignmentUi && data && (
<div className="test-detail-subsection">
<h3 className="test-detail-subsection__title">Кому выдать</h3>
<p className="test-detail-hint" style={{ marginTop: 0 }}>
Список с учётом поиска и фильтров; можно отметить всех на экране.
</p>
{assignErr && (
<p className="error-text" role="alert" style={{ marginTop: 0, marginBottom: 8 }}>
{assignErr}
</p>
)}
<div className="assign-toolbar">
<input
type="search"
className="form-input assign-toolbar__search"
placeholder="Поиск: ФИО, логин"
value={assignSearch}
onChange={(e) => setAssignSearch(e.target.value)}
aria-label="Поиск сотрудника"
/>
<select
className="form-input"
value={assignDept}
onChange={(e) => setAssignDept(e.target.value)}
aria-label="Фильтр по отделу"
>
<option value="__all__">Все отделы</option>
{assignDepts.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
<select
className="form-input"
value={assignClinic}
onChange={(e) => setAssignClinic(e.target.value)}
aria-label="Кто в модуле"
>
<option value="all">Все</option>
<option value="with">С учёткой в модуле</option>
<option value="without">Без учётки (создать)</option>
</select>
{assignLoadBusy && <span className="text-muted">Загрузка</span>}
</div>
{assignPeople.length > 0 && (
<div
className="publication-visibility__actions"
style={{ marginTop: '0.45rem', marginBottom: '0.25rem' }}
>
<button
type="button"
className="btn btn-ghost btn--sm"
onClick={selectAllVisible}
disabled={assignLoadBusy}
>
Выбрать всех ({assignPeople.length})
</button>
</div>
)}
{assignPeople.length > 0 ? (
<div className="assign-list" role="group" aria-label="Список сотрудников">
{assignPeople.map((p) => {
const k = assignPersonKey(p);
const picked = assignSelected.has(k);
return (
<label
key={k}
className={`assign-row${picked ? ' assign-row--selected' : ''}`}
>
<input
type="checkbox"
checked={picked}
onChange={() => toggleAssignPerson(p)}
/>
<span className="assign-row__text">
<span className="assign-row__fio">{p.fio}</span>
{p.webLogin && <span className="assign-row__login">{p.webLogin}</span>}
<span className="assign-row__meta">
{p.departments || '—'}
{p.clinicUserId
? ' · есть учётка в модуле'
: ' · нет учётки (создадим при назначении)'}
</span>
</span>
</label>
);
})}
</div>
) : (
!assignLoadBusy && (
<p className="text-muted" style={{ margin: '0.5rem 0' }}>
Нет подходящих записей. Смените фильтры или поиск.
</p>
)
)}
<div className="publication-visibility__actions" style={{ marginTop: '0.75rem' }}>
<button
type="button"
className="btn btn-ghost"
disabled={assignSelectedInList.length === 0}
onClick={postAssign}
>
Назначить выбранных
{assignSelectedInList.length > 0
? ` (${assignSelectedInList.length})`
: ''}
</button>
</div>
{assignMsg && <p className="text-muted" style={{ marginTop: 8, marginBottom: 0 }}>{assignMsg}</p>}
</div>
)}
</AccSection>
<div
className="test-detail-fixed-actions"
role="region"
aria-label="Быстрые действия с черновиком"
>
<div className="test-detail-fixed-actions__inner">
{draftStatus && (
<p className="muted test-detail-fixed-actions__status" role="status">
{draftStatus}
</p>
)}
<div className="actions-bar">
<button type="button" className="btn btn-primary" onClick={saveDraft}>
Сохранить черновик
</button>
<Link to="/tests" className="btn btn-ghost">
К списку
</Link>
</div>
</div>
</div>
</div>
);
}