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