diff --git a/backend/src/routes/tests.js b/backend/src/routes/tests.js index dd2f53e..dcf785b 100644 --- a/backend/src/routes/tests.js +++ b/backend/src/routes/tests.js @@ -9,6 +9,28 @@ import pool, { query } from '../db/db.js'; import { authenticate } from '../middleware/auth.js'; import { hasAnyAttemptForTest } from '../services/testChainService.js'; import { saveTestDraft, createTestWithVersion } from '../services/testDraftService.js'; +import { + getEditorContent, + getPlayContent, + submitAttempt, + getAttemptReviewForUser, + listTestAttemptsForAuthor, +} from '../services/testAttemptService.js'; +import { extractTextFromFile } from '../services/documentExtractService.js'; +import { generationForImportDocument } from '../services/documentGenService.js'; +import { RU } from '../messages/ru.js'; +import { isTestAuthor } from '../config/devAuthor.js'; +import { ensureClinicUserIdForStaff } from '../services/assignmentUserService.js'; +import { + queryTestsVisibleToUser, + userHasTestAccess, +} from '../services/testAccessService.js'; +import { isAssignmentFeatureEnabled } from '../config/featureFlags.js'; +import { + generateFullTestByShape, + generateOrRephraseQuestion, + parseAndValidateShape, +} from '../services/aiEditorService.js'; const router = express.Router(); const upload = multer({ @@ -28,35 +50,59 @@ function asyncHandler(fn) { }; } -/** D.1 — раньше /:id, иначе id = "import" */ +/** D.1 + D.2 + D.3 (заглушка) — `POST` до маршрутов `/:id` */ router.post( '/import/document', authenticate, - upload.single('file'), + (req, res, next) => { + upload.single('file')(req, res, (err) => { + if (err) { + if (err.code === 'LIMIT_FILE_SIZE') { + return res.status(413).json({ error: RU.fileTooLarge }); + } + return next(err); + } + next(); + }); + }, asyncHandler(async (req, res) => { if (!req.file) { - return res.status(400).json({ error: 'file field required' }); + return res.status(400).json({ error: RU.fileFieldRequired }); } const p = req.file.path; - let size = 0; + const { mimetype, originalname } = req.file; + let size; + let extractedText; try { const st = await fs.stat(p); size = st.size; - await fs.unlink(p); - } catch { + extractedText = await extractTextFromFile(mimetype, p, originalname); + } catch (e) { try { await fs.unlink(p); } catch { // ignore } - return res.status(500).json({ error: 'upload failed' }); + if (e.status) { + return res.status(e.status).json({ error: e.message }); + } + console.error('import document:', e); + return res.status(500).json({ error: RU.uploadFailed }); } + try { + await fs.unlink(p); + } catch { + // D.5: временный файл удалён; при ошибке extract уже удалили выше + } + const generation = await generationForImportDocument(extractedText); res.json({ received: true, - originalName: req.file.originalname, + originalName: originalname, + mime: mimetype, size, - message: - 'Файл принят; извлечение текста и генерация (D.2–D.3) — в следующем шаге', + extractedText, + textLength: extractedText.length, + generation, }); }) ); @@ -65,15 +111,19 @@ router.get( '/', authenticate, asyncHandler(async (req, res) => { - const { rows } = await query( + const { rows } = await queryTestsVisibleToUser(req.user.id); + const { rows: hidden } = await query( `SELECT t.id, t.title, t.description, t.is_active AS chain_active, - t.created_at, t.updated_at, tv.id AS active_version_id, tv.version + t.created_at, t.updated_at, tv.id AS active_version_id, tv.version, + t.created_by, u.full_name AS author_full_name FROM tests t INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true - WHERE t.is_active = true - ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC` + INNER JOIN users u ON u.id = t.created_by + WHERE t.is_active = false AND t.created_by = $1 + ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC`, + [req.user.id] ); - res.json({ tests: rows }); + res.json({ tests: rows, hiddenByYou: hidden }); }) ); @@ -83,7 +133,7 @@ router.post( asyncHandler(async (req, res) => { const { title, description } = req.body; if (!title || typeof title !== 'string') { - return res.status(400).json({ error: 'title required' }); + return res.status(400).json({ error: RU.titleRequired }); } const out = await createTestWithVersion(pool, req.user.id, { title, @@ -93,27 +143,172 @@ router.post( }) ); +/** + * V.8: краткая карточка цепочки — одна строка (активная версия), без дублей. + * Не-автор не видит скрытую с общего списка цепочку (кроме прямой ссылки автора). + */ +router.get( + '/:id/summary', + authenticate, + asyncHandler(async (req, res) => { + const testId = req.params.id; + const { rows } = await query( + `SELECT t.id, t.title, t.description, t.passing_threshold, t.is_active AS chain_active, + t.created_by, t.created_at, t.updated_at, tv.id AS active_version_id, tv.version, + u.full_name AS author_full_name + FROM tests t + LEFT JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true + LEFT JOIN users u ON u.id = t.created_by + WHERE t.id = $1`, + [testId] + ); + if (!rows.length) { + return res.status(404).json({ error: RU.testNotFound }); + } + const row = rows[0]; + const isAuthor = isTestAuthor(row.created_by, req.user.id); + if (row.chain_active === false && !isAuthor) { + return res.status(404).json({ error: RU.testNotFound }); + } + if (!isAuthor) { + const acc = await userHasTestAccess(req.user.id, testId); + if (!acc.ok) { + return res.status(404).json({ error: RU.testNotFound }); + } + } + res.json({ + test: { + id: row.id, + title: row.title, + description: row.description, + passingThreshold: row.passing_threshold, + chainActive: row.chain_active, + activeVersionId: row.active_version_id, + version: row.version, + createdAt: row.created_at, + updatedAt: row.updated_at, + createdBy: row.created_by, + authorFullName: row.author_full_name, + }, + isAuthor, + hasActiveVersion: row.active_version_id != null, + }); + }) +); + router.get( '/:id/versions', authenticate, asyncHandler(async (req, res) => { const testId = req.params.id; - const { rows: t } = await query(`SELECT id, created_by FROM tests WHERE id = $1`, [ - testId, - ]); + const { rows: t } = await query( + `SELECT t.id, t.title, t.created_by, t.is_active, t.created_at, t.updated_at, t.description, + u.full_name AS author_full_name + FROM tests t + INNER JOIN users u ON u.id = t.created_by + WHERE t.id = $1`, + [testId] + ); if (!t.length) { - return res.status(404).json({ error: 'Test not found' }); + return res.status(404).json({ error: RU.testNotFound }); } - if (t[0].created_by !== req.user.id) { - return res.status(403).json({ error: 'Forbidden' }); + if (!isTestAuthor(t[0].created_by, req.user.id)) { + return res.status(403).json({ error: RU.forbidden }); } + const testRow = t[0]; const { rows } = await query( `SELECT id, version, is_active, parent_id, created_at FROM test_versions WHERE test_id = $1 ORDER BY version`, [testId] ); const hasAttempts = await hasAnyAttemptForTest(pool, testId); - res.json({ versions: rows, hasAttempts }); + res.json({ + test: { + id: testRow.id, + title: testRow.title, + description: testRow.description, + chainActive: testRow.is_active, + createdAt: testRow.created_at, + updatedAt: testRow.updated_at, + createdBy: testRow.created_by, + authorFullName: testRow.author_full_name, + }, + versions: rows, + hasAttempts, + }); + }) +); + +router.get( + '/:id/editor', + authenticate, + asyncHandler(async (req, res) => { + const out = await getEditorContent(pool, req.user.id, req.params.id); + res.json(out); + }) +); + +/** + * ИИ: заполнить тест по текущей сетке (число вопросов = len(shape), варианты = optionsCount). + * Только автор. Тело: { testTitle?, testDescription?, shape: [{ optionsCount, hasMultipleAnswers }] } + */ +router.post( + '/:id/ai/generate-test', + authenticate, + asyncHandler(async (req, res) => { + const testId = req.params.id; + const { rows: t } = await query( + `SELECT id, created_by FROM tests WHERE id = $1`, + [testId] + ); + if (!t.length) { + return res.status(404).json({ error: RU.testNotFound }); + } + if (!isTestAuthor(t[0].created_by, req.user.id)) { + return res.status(403).json({ error: RU.forbidden }); + } + const b = req.body && typeof req.body === 'object' ? req.body : {}; + const shape = parseAndValidateShape(b.shape); + const testTitle = typeof b.testTitle === 'string' ? b.testTitle : ''; + const testDescription = typeof b.testDescription === 'string' ? b.testDescription : ''; + const draft = await generateFullTestByShape(testTitle, testDescription, shape); + res.json({ ok: true, draft }); + }) +); + +/** + * ИИ: один вопрос — пустой текст → сгенерировать вопрос и варианты; иначе переформулировать только текст. + * Только автор. Тело: { testTitle?, testDescription?, questionText, optionsCount, hasMultipleAnswers } + */ +router.post( + '/:id/ai/generate-question', + authenticate, + asyncHandler(async (req, res) => { + const testId = req.params.id; + const { rows: tr } = await query( + `SELECT id, created_by FROM tests WHERE id = $1`, + [testId] + ); + if (!tr.length) { + return res.status(404).json({ error: RU.testNotFound }); + } + if (!isTestAuthor(tr[0].created_by, req.user.id)) { + return res.status(403).json({ error: RU.forbidden }); + } + const b = req.body && typeof req.body === 'object' ? req.body : {}; + const testTitle = typeof b.testTitle === 'string' ? b.testTitle : ''; + const testDescription = typeof b.testDescription === 'string' ? b.testDescription : ''; + const questionText = typeof b.questionText === 'string' ? b.questionText : ''; + const optionsCount = b.optionsCount; + const hasMultipleAnswers = Boolean(b.hasMultipleAnswers); + const out = await generateOrRephraseQuestion( + testTitle, + testDescription, + questionText, + optionsCount, + hasMultipleAnswers + ); + res.json({ ok: true, ...out }); }) ); @@ -128,17 +323,17 @@ router.post( [testId] ); if (!t.length) { - return res.status(404).json({ error: 'Test not found' }); + return res.status(404).json({ error: RU.testNotFound }); } - if (t[0].created_by !== req.user.id) { - return res.status(403).json({ error: 'Forbidden' }); + if (!isTestAuthor(t[0].created_by, req.user.id)) { + return res.status(403).json({ error: RU.forbidden }); } const { rows: v } = await query( `SELECT id FROM test_versions WHERE test_id = $1 AND id = $2`, [testId, versionId] ); if (!v.length) { - return res.status(404).json({ error: 'Version not found' }); + return res.status(404).json({ error: RU.versionNotFound }); } const client = await pool.connect(); try { @@ -170,17 +365,17 @@ router.patch( const { isActive, chainActive } = req.body; const chain = chainActive ?? isActive; if (typeof chain !== 'boolean') { - return res.status(400).json({ error: 'chainActive (boolean) required' }); + return res.status(400).json({ error: RU.chainActiveRequired }); } const { rows: t } = await query( `SELECT id, created_by FROM tests WHERE id = $1`, [testId] ); if (!t.length) { - return res.status(404).json({ error: 'Test not found' }); + return res.status(404).json({ error: RU.testNotFound }); } - if (t[0].created_by !== req.user.id) { - return res.status(403).json({ error: 'Forbidden' }); + if (!isTestAuthor(t[0].created_by, req.user.id)) { + return res.status(403).json({ error: RU.forbidden }); } await query( `UPDATE tests SET is_active = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`, @@ -190,6 +385,111 @@ router.patch( }) ); +/** + * Пакетное назначение — `test_assignments` + `test_assignment_targets` (user). + * Включение: `development` или `CLINIC_ASSIGNMENT_ENABLED=1`. Только автор. Тело: `userIds`, `staffIds` и/или одиночные `userId` / `staffId`. + */ +router.post( + '/:id/assign', + authenticate, + asyncHandler(async (req, res) => { + if (!isAssignmentFeatureEnabled()) { + return res.status(404).json({ error: RU.notFound }); + } + const testId = req.params.id; + const b = req.body && typeof req.body === 'object' ? req.body : {}; + const userIds = new Set(); + + if (Array.isArray(b.userIds)) { + for (const u of b.userIds) { + if (typeof u === 'string' && u.trim()) { + userIds.add(u.trim()); + } + } + } + if (Array.isArray(b.staffIds)) { + for (const s of b.staffIds) { + const n = Number(s); + if (Number.isFinite(n) && n >= 1) { + userIds.add(await ensureClinicUserIdForStaff(pool, n)); + } + } + } + if (userIds.size === 0) { + if (b.staffId != null && b.userId) { + return res.status(400).json({ error: RU.assignmentUserOrStaff }); + } + if (b.staffId != null) { + const sid = Number(b.staffId); + if (Number.isNaN(sid)) { + return res.status(400).json({ error: RU.assignmentUserRequired }); + } + userIds.add(await ensureClinicUserIdForStaff(pool, sid)); + } else if (typeof b.userId === 'string' && b.userId.trim()) { + userIds.add(b.userId.trim()); + } + } + if (userIds.size === 0) { + return res.status(400).json({ error: RU.assignmentUserRequired }); + } + + const { rows: t } = await query( + `SELECT id, created_by FROM tests WHERE id = $1`, + [testId] + ); + if (!t.length) { + return res.status(404).json({ error: RU.testNotFound }); + } + if (!isTestAuthor(t[0].created_by, req.user.id)) { + return res.status(403).json({ error: RU.forbidden }); + } + const { rows: tv } = await query( + `SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true LIMIT 1`, + [testId] + ); + if (!tv.length) { + return res.status(400).json({ error: RU.noActiveVersion }); + } + for (const uid of userIds) { + const { rows: u } = await query( + `SELECT id FROM users WHERE id = $1 AND is_active = true`, + [uid] + ); + if (!u.length) { + return res.status(400).json({ error: RU.userNotFound }); + } + } + const versionId = tv[0].id; + const client = await pool.connect(); + let assignmentId; + try { + await client.query('BEGIN'); + const { rows: ins } = await client.query( + `INSERT INTO test_assignments (test_version_id, assigned_by, max_attempts) + VALUES ($1, $2, 5) RETURNING id`, + [versionId, req.user.id] + ); + assignmentId = ins[0].id; + for (const uid of userIds) { + await client.query( + `INSERT INTO test_assignment_targets (assignment_id, target_type, target_id) + VALUES ($1, 'user', $2)`, + [assignmentId, uid] + ); + } + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } + res + .status(201) + .json({ ok: true, assignmentId, count: userIds.size }); + }) +); + router.post( '/:id/draft', authenticate, @@ -204,6 +504,10 @@ router.post( authenticate, asyncHandler(async (req, res) => { const testId = req.params.id; + const acc = await userHasTestAccess(req.user.id, testId); + if (!acc.ok) { + return res.status(404).json({ error: RU.testNotFound }); + } const { rows: tv } = await query( `SELECT tv.id AS test_version_id FROM test_versions tv @@ -211,7 +515,7 @@ router.post( [testId] ); if (!tv.length) { - return res.status(404).json({ error: 'No active version' }); + return res.status(404).json({ error: RU.noActiveVersion }); } const testVersionId = tv[0].test_version_id; const { rows: mx } = await query( @@ -230,11 +534,98 @@ router.post( }) ); +/** Только автор: список попыток по цепочке (все версии) */ +router.get( + '/:id/attempts', + authenticate, + asyncHandler(async (req, res) => { + const rows = await listTestAttemptsForAuthor(pool, req.user.id, req.params.id); + res.json({ + attempts: rows.map((r) => ({ + id: r.id, + userId: r.user_id, + status: r.status, + attemptNumber: r.attempt_number, + startedAt: r.started_at, + completedAt: r.completed_at, + correctCount: r.correct_count, + totalQuestions: r.total_questions, + passed: r.passed, + testVersion: r.test_version, + attempterName: r.attempter_name, + attempterLogin: r.attempter_login, + })), + }); + }) +); + +/** Разбор завершённой попытки: владелец или автор */ +router.get( + '/:id/attempts/:aid/review', + authenticate, + asyncHandler(async (req, res) => { + const review = await getAttemptReviewForUser( + pool, + req.user.id, + req.params.id, + req.params.aid + ); + res.json(review); + }) +); + +router.get( + '/:id/attempts/:aid/play', + authenticate, + asyncHandler(async (req, res) => { + const out = await getPlayContent(pool, req.user.id, req.params.id, req.params.aid); + res.json(out); + }) +); + +router.post( + '/:id/attempts/:aid/submit', + authenticate, + asyncHandler(async (req, res) => { + const out = await submitAttempt( + pool, + req.user.id, + req.params.id, + req.params.aid, + req.body?.answers + ); + res.json(out); + }) +); + router.get( '/:id/chain-info', authenticate, asyncHandler(async (req, res) => { const testId = req.params.id; + const acc = await userHasTestAccess(req.user.id, testId); + if (acc.notFound) { + return res.status(404).json({ error: RU.testNotFound }); + } + if (!acc.ok) { + return res.status(404).json({ error: RU.testNotFound }); + } + const { rows: tr } = await query( + `SELECT t.is_active AS chain_active FROM tests t WHERE t.id = $1`, + [testId] + ); + if (!tr.length) { + return res.status(404).json({ error: RU.testNotFound }); + } + if (tr[0].chain_active === false) { + const { rows: auth } = await query( + `SELECT created_by FROM tests WHERE id = $1`, + [testId] + ); + if (!isTestAuthor(auth[0].created_by, req.user.id)) { + return res.status(404).json({ error: RU.testNotFound }); + } + } const has = await hasAnyAttemptForTest(pool, testId); res.json({ testId, hasAnyAttempt: has }); }) diff --git a/backend/src/services/testAccessService.js b/backend/src/services/testAccessService.js new file mode 100644 index 0000000..f809110 --- /dev/null +++ b/backend/src/services/testAccessService.js @@ -0,0 +1,64 @@ +/** + * Кто видит тест: автор цепочки и пользователи с назначением (target user = clinic user id). + */ +import { isTestAuthor } from '../config/devAuthor.js'; +import { query } from '../db/db.js'; + +/** + * @param {string} userId + * @param {string} testId + * @returns {Promise<{ ok: boolean, isAuthor: boolean, notFound: boolean }>} + */ +export async function userHasTestAccess(userId, testId) { + const { rows } = await query( + `SELECT t.created_by FROM tests t WHERE t.id = $1`, + [testId] + ); + if (!rows.length) { + return { ok: false, isAuthor: false, notFound: true }; + } + if (isTestAuthor(rows[0].created_by, userId)) { + return { ok: true, isAuthor: true, notFound: false }; + } + const { rows: ar } = await query( + `SELECT 1 + FROM test_assignments ta + INNER JOIN test_versions tv_a ON tv_a.id = ta.test_version_id + INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id + WHERE tv_a.test_id = $1 + AND tat.target_type = 'user' + AND tat.target_id = $2 + LIMIT 1`, + [testId, userId] + ); + return { ok: ar.length > 0, isAuthor: false, notFound: false }; +} + +/** + * Список тестов в каталоге: только `is_active` цепочка + (автор OR назначен). + */ +export async function queryTestsVisibleToUser(userId) { + return query( + `SELECT DISTINCT t.id, t.title, t.description, t.is_active AS chain_active, + t.created_at, t.updated_at, tv.id AS active_version_id, tv.version, + t.created_by, u.full_name AS author_full_name + FROM tests t + INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true + INNER JOIN users u ON u.id = t.created_by + WHERE t.is_active = true + AND ( + t.created_by = $1 + OR EXISTS ( + SELECT 1 + FROM test_assignments ta + INNER JOIN test_versions tv2 ON tv2.id = ta.test_version_id + INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id + WHERE tv2.test_id = t.id + AND tat.target_type = 'user' + AND tat.target_id = $1 + ) + ) + ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC`, + [userId] + ); +} diff --git a/frontend/src/components/CabinetLayout.jsx b/frontend/src/components/CabinetLayout.jsx new file mode 100644 index 0000000..9f5920c --- /dev/null +++ b/frontend/src/components/CabinetLayout.jsx @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Link, Outlet, useNavigate } from 'react-router-dom'; +import { api } from '../api'; +import { formatSurnameWithInitials } from '../utils/formatUserName'; + +export default function CabinetLayout() { + const nav = useNavigate(); + const [user, setUser] = useState(null); + /** Backend: `devUi` при NODE_ENV=development */ + const [devUi, setDevUi] = useState(false); + /** Каталог HR + блок назначения (env CLINIC_ASSIGNMENT_ENABLED в prod) */ + const [assignmentUi, setAssignmentUi] = useState(false); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + + const loadMe = useCallback(async () => { + setErr(null); + try { + const me = await api('/api/auth/me'); + setUser(me.user); + setDevUi(!!me.devUi); + setAssignmentUi(!!me.assignmentUi); + } catch (e) { + if (e.status === 401) { + nav('/login', { replace: true }); + return; + } + setErr(e.message); + } finally { + setLoading(false); + } + }, [nav]); + + useEffect(() => { + loadMe(); + }, [loadMe]); + + async function logout() { + try { + await api('/api/auth/logout', { method: 'POST' }); + } catch { + // ignore; still go to login + } + nav('/login'); + } + + if (loading) { + return ( +
Загрузка…
++ {err} +
+{err}
; + return{err}
; } - if (!data) { - returnЗагрузка…
; + if (!data && !taker) { + returnЗагрузка…
; } + if (taker) { + const { test: t, hasActiveVersion } = taker.summary; + const title = t?.title || 'Тест'; + return ( ++ ← к списку +
++ {formatTestAuthorLabel(user, t?.createdBy, t?.authorFullName)} +
++ Режим сотрудника: одна цепочка — одна активная версия (v{t?.version ?? '—'} + {t?.activeVersionId && ( + + · {String(t.activeVersionId).slice(0, 8)}… + + )} + ) +
+ {t?.description && ( ++ {t.description} +
+ )} ++ Порог для зачёта: {t?.passingThreshold ?? '—'}% +
+ {!hasActiveVersion && ( ++ Пройдите тест в{' '} + каталоге — в строке с названием слева откроется карточка, справа + кнопка «Пройти». +
+- ← к списку -
- {chain?.hasAnyAttempt && ( -+ ← к списку +
+ ++ {formatTestAuthorLabel(user, test?.createdBy, test?.authorFullName)} +
+
+ Заполняет все вопросы и варианты по текущей структуре (число карточек
+ вопросов и вариантов в каждой — задайте кнопками «+ вопрос» / «+ вариант»). Верные ответы
+ отмечает модель. Нужен ключ в backend:{' '}
+ DEEPSEEK_API_KEY или{' '}
+ OPENAI_API_KEY.
+
+ {test.description} +
+ )} ++ Обновлён: {fmtDt(test?.updatedAt || test?.createdAt)} + {test?.chainActive === false && ( + + · скрыт из списка + + )} +
-| ver | -id | -active | -- | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| {r.version} | -{r.id} | -{r.is_active ? 'да' : 'нет'} | -- {!r.is_active && ( - - )} - | + {test?.chainActive === false && ( +
| Версия | +Активна | +Создана | +
|---|
hasAttempts: да
+ + + {versions.map((r) => ( ++ По цепочке есть зафиксированные прогоны — разбор идёт по той версии, с + которой проходили. +
+ )} + + + {attemptsList != null && attemptsList.length > 0 && ( ++ Попытки по всем версиям цепочки. Подробный разбор вариантов — для завершённых + прогонов. +
+ {attemptsErr && ( ++ {attemptsErr} +
+ )} +| Когда | +Участник | +v | +Результат | ++ |
|---|---|---|---|---|
| + {a.completedAt + ? fmtDt(a.completedAt) + : a.startedAt + ? fmtDt(a.startedAt) + : '—'} + | ++ {a.attempterName || '—'} + {a.attempterLogin && ( + + {a.attempterLogin} + + )} + | +v{a.testVersion} | ++ {a.status === 'completed' && a.totalQuestions != null ? ( + <> + {a.correctCount}/{a.totalQuestions} + {a.passed ? ' · зачёт' : ' · незачёт'} + > + ) : ( + a.status + )} + | ++ {a.status === 'completed' && ( + + Разбор + + )} + | +
+ Деактивация убирает тест из общего списка; карточка открывается по прямой + ссылке. +
+
+ PDF, DOCX, TXT/MD, до 10 МБ. Текст извлекается на сервере; при{' '}
+ DEEPSEEK_API_KEY или{' '}
+ OPENAI_API_KEY в backend строится черновик теста
+ (см. generation.draft).
+
+ {importErr} +
+ )} + {importPreview && ( ++ {importPreview.generation?.message} +
+ {importPreview.generation?.textPreview && !importPreview.generation?.available && ( +
+ {importPreview.generation.textPreview}
+ {importPreview.textLength > 4000 ? '…' : ''}
+
+ )}
+ {importPreview.generation?.draft && (
+