Compare commits
2 Commits
72a5863871
...
5cd94c05ad
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cd94c05ad | |||
| f1f5223076 |
@@ -0,0 +1,10 @@
|
|||||||
|
# Иллюстрации к разделу «Как пользоваться»
|
||||||
|
|
||||||
|
Сейчас здесь **SVG-схемы** — их видно в Git и в просмотрщике Markdown.
|
||||||
|
Чтобы подставить **скриншоты** с экрана мини-приложения:
|
||||||
|
|
||||||
|
1. Сделайте 2–3 скриншота (список, низ с «Сохранить», блок «Показ / назначения»).
|
||||||
|
2. Сохраните в эту папку, например: `spisok.png`, `chernovik.png`, `pokaz.png`.
|
||||||
|
3. В файле `docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md` замените в картинках `placeholder-*.svg` на ваши имена `*.png` (или оставьте SVG).
|
||||||
|
|
||||||
|
Имена в Markdown сейчас: `placeholder-spisok.svg`, `placeholder-chernovik.svg`, `placeholder-pokaz.svg`.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="200" viewBox="0 0 360 200" role="img" aria-label="!E5<0: A>E@0=5=85">
|
||||||
|
<rect width="100%" height="100%" fill="#f8faf9" stroke="#cfe8e3"/>
|
||||||
|
<text x="180" y="36" text-anchor="middle" font-family="system-ui,sans-serif" font-size="14" font-weight="700" fill="#0d5c54">=87C «!>E@0=8BL G5@=>28:»</text>
|
||||||
|
<text x="180" y="58" text-anchor="middle" font-family="system-ui,sans-serif" font-size="11" fill="#5c6b69">A>E@0=O5B 2AQ, GB> =0?8A0;8 2 2>?@>A0E</text>
|
||||||
|
<rect x="32" y="100" width="296" height="40" rx="8" fill="#0d5c54"/>
|
||||||
|
<text x="180" y="125" text-anchor="middle" font-family="system-ui,sans-serif" font-size="12" font-weight="600" fill="#fff">!>E@0=8BL G5@=>28:</text>
|
||||||
|
<rect x="32" y="150" width="296" height="32" rx="8" fill="#fff" stroke="#0d5c54"/>
|
||||||
|
<text x="180" y="170" text-anchor="middle" font-family="system-ui,sans-serif" font-size="11" fill="#0d5c54"> A?8A:C</text>
|
||||||
|
</svg>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="180" viewBox="0 0 360 180" role="img" aria-label="!E5<0: 2848<>ABL 8 =07=0G5=8O">
|
||||||
|
<rect width="100%" height="100%" fill="#f8faf9" stroke="#cfe8e3"/>
|
||||||
|
<text x="180" y="32" text-anchor="middle" font-family="system-ui,sans-serif" font-size="14" font-weight="700" fill="#0d5c54">>:07 2 :0B0;>35</text>
|
||||||
|
<text x="32" y="64" font-family="system-ui,sans-serif" font-size="11" fill="#1a1a1a">· 848<>ABL 2 >1I5< A?8A:5 8;8 A:@KB</text>
|
||||||
|
<text x="32" y="88" font-family="system-ui,sans-serif" font-size="11" fill="#1a1a1a">· ><C 2K40BL A>B@C4=8:8 (5A;8 2:;NG5=>)</text>
|
||||||
|
<text x="32" y="118" font-family="system-ui,sans-serif" font-size="10" fill="#5c6b69">07=0G5=85 = 2K40BL B5AB 2 @01>BC, =5 ?CB09B5 A «A>E@0=8BL»</text>
|
||||||
|
<text x="32" y="148" font-family="system-ui,sans-serif" font-size="10" fill="#5c6b69">«K1@0BL 2A5E» = B>;L:> 2 B5:CI5< >BD8;LB@>20==>< A?8A:5</text>
|
||||||
|
</svg>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="200" viewBox="0 0 360 200" role="img" aria-label="!E5<0: A?8A>: B5AB>2">
|
||||||
|
<rect width="100%" height="100%" fill="#f8faf9" stroke="#cfe8e3"/>
|
||||||
|
<text x="180" y="32" text-anchor="middle" font-family="system-ui,sans-serif" font-size="14" font-weight="700" fill="#0d5c54">!?8A>: B5AB>2</text>
|
||||||
|
<text x="180" y="54" text-anchor="middle" font-family="system-ui,sans-serif" font-size="11" fill="#5c6b69">:0@B>G:0 ’ =06<8B5 =0 =0720=85 8;8 «@>9B8»</text>
|
||||||
|
<rect x="24" y="72" width="312" height="44" rx="10" fill="#fff" stroke="#b8d4cf"/>
|
||||||
|
<rect x="24" y="124" width="312" height="44" rx="10" fill="#fff" stroke="#b8d4cf"/>
|
||||||
|
</svg>
|
||||||
@@ -31,6 +31,106 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Спринт 3 — дизайн-токены (по желанию)
|
## Спринт 3 — структура «карточки теста», копи, доводка списка и кабинета
|
||||||
|
|
||||||
- [ ] Единая шкала: `--control-height`, `--control-padding-x`, `--button-gap` — рефакторинг всех `inline-actions` и форм
|
**Цель:** человекочитаемые названия и группировка разделов; согласованные подсказки и тап-зоны; доводка списка тестов, истории, назначения и редактора по замечаниям.
|
||||||
|
|
||||||
|
### Страница теста (`TestDetail`)
|
||||||
|
|
||||||
|
- [x] **3.1** `AccSection`: подзаголовок под заголовком (`subtitle` + стили `cabinet-disclosure__summary-*`)
|
||||||
|
- [x] **3.2** У «тела» аккордеона — верхний внутренний отступ: `.cabinet-disclosure__body` (контент не «прилипает» к header)
|
||||||
|
- [x] **3.3** Переименования: «О тесте», «Вопросы» (вместо мета/содержания); подзаголовки-описания по смыслу
|
||||||
|
- [x] **3.4** **Импорт** встроен в «Вопросы»: подсекция «Документ в вопросы»; отдельного аккордеона «Импорт из файла» нет
|
||||||
|
- [x] **3.5** **История**: одна секция, подпункты «Версии» + «Прохождения»; пустое/ошибочное состояние у прогонов
|
||||||
|
- [x] **3.6** Ошибка загрузки прогонов: не в герое страницы, а внутри «Прохождения»
|
||||||
|
- [x] **3.7** **Показ в каталоге:** подсекция «Видимость» + (при `assignmentUi`) «Кому выдать»; кнопка видимости **не** на всю ширину (`.publication-visibility__actions`)
|
||||||
|
- [x] **3.8** Панель ИИ-генерации: `.test-detail-ai-panel` (фон/рамка в духе кабинета, без «лишней» карточки)
|
||||||
|
- [x] **3.9** Вопрос: блоки `.question-editor-block` + первый вопрос без лишнего верхнего бордера (`.question-editor-block--first`)
|
||||||
|
- [x] **3.10** Варианты: удаление — **иконка** «закрыть» + `aria-label` (вместо текста «убрать»)
|
||||||
|
- [x] **3.11** Одна панель `.question-editor__footer`: «+ вариант» и «Удалить вопрос»; «+ вопрос» вынесен в `.test-detail-add-question` над блоком импорта
|
||||||
|
- [x] **3.12** **Назначение:** кнопка **«Выбрать всех (N)»** по текущему отфильтрованному списку; подсказки в подсекциях
|
||||||
|
- [x] **3.13** Стили подсекций: `.test-detail-subsection`, `.test-detail-subsection__title`, `.test-detail-hint`
|
||||||
|
- [x] **3.14** Импорт: на мобилке **полная ширина** кнопки «Выбрать файл» (`.import-file-row--block`); ровнее отступы у превью
|
||||||
|
- [x] **3.15** Классы списка версий: на узком экране у `.version-card-list__main` `flex-grow: 0` в column — **без пустой «вытянутой» карточки v1**
|
||||||
|
|
||||||
|
### Список тестов и шапка
|
||||||
|
|
||||||
|
- [x] **3.16** **Список:** на `≤640px` карточка в **колонку** (заголовок на ширину экрана, **«Пройти»** снизу на **всю ширину**; без пустой полосы)
|
||||||
|
- [x] **3.17** `hover` у строк списка — только при `@media (hover: hover) and (pointer: fine)`; лёгкий **`:active`** на ссылке (тач-фидбек)
|
||||||
|
- [x] **3.18** **Шапка** `cabinet-header__inner`: учёт **safe-area** сверху и по бокам
|
||||||
|
- [x] **3.19** **Назначение (кабинет):** у `.assign-list` — выше область прокрутки на мобилке; у строк — линия раздела чуть заметнее; **центр чекбокса** + **line-clamp** у мета; `accent-color` у чекбокса
|
||||||
|
- [x] **3.20** Ритм **аккордеонов** на `test-detail-page` — чуть больше `margin` между `cabinet-brick`; **нижний отступ** у страницы под **фикс-футер** увеличен
|
||||||
|
- [x] **3.21** **Поле поиска** в назначении: более «строчный» вид (min/max `height` + `padding` у `.assign-toolbar__search`)
|
||||||
|
|
||||||
|
**Файлы:** `frontend/src/styles/cabinet-theme.css`, `frontend/src/pages/TestDetail.jsx`, `frontend/src/pages/TestsList.jsx` (только косвенно через стили, разметка списка ранее).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Спринт 4 — памятка для врачей/заведующих (без IT-сленга)
|
||||||
|
|
||||||
|
**Цель:** в этом же файле в конце — краткая инструкция, как пользоваться кабинетом тестов; иллюстрации к разделу; никакой отдельной «саги» про дизайн-токены.
|
||||||
|
|
||||||
|
- [x] **4.1** Блок «Как пользоваться» простым языком, по смыслу блоков интерфейса
|
||||||
|
- [x] **4.2** Папка `docs/images/cabinet-ui/`: схемы-заглушки (SVG); при необходимости **замените** их своими **скриншотами** с тем же именем файла или поправьте пути внизу документа
|
||||||
|
- [x] **4.3** Отдельный спринт про design tokens **убран** — при желании вынесите в issue/другой документ
|
||||||
|
|
||||||
|
**Файлы:** `docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md` (блок внизу), `docs/images/cabinet-ui/*`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как пользоваться: коротко и по делу
|
||||||
|
|
||||||
|
*Если картинки не открываются — проверьте, что рядом с этим файлом лежат файлы в `images/cabinet-ui/`. Схемы можно заменить на настоящие скриншоты из мини-приложения (PNG) — с теми же названиями.*
|
||||||
|
|
||||||
|
### 1. Список тестов
|
||||||
|
|
||||||
|
Тут — все доступные вам тесты. **Название** (или область слева) ведёт в **редактирование**, кнопка **«Пройти»** — **сдать** тест, если вы в роли сотрудника, а не автора. Не путайте: редактировать могут не все.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. О тесте
|
||||||
|
|
||||||
|
Название, **описание** для коллег, **порог зачёта** (процент). Это «паспорт» теста: заполните так, чтобы было понятно, **что проверяете** и с какой планкой «зачёт/незачёт».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Вопросы
|
||||||
|
|
||||||
|
Здесь вы **пишете сами** вопросы и **варианты ответов**. Кружок/квадратик слева от варианта — **отметьте верные**. Один верный = как контрольный; несколько = можно включить **«Несколько верных ответов»**.
|
||||||
|
|
||||||
|
- **+ вопрос** / **+ вариант** — добавить.
|
||||||
|
- **Крестик** у варианта — убрать лишний ответ.
|
||||||
|
- **Документ в вопросы** — подгрузить **файл** (PDF, Word, текст) и по подсказкам вставить в черновик; **не** обязателен, если вы всё ввели руками.
|
||||||
|
- **ИИ** (если есть) — **помощь**, а не готовый клинический протокол: всё равно **прочитайте и исправьте** перед публикацией.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Сохранить
|
||||||
|
|
||||||
|
Кнопка **«Сохранить черновик»** (часто закреплена **внизу экрана** на телефоне) — **обязательно**, если не хотите потерять правки. **«К списку»** — выйти без паники: если **уже нажимали «Сохранить»** до этого, черновик сохранён.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. История
|
||||||
|
|
||||||
|
- **Версии** — когда менялся тест. Актуальная помечена. Редко нужно **«Сделать активной»** другую версию — обычно по согласованию с IT/методистом.
|
||||||
|
- **Прохождения** — **кто уже сдавал**; «Разбор» — посмотреть **ответы** (если вам открыт доступ). Для **обычного заведения теста** смотрите **редко**; для **разбора ситуации** — полезно.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Показ в каталоге
|
||||||
|
|
||||||
|
- **Видимость** — **показать** тест **всех** в списке или **скрыть** (тест **не** удаляется, просто **не мешается** в ленте). Скрытие **не** отменяет уже выданные ссылки, если кто-то ими **поделился**.
|
||||||
|
- **Кому выдать** (если раздел есть) — **кому** из сотрудников **назначить** тест. Сначала **поиск/фильтр**, **галочки**, при необходимости **«Выбрать всех»** по **текущему** списку, затем **«Назначить выбранных»**. Это **про людей**, не про текст вопросов.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### В одном предложении
|
||||||
|
|
||||||
|
**Написали вопросы → нажали «Сохранить» → при необходимости показали в списке / кому-то выдали.** Всё остальное — по ситуации.
|
||||||
|
|||||||
+393
-323
@@ -34,13 +34,18 @@ function createEmptyQuestion() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{ title: string, defaultOpen?: boolean, id?: string, children: import('react').ReactNode }} p
|
* @param {{ title: string, subtitle?: string, defaultOpen?: boolean, id?: string, children: import('react').ReactNode }} p
|
||||||
*/
|
*/
|
||||||
function AccSection({ title, defaultOpen, id, children }) {
|
function AccSection({ title, subtitle, defaultOpen, id, children }) {
|
||||||
return (
|
return (
|
||||||
<div className="cabinet-brick">
|
<div className="cabinet-brick">
|
||||||
<details className="cabinet-disclosure" defaultOpen={defaultOpen} id={id || undefined}>
|
<details className="cabinet-disclosure" defaultOpen={defaultOpen} id={id || undefined}>
|
||||||
<summary className="cabinet-disclosure__summary font-headline">{title}</summary>
|
<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>
|
<div className="cabinet-disclosure__body">{children}</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
@@ -235,6 +240,16 @@ export default function TestDetail() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectAllVisible() {
|
||||||
|
setAssignSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const p of assignPeople) {
|
||||||
|
next.add(assignPersonKey(p));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function postAssign() {
|
async function postAssign() {
|
||||||
const selectedRows = assignPeople.filter((p) =>
|
const selectedRows = assignPeople.filter((p) =>
|
||||||
assignSelected.has(assignPersonKey(p))
|
assignSelected.has(assignPersonKey(p))
|
||||||
@@ -657,14 +672,13 @@ export default function TestDetail() {
|
|||||||
При сохранении будет создана новая версия теста.
|
При сохранении будет создана новая версия теста.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data && attemptsErr && (
|
|
||||||
<p className="error-text" style={{ marginTop: '0.75rem', marginBottom: 0 }} role="alert">
|
|
||||||
{attemptsErr}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AccSection title="Метаинформация" defaultOpen>
|
<AccSection
|
||||||
|
title="О тесте"
|
||||||
|
subtitle="Название, описание и порог зачёта"
|
||||||
|
defaultOpen
|
||||||
|
>
|
||||||
<div className="draft-block">
|
<div className="draft-block">
|
||||||
<label className="form-label" htmlFor="draft-title">
|
<label className="form-label" htmlFor="draft-title">
|
||||||
Название
|
Название
|
||||||
@@ -702,17 +716,13 @@ export default function TestDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</AccSection>
|
</AccSection>
|
||||||
|
|
||||||
<AccSection title="Содержание" defaultOpen>
|
<AccSection
|
||||||
|
title="Вопросы"
|
||||||
|
subtitle="Тексты, варианты и при необходимости загрузка из файла"
|
||||||
|
defaultOpen
|
||||||
|
>
|
||||||
<div className="draft-block">
|
<div className="draft-block">
|
||||||
<div
|
<div className="test-detail-ai-panel" aria-label="Параметры генерации теста с ИИ">
|
||||||
className="surface-card"
|
|
||||||
style={{
|
|
||||||
padding: '1rem 1.1rem',
|
|
||||||
marginBottom: '1.25rem',
|
|
||||||
borderLeft: '3px solid color-mix(in srgb, var(--primary) 45%, transparent)',
|
|
||||||
}}
|
|
||||||
aria-label="Параметры генерации теста с ИИ"
|
|
||||||
>
|
|
||||||
<p
|
<p
|
||||||
className="font-headline"
|
className="font-headline"
|
||||||
style={{ fontSize: '0.95rem', marginTop: 0, marginBottom: '0.65rem' }}
|
style={{ fontSize: '0.95rem', marginTop: 0, marginBottom: '0.65rem' }}
|
||||||
@@ -792,12 +802,7 @@ export default function TestDetail() {
|
|||||||
{draftQuestions.map((q, qi) => (
|
{draftQuestions.map((q, qi) => (
|
||||||
<div
|
<div
|
||||||
key={q.key}
|
key={q.key}
|
||||||
className="question-editor-block"
|
className={`question-editor-block${qi === 0 ? ' question-editor-block--first' : ''}`}
|
||||||
style={{
|
|
||||||
borderTop: '1px solid var(--border-subtle, #2a2a2a)',
|
|
||||||
marginTop: '1rem',
|
|
||||||
paddingTop: '1rem',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="question-editor-block__header">
|
<div className="question-editor-block__header">
|
||||||
<label
|
<label
|
||||||
@@ -848,11 +853,7 @@ export default function TestDetail() {
|
|||||||
</p>
|
</p>
|
||||||
<ul className="question-options-list" style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
<ul className="question-options-list" style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||||
{q.options.map((o, oi) => (
|
{q.options.map((o, oi) => (
|
||||||
<li
|
<li key={o.key} className="question-option-row">
|
||||||
key={o.key}
|
|
||||||
className="question-option-row"
|
|
||||||
style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginBottom: 8 }}
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
key={`${o.key}-${q.hasMultipleAnswers ? 'm' : 's'}`}
|
key={`${o.key}-${q.hasMultipleAnswers ? 'm' : 's'}`}
|
||||||
className="question-option-row__mark"
|
className="question-option-row__mark"
|
||||||
@@ -886,8 +887,7 @@ export default function TestDetail() {
|
|||||||
aria-label="Пометить как верный"
|
aria-label="Пометить как верный"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="form-input"
|
className="form-input question-option-row__text"
|
||||||
style={{ flex: '1 1 200px' }}
|
|
||||||
value={o.text}
|
value={o.text}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
@@ -908,17 +908,25 @@ export default function TestDetail() {
|
|||||||
{q.options.length > 1 && (
|
{q.options.length > 1 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn--sm"
|
className="question-option-remove"
|
||||||
onClick={() => removeOption(qi, oi)}
|
onClick={() => removeOption(qi, oi)}
|
||||||
|
title="Удалить вариант"
|
||||||
|
aria-label="Удалить вариант"
|
||||||
>
|
>
|
||||||
убрать
|
<span className="material-symbols-outlined" aria-hidden>
|
||||||
|
close
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="inline-actions" style={{ marginTop: 4 }}>
|
<div className="question-editor__footer">
|
||||||
<button type="button" className="btn btn-ghost btn--sm" onClick={() => addOption(qi)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn--sm"
|
||||||
|
onClick={() => addOption(qi)}
|
||||||
|
>
|
||||||
+ вариант
|
+ вариант
|
||||||
</button>
|
</button>
|
||||||
{draftQuestions.length > 1 && (
|
{draftQuestions.length > 1 && (
|
||||||
@@ -933,11 +941,92 @@ export default function TestDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="inline-actions" style={{ marginTop: '0.75rem' }}>
|
<div className="test-detail-add-question">
|
||||||
<button type="button" className="btn btn-ghost" onClick={addQuestion}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost question-editor__add-question"
|
||||||
|
onClick={addQuestion}
|
||||||
|
>
|
||||||
+ вопрос
|
+ вопрос
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</AccSection>
|
</AccSection>
|
||||||
|
|
||||||
@@ -959,295 +1048,276 @@ export default function TestDetail() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<AccSection title="История версий" defaultOpen={false}>
|
<AccSection
|
||||||
<ul className="version-card-list" aria-label="Список версий теста">
|
title="История"
|
||||||
{versions.map((r) => (
|
subtitle="Версии теста и кто проходил"
|
||||||
<li key={r.id} className="surface-card version-card-list__item">
|
defaultOpen={false}
|
||||||
<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>
|
|
||||||
</AccSection>
|
|
||||||
|
|
||||||
{attemptsList != null && attemptsList.length > 0 && (
|
|
||||||
<AccSection title="Прогоны и разбор" defaultOpen={false}>
|
|
||||||
{attemptsErr && (
|
|
||||||
<p className="error-text" role="alert">
|
|
||||||
{attemptsErr}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<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>
|
|
||||||
</AccSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AccSection title="Публикация (видимость в списке)" defaultOpen={false}>
|
|
||||||
<div
|
|
||||||
className="inline-actions inline-actions--block-mobile"
|
|
||||||
style={{ marginTop: '0.5rem' }}
|
|
||||||
>
|
>
|
||||||
{test?.chainActive !== false ? (
|
<div className="test-detail-subsection test-detail-subsection--tight">
|
||||||
<button
|
<h3 className="test-detail-subsection__title">Версии</h3>
|
||||||
type="button"
|
<ul className="version-card-list" aria-label="Список версий теста">
|
||||||
className="btn btn-ghost"
|
{versions.map((r) => (
|
||||||
disabled={deactivateBusy}
|
<li key={r.id} className="surface-card version-card-list__item">
|
||||||
onClick={() => setChainVisible(false)}
|
<div className="version-card-list__row">
|
||||||
>
|
<div className="version-card-list__main">
|
||||||
Скрыть из списка
|
<div className="version-card-list__title-line">
|
||||||
</button>
|
<span className="font-headline" style={{ fontSize: '1rem' }}>
|
||||||
) : (
|
v{r.version}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
disabled={deactivateBusy}
|
|
||||||
onClick={() => setChainVisible(true)}
|
|
||||||
>
|
|
||||||
Снова показать в списке
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AccSection>
|
|
||||||
|
|
||||||
<AccSection title="Импорт из файла" defaultOpen={false}>
|
|
||||||
<div className="import-file-row" style={{ marginBottom: '0.5rem' }}>
|
|
||||||
<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={{ marginBottom: '1rem' }}>
|
|
||||||
<p className="muted" style={{ marginTop: 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 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
onClick={applyGeneratedDraft}
|
|
||||||
>
|
|
||||||
Применить сгенерированный черновик
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{importPreview.extractedText && (
|
|
||||||
<div className="inline-actions" style={{ marginTop: 8 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
onClick={applyExtractedToQuestion}
|
|
||||||
>
|
|
||||||
Вставить в первый вопрос (до 2000 симв.)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AccSection>
|
|
||||||
|
|
||||||
{assignmentUi && data && (
|
|
||||||
<AccSection title="Назначение сотрудникам" defaultOpen={false}>
|
|
||||||
{assignErr && (
|
|
||||||
<p className="error-text" role="alert">
|
|
||||||
{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="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>
|
||||||
</span>
|
{r.is_active && (
|
||||||
</label>
|
<span className="code-inline" style={{ fontSize: '0.7rem' }}>
|
||||||
);
|
текущая
|
||||||
})}
|
</span>
|
||||||
</div>
|
)}
|
||||||
) : (
|
</div>
|
||||||
!assignLoadBusy && (
|
<p className="muted mono" style={{ margin: '0.4rem 0 0', fontSize: '0.8rem' }}>
|
||||||
<p className="text-muted" style={{ margin: '0.5rem 0' }}>
|
{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>
|
</p>
|
||||||
)
|
)}
|
||||||
)}
|
{attemptsList && attemptsList.length > 0 && (
|
||||||
<div className="inline-actions" style={{ marginTop: '0.75rem' }}>
|
<ul className="attempts-card-list" aria-label="Список прогонов">
|
||||||
<button
|
{attemptsList.map((a) => {
|
||||||
type="button"
|
const when = a.completedAt
|
||||||
className="btn btn-ghost"
|
? fmtDt(a.completedAt)
|
||||||
disabled={assignSelectedInList.length === 0}
|
: a.startedAt
|
||||||
onClick={postAssign}
|
? fmtDt(a.startedAt)
|
||||||
>
|
: '—';
|
||||||
Назначить выбранных
|
const result =
|
||||||
{assignSelectedInList.length > 0
|
a.status === 'completed' && a.totalQuestions != null ? (
|
||||||
? ` (${assignSelectedInList.length})`
|
<>
|
||||||
: ''}
|
{a.correctCount}/{a.totalQuestions}
|
||||||
</button>
|
{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>
|
</div>
|
||||||
{assignMsg && <p className="text-muted">{assignMsg}</p>}
|
)}
|
||||||
</AccSection>
|
</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
|
<div
|
||||||
className="test-detail-fixed-actions"
|
className="test-detail-fixed-actions"
|
||||||
|
|||||||
@@ -605,6 +605,29 @@ code,
|
|||||||
min-height: 2.75rem;
|
min-height: 2.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cabinet-disclosure__summary-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cabinet-disclosure__summary-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cabinet-disclosure__summary-sub {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cabinet-disclosure__summary::-webkit-details-marker {
|
.cabinet-disclosure__summary::-webkit-details-marker {
|
||||||
@@ -626,7 +649,7 @@ code,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cabinet-disclosure__body {
|
.cabinet-disclosure__body {
|
||||||
padding: 0 1rem 1.05rem;
|
padding: 0.7rem 1rem 1.05rem;
|
||||||
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent);
|
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,19 +680,160 @@ code,
|
|||||||
|
|
||||||
.assign-toolbar__search {
|
.assign-toolbar__search {
|
||||||
flex: 1 1 200px;
|
flex: 1 1 200px;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
max-height: 2.75rem;
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
line-height: 1.25;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Назначение: компактнее поля на мобилке; 16px — без авто-зума iOS в поле */
|
/* Назначение: селекты — как раньше; поиск — одна «строка», не «плитка» */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.assign-toolbar {
|
.assign-toolbar {
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assign-toolbar .form-input {
|
.assign-toolbar .form-input {
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assign-toolbar__search {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assign-toolbar .form-input:not(.assign-toolbar__search) {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Подсекции и подсказки (карточка теста) */
|
||||||
|
.test-detail-subsection {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding-top: 1.15rem;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-detail-subsection:first-of-type,
|
||||||
|
.test-detail-subsection--tight {
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-detail-subsection--import {
|
||||||
|
margin-top: 1.35rem;
|
||||||
|
padding-top: 1.2rem;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-detail-subsection__title {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--on-surface);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-detail-hint {
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Панель «ИИ» — в тон остальным disclosure, без лишнего «модальности» */
|
||||||
|
.test-detail-ai-panel {
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
margin-bottom: 1.15rem;
|
||||||
|
background: var(--surface-container-low);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопка публикации — не тянем на 100% ширины, если одна */
|
||||||
|
.publication-visibility__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-visibility__actions .btn {
|
||||||
|
width: auto;
|
||||||
|
min-width: min(100%, 11rem);
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Убрать вариант: иконка */
|
||||||
|
.question-option-remove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.25rem;
|
||||||
|
min-width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-option-remove:hover {
|
||||||
|
background: color-mix(in srgb, var(--error) 8%, var(--surface));
|
||||||
|
color: var(--error, #b91c1c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-option-remove .material-symbols-outlined {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Футер вопроса: + вариант, удалить вопрос */
|
||||||
|
.question-editor__footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem 0.65rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-editor__footer .btn--sm,
|
||||||
|
.question-editor__add-question {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Добавить вопрос — в конец блока «Вопросы» */
|
||||||
|
.test-detail-add-question {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-top: 1.1rem;
|
||||||
|
padding-top: 0.85rem;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 28%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Импорт: кнопка на всю ширину в колонку; ритм отступов */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.import-file-row--block {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-file-label {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.assign-list {
|
.assign-list {
|
||||||
@@ -986,6 +1150,31 @@ code,
|
|||||||
|
|
||||||
/* --- Спринт 2: редактор вопроса, прогоны, импорт, фикс. футер --- */
|
/* --- Спринт 2: редактор вопроса, прогоны, импорт, фикс. футер --- */
|
||||||
|
|
||||||
|
.question-editor-block {
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
|
||||||
|
margin-top: 1.05rem;
|
||||||
|
padding-top: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-editor-block--first {
|
||||||
|
border-top: none;
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-option-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-option-row__text {
|
||||||
|
flex: 1 1 10rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.question-editor-block__header {
|
.question-editor-block__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user