Browse Source
- Хедер: отображать Фамилия И. О., полное ФИО в title. - Список тестов и карточка: «Автор: Вы» для своих, иначе «Автор: Фамилия И. О.». - API: в каталоге и summary/versions — created_by, author full_name (camelCase в JSON). Made-with: Cursordev
6 changed files with 1894 additions and 169 deletions
@ -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> |
||||
); |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -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}` : 'Автор: —'; |
||||
} |
||||
Loading…
Reference in new issue