UI: фамилия с инициалами в шапке, подпись автора у тестов
- Хедер: отображать Фамилия И. О., полное ФИО в title. - Список тестов и карточка: «Автор: Вы» для своих, иначе «Автор: Фамилия И. О.». - API: в каталоге и summary/versions — created_by, author full_name (camelCase в JSON). Made-with: Cursor
This commit is contained in:
+423
-32
@@ -9,6 +9,28 @@ import pool, { query } from '../db/db.js';
|
|||||||
import { authenticate } from '../middleware/auth.js';
|
import { authenticate } from '../middleware/auth.js';
|
||||||
import { hasAnyAttemptForTest } from '../services/testChainService.js';
|
import { hasAnyAttemptForTest } from '../services/testChainService.js';
|
||||||
import { saveTestDraft, createTestWithVersion } from '../services/testDraftService.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 router = express.Router();
|
||||||
const upload = multer({
|
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(
|
router.post(
|
||||||
'/import/document',
|
'/import/document',
|
||||||
authenticate,
|
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) => {
|
asyncHandler(async (req, res) => {
|
||||||
if (!req.file) {
|
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;
|
const p = req.file.path;
|
||||||
let size = 0;
|
const { mimetype, originalname } = req.file;
|
||||||
|
let size;
|
||||||
|
let extractedText;
|
||||||
try {
|
try {
|
||||||
const st = await fs.stat(p);
|
const st = await fs.stat(p);
|
||||||
size = st.size;
|
size = st.size;
|
||||||
await fs.unlink(p);
|
extractedText = await extractTextFromFile(mimetype, p, originalname);
|
||||||
} catch {
|
} catch (e) {
|
||||||
try {
|
try {
|
||||||
await fs.unlink(p);
|
await fs.unlink(p);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// 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({
|
res.json({
|
||||||
received: true,
|
received: true,
|
||||||
originalName: req.file.originalname,
|
originalName: originalname,
|
||||||
|
mime: mimetype,
|
||||||
size,
|
size,
|
||||||
message:
|
extractedText,
|
||||||
'Файл принят; извлечение текста и генерация (D.2–D.3) — в следующем шаге',
|
textLength: extractedText.length,
|
||||||
|
generation,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -65,15 +111,19 @@ router.get(
|
|||||||
'/',
|
'/',
|
||||||
authenticate,
|
authenticate,
|
||||||
asyncHandler(async (req, res) => {
|
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,
|
`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
|
FROM tests t
|
||||||
INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true
|
INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true
|
||||||
WHERE t.is_active = true
|
INNER JOIN users u ON u.id = t.created_by
|
||||||
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC`
|
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) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { title, description } = req.body;
|
const { title, description } = req.body;
|
||||||
if (!title || typeof title !== 'string') {
|
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, {
|
const out = await createTestWithVersion(pool, req.user.id, {
|
||||||
title,
|
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(
|
router.get(
|
||||||
'/:id/versions',
|
'/:id/versions',
|
||||||
authenticate,
|
authenticate,
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const testId = req.params.id;
|
const testId = req.params.id;
|
||||||
const { rows: t } = await query(`SELECT id, created_by FROM tests WHERE id = $1`, [
|
const { rows: t } = await query(
|
||||||
testId,
|
`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) {
|
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) {
|
if (!isTestAuthor(t[0].created_by, req.user.id)) {
|
||||||
return res.status(403).json({ error: 'Forbidden' });
|
return res.status(403).json({ error: RU.forbidden });
|
||||||
}
|
}
|
||||||
|
const testRow = t[0];
|
||||||
const { rows } = await query(
|
const { rows } = await query(
|
||||||
`SELECT id, version, is_active, parent_id, created_at
|
`SELECT id, version, is_active, parent_id, created_at
|
||||||
FROM test_versions WHERE test_id = $1 ORDER BY version`,
|
FROM test_versions WHERE test_id = $1 ORDER BY version`,
|
||||||
[testId]
|
[testId]
|
||||||
);
|
);
|
||||||
const hasAttempts = await hasAnyAttemptForTest(pool, 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]
|
[testId]
|
||||||
);
|
);
|
||||||
if (!t.length) {
|
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) {
|
if (!isTestAuthor(t[0].created_by, req.user.id)) {
|
||||||
return res.status(403).json({ error: 'Forbidden' });
|
return res.status(403).json({ error: RU.forbidden });
|
||||||
}
|
}
|
||||||
const { rows: v } = await query(
|
const { rows: v } = await query(
|
||||||
`SELECT id FROM test_versions WHERE test_id = $1 AND id = $2`,
|
`SELECT id FROM test_versions WHERE test_id = $1 AND id = $2`,
|
||||||
[testId, versionId]
|
[testId, versionId]
|
||||||
);
|
);
|
||||||
if (!v.length) {
|
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();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
@@ -170,17 +365,17 @@ router.patch(
|
|||||||
const { isActive, chainActive } = req.body;
|
const { isActive, chainActive } = req.body;
|
||||||
const chain = chainActive ?? isActive;
|
const chain = chainActive ?? isActive;
|
||||||
if (typeof chain !== 'boolean') {
|
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(
|
const { rows: t } = await query(
|
||||||
`SELECT id, created_by FROM tests WHERE id = $1`,
|
`SELECT id, created_by FROM tests WHERE id = $1`,
|
||||||
[testId]
|
[testId]
|
||||||
);
|
);
|
||||||
if (!t.length) {
|
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) {
|
if (!isTestAuthor(t[0].created_by, req.user.id)) {
|
||||||
return res.status(403).json({ error: 'Forbidden' });
|
return res.status(403).json({ error: RU.forbidden });
|
||||||
}
|
}
|
||||||
await query(
|
await query(
|
||||||
`UPDATE tests SET is_active = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
|
`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(
|
router.post(
|
||||||
'/:id/draft',
|
'/:id/draft',
|
||||||
authenticate,
|
authenticate,
|
||||||
@@ -204,6 +504,10 @@ router.post(
|
|||||||
authenticate,
|
authenticate,
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const testId = req.params.id;
|
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(
|
const { rows: tv } = await query(
|
||||||
`SELECT tv.id AS test_version_id
|
`SELECT tv.id AS test_version_id
|
||||||
FROM test_versions tv
|
FROM test_versions tv
|
||||||
@@ -211,7 +515,7 @@ router.post(
|
|||||||
[testId]
|
[testId]
|
||||||
);
|
);
|
||||||
if (!tv.length) {
|
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 testVersionId = tv[0].test_version_id;
|
||||||
const { rows: mx } = await query(
|
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(
|
router.get(
|
||||||
'/:id/chain-info',
|
'/:id/chain-info',
|
||||||
authenticate,
|
authenticate,
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const testId = req.params.id;
|
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);
|
const has = await hasAnyAttemptForTest(pool, testId);
|
||||||
res.json({ testId, hasAnyAttempt: has });
|
res.json({ testId, hasAnyAttempt: has });
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="cabinet-app cabinet-page--center">
|
||||||
|
<p className="text-muted">Загрузка…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
return (
|
||||||
|
<div className="cabinet-app cabinet-page--center">
|
||||||
|
<p className="callout callout--error" role="alert">
|
||||||
|
{err}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cabinet-app">
|
||||||
|
<header className="cabinet-header">
|
||||||
|
<div className="cabinet-header__inner">
|
||||||
|
<Link to="/tests" className="cabinet-brand">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined cabinet-brand__icon"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
school
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="cabinet-brand__title">Система тестрования</div>
|
||||||
|
<div className="cabinet-brand__subtitle">Портал</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<div className="cabinet-header__actions">
|
||||||
|
{user && (
|
||||||
|
<span
|
||||||
|
className="cabinet-user"
|
||||||
|
title={user.fullName ? `${user.fullName} · ${user.role}` : user.role}
|
||||||
|
>
|
||||||
|
{formatSurnameWithInitials(user.fullName) || user.login || '—'}
|
||||||
|
<span className="cabinet-user__role"> · {user.role}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={logout}>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="cabinet-main">
|
||||||
|
<Outlet context={{ user, devUi, assignmentUi, refreshUser: loadMe }} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1130
-94
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,27 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate, useOutletContext } from 'react-router-dom';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
|
import { formatTestAuthorLabel } from '../utils/formatUserName';
|
||||||
|
|
||||||
export default function TestsList() {
|
export default function TestsList() {
|
||||||
|
const { user, devUi } = useOutletContext() || {};
|
||||||
|
const isEmployee = user?.role === 'employee';
|
||||||
|
const canCreate = devUi || !isEmployee;
|
||||||
const [tests, setTests] = useState([]);
|
const [tests, setTests] = useState([]);
|
||||||
const [user, setUser] = useState(null);
|
const [hiddenByYou, setHiddenByYou] = useState([]);
|
||||||
const [err, setErr] = useState(null);
|
const [err, setErr] = useState(null);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
|
const [passBusy, setPassBusy] = useState(null);
|
||||||
|
const [passErr, setPassErr] = useState(null);
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
setErr(null);
|
||||||
|
setPassErr(null);
|
||||||
try {
|
try {
|
||||||
const me = await api('/api/auth/me');
|
|
||||||
setUser(me.user);
|
|
||||||
const t = await api('/api/tests');
|
const t = await api('/api/tests');
|
||||||
setTests(t.tests || []);
|
setTests(t.tests || []);
|
||||||
|
setHiddenByYou(t.hiddenByYou || []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.status === 401) {
|
if (e.status === 401) {
|
||||||
nav('/login');
|
nav('/login');
|
||||||
@@ -28,6 +35,22 @@ export default function TestsList() {
|
|||||||
load();
|
load();
|
||||||
}, [nav]);
|
}, [nav]);
|
||||||
|
|
||||||
|
async function startPass(testId) {
|
||||||
|
setPassErr(null);
|
||||||
|
setPassBusy(testId);
|
||||||
|
try {
|
||||||
|
const o = await api(`/api/tests/${testId}/attempts/start`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
nav(`/tests/${testId}/attempt/${o.attempt.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
setPassErr(e.message);
|
||||||
|
} finally {
|
||||||
|
setPassBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createTest(e) {
|
async function createTest(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
@@ -45,56 +68,126 @@ export default function TestsList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
await api('/api/auth/logout', { method: 'POST' });
|
|
||||||
nav('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 800, margin: '24px auto', fontFamily: 'system-ui' }}>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
<h1 className="font-headline" style={{ fontSize: '1.5rem' }}>
|
||||||
<h1>Тесты</h1>
|
Тесты
|
||||||
<div>
|
</h1>
|
||||||
{user && (
|
{err && (
|
||||||
<span style={{ marginRight: 12 }}>
|
<p className="error-text" role="alert">
|
||||||
{user.fullName} ({user.role})
|
{err}
|
||||||
</span>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button type="button" onClick={logout}>
|
{passErr && (
|
||||||
Выйти
|
<p className="error-text" role="alert">
|
||||||
|
{passErr}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canCreate && (
|
||||||
|
<form onSubmit={createTest} className="create-row" aria-label="Создать тест">
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Новый тест — название"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-ghost">
|
||||||
|
Создать
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
)}
|
||||||
{err && <p style={{ color: 'coral' }}>{err}</p>}
|
|
||||||
|
|
||||||
<form onSubmit={createTest} style={{ margin: '20px 0' }}>
|
<ul className="list-stack" aria-label="Тесты в общем списке">
|
||||||
<input
|
|
||||||
placeholder="Новый тест — название"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
style={{ padding: 8, width: 320 }}
|
|
||||||
/>
|
|
||||||
<button type="submit" style={{ marginLeft: 8 }}>
|
|
||||||
Создать
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
|
||||||
{tests.map((t) => (
|
{tests.map((t) => (
|
||||||
<li
|
<li key={t.id} className="list-row list-row--split">
|
||||||
key={t.id}
|
<div className="list-row__main">
|
||||||
style={{ border: '1px solid #ddd', marginBottom: 8, padding: 12 }}
|
<Link to={`/tests/${t.id}`} className="list-row__link">
|
||||||
>
|
<span className="list-row__title">{t.title}</span>
|
||||||
<Link to={`/tests/${t.id}`}>
|
<span className="list-row__meta">
|
||||||
{t.title}
|
{formatTestAuthorLabel(user, t.created_by, t.author_full_name)}
|
||||||
</Link>
|
<span className="list-row__meta-sep" aria-hidden>
|
||||||
<span style={{ color: '#888', fontSize: 13, marginLeft: 8 }}>
|
{' '}
|
||||||
v{t.version} · активная версия {t.active_version_id?.slice(0, 8)}…
|
·{' '}
|
||||||
</span>
|
</span>
|
||||||
|
v{t.version} · активная {t.active_version_id?.slice(0, 8) ?? '—'}…
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="list-row__side">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
disabled={passBusy != null}
|
||||||
|
onClick={() => startPass(t.id)}
|
||||||
|
>
|
||||||
|
{passBusy === t.id ? '…' : 'Пройти'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
{tests.length === 0 && <p>Нет тестов — создайте первый.</p>}
|
{tests.length === 0 && hiddenByYou.length === 0 && (
|
||||||
|
<p className="text-muted">
|
||||||
|
{canCreate
|
||||||
|
? 'Пусто. В списке — только тесты, которые вы ведёте, и назначенные вам. Создайте цепочку или дождитесь назначения.'
|
||||||
|
: 'Пусто: вам пока ничего не назначено и нет цепочек, где вы автор.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{tests.length === 0 && hiddenByYou.length > 0 && (
|
||||||
|
<p className="text-muted" style={{ marginBottom: '0.75rem' }}>
|
||||||
|
В общем списке пусто — у вас есть скрытые тесты ниже; откройте карточку,
|
||||||
|
чтобы снова включить отображение.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hiddenByYou.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2
|
||||||
|
className="font-headline"
|
||||||
|
style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.5rem' }}
|
||||||
|
>
|
||||||
|
Скрытые вами из списка
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.75rem' }}>
|
||||||
|
Эти цепочки не видны в блоке выше. Откройте карточку и внизу раздела
|
||||||
|
«Публикация» нажмите «Снова показать в списке».
|
||||||
|
</p>
|
||||||
|
<ul className="list-stack" aria-label="Скрытые тесты автора">
|
||||||
|
{hiddenByYou.map((t) => (
|
||||||
|
<li
|
||||||
|
key={t.id}
|
||||||
|
className="list-row list-row--split"
|
||||||
|
style={{ borderStyle: 'dashed', opacity: 0.95 }}
|
||||||
|
>
|
||||||
|
<div className="list-row__main">
|
||||||
|
<Link to={`/tests/${t.id}`} className="list-row__link">
|
||||||
|
<span className="list-row__title">{t.title}</span>
|
||||||
|
<span className="list-row__meta">
|
||||||
|
{formatTestAuthorLabel(user, t.created_by, t.author_full_name)}
|
||||||
|
<span className="list-row__meta-sep" aria-hidden>
|
||||||
|
{' '}
|
||||||
|
·{' '}
|
||||||
|
</span>
|
||||||
|
v{t.version} · скрыт · {t.active_version_id?.slice(0, 8) ?? '—'}…
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="list-row__side">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
disabled={passBusy != null}
|
||||||
|
onClick={() => startPass(t.id)}
|
||||||
|
>
|
||||||
|
{passBusy === t.id ? '…' : 'Пройти'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* «Иванов Иван Иванович» → «Иванов И. И.»; «Иванов Иван» → «Иванов И.».
|
||||||
|
* @param {string | null | undefined} fullName
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatSurnameWithInitials(fullName) {
|
||||||
|
if (fullName == null || typeof fullName !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return parts[0];
|
||||||
|
}
|
||||||
|
const [surname, first, ...rest] = parts;
|
||||||
|
const i1 = first.charAt(0).toLocaleUpperCase('ru-RU');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
return `${surname} ${i1}.`;
|
||||||
|
}
|
||||||
|
const pat = rest.length ? rest[0] : '';
|
||||||
|
const i2 = pat ? pat.charAt(0).toLocaleUpperCase('ru-RU') : '';
|
||||||
|
return i2 ? `${surname} ${i1}. ${i2}.` : `${surname} ${i1}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подпись автора в списке и карточке.
|
||||||
|
* @param {{ id?: string } | null | undefined} user
|
||||||
|
* @param {string | null | undefined} createdBy
|
||||||
|
* @param {string | null | undefined} authorFullName
|
||||||
|
*/
|
||||||
|
export function formatTestAuthorLabel(user, createdBy, authorFullName) {
|
||||||
|
if (user?.id != null && createdBy != null && String(user.id) === String(createdBy)) {
|
||||||
|
return 'Автор: Вы';
|
||||||
|
}
|
||||||
|
const short = formatSurnameWithInitials(authorFullName);
|
||||||
|
return short ? `Автор: ${short}` : 'Автор: —';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user