Compare commits
10 Commits
fad0842640
...
cb810972c4
| Author | SHA1 | Date | |
|---|---|---|---|
| cb810972c4 | |||
| 7ca71c6b6c | |||
| 3128190b5c | |||
| d2852df976 | |||
| 8ed1a02967 | |||
| c69a11d167 | |||
| 45c974d0c6 | |||
| 650bce809a | |||
| bb7093de20 | |||
| 355a1300ce |
@@ -89,13 +89,15 @@
|
|||||||
- [ ] Модалка: «Название *» (обязательно, Enter — сохранить), «Родительский объект» (select только при правке Раздела: узлы уровня 0, сортировка ru, без себя и потомков), «Проект» (read-only)
|
- [ ] Модалка: «Название *» (обязательно, Enter — сохранить), «Родительский объект» (select только при правке Раздела: узлы уровня 0, сортировка ru, без себя и потомков), «Проект» (read-only)
|
||||||
- [ ] Заголовки модалки: «Добавить Объект-пятно / Раздел / Вид работ», «Редактировать …», «— (корневой)»
|
- [ ] Заголовки модалки: «Добавить Объект-пятно / Раздел / Вид работ», «Редактировать …», «— (корневой)»
|
||||||
- [ ] Удаление: подтверждение с числом дочерних, «Это действие нельзя отменить.»
|
- [ ] Удаление: подтверждение с числом дочерних, «Это действие нельзя отменить.»
|
||||||
- [ ] Drag & drop (только `canEdit`): до / внутрь / после, запрет на себя и потомков, ошибка «Не удалось переместить: …»
|
- [-] ~~Drag & drop~~ — **убрано по решению 2026-10-16** (неудобно, зона «внутрь» не работала, лишние случайные переносы). Родителя Раздела меняют через «Редактировать».
|
||||||
- [ ] «Работы» → раздел Работы с `objectId`
|
- [ ] «Работы» → раздел Работы с `objectId`
|
||||||
- [ ] После любой операции дерево перезагружается
|
- [ ] После любой операции дерево перезагружается
|
||||||
- [ ] Список «Объекты»: `Title`, `project` (текст = название проекта), `parent` (self-lookup)
|
- [ ] Список «Объекты»: `Title`, `project` (текст = название проекта), `parent` (self-lookup)
|
||||||
- [ ] Права: «Доступы» `projectId`/`userId` → `edit0`
|
- [ ] Права: «Доступы» `projectId`/`userId` → `edit0`
|
||||||
|
|
||||||
🐞 Перетаскивание меняет только родителя, порядок не сохраняется. 🐞 Удаление узла не удаляет детей. 🐞 Зона сброса «внутрь» нулевой высоты — на практике узел можно бросить только «до» / «после» (перенесено как есть).
|
🐞 Удаление узла не удаляет детей.
|
||||||
|
|
||||||
|
ℹ️ Экспорт падает с 500 на стороне `api.tnsu.kz`: «Failed to acquire Graph token … SPO_TENANT_ID, SPO_CLIENT_ID, SPO_CERT_PATH, SPO_CERT_PASS» — у API не настроен доступ к Graph (проверка 2026-10-16). Наш запрос доходит до API корректно; чинится на сервере.
|
||||||
|
|
||||||
**Сознательные отличия от objects:**
|
**Сознательные отличия от objects:**
|
||||||
- `alert()` → сообщение `Alert` на странице (ошибки переноса, удаления, экспорта) или в модалке (ошибка сохранения). Тексты те же.
|
- `alert()` → сообщение `Alert` на странице (ошибки переноса, удаления, экспорта) или в модалке (ошибка сохранения). Тексты те же.
|
||||||
@@ -119,7 +121,7 @@
|
|||||||
- [ ] Навигационное свойство родителя определяется через `EntityDefinitions` (fallback `cree9_parentid`)
|
- [ ] Навигационное свойство родителя определяется через `EntityDefinitions` (fallback `cree9_parentid`)
|
||||||
- [ ] Проект находится подъёмом по родителям объекта до непустого `project`
|
- [ ] Проект находится подъёмом по родителям объекта до непустого `project`
|
||||||
|
|
||||||
🐞 Путь объекта (`#headerTitle`) не выводится. 🐞 Нет проверки «начало ≤ окончание». 🐞 Отступы в списке «Родительская работа» — обычные пробелы, браузер их схлопывает (как было).
|
🐞 Путь объекта (`#headerTitle`) не выводится. 🐞 Отступы в списке «Родительская работа» — обычные пробелы, браузер их схлопывает (как было).
|
||||||
|
|
||||||
**Сознательные отличия от works:**
|
**Сознательные отличия от works:**
|
||||||
- `alert()` → сообщение `Alert`: ошибка сохранения — в модалке, ошибки удаления — над таблицей. Тексты те же.
|
- `alert()` → сообщение `Alert`: ошибка сохранения — в модалке, ошибки удаления — над таблицей. Тексты те же.
|
||||||
@@ -127,6 +129,8 @@
|
|||||||
- PATCH и DELETE идут через тот же AAD-клиент Dataverse, а не через `fetch` с токеном вручную; заголовки те же.
|
- PATCH и DELETE идут через тот же AAD-клиент Dataverse, а не через `fetch` с токеном вручную; заголовки те же.
|
||||||
- Адрес сайта — настройка «URL сайта SharePoint» (раньше `pageContext.web.absoluteUrl`), Dataverse — настройка «URL Dataverse»; имя таблицы — константа (раньше настройка `worksTable`).
|
- Адрес сайта — настройка «URL сайта SharePoint» (раньше `pageContext.web.absoluteUrl`), Dataverse — настройка «URL Dataverse»; имя таблицы — константа (раньше настройка `worksTable`).
|
||||||
- Таблица — `DataTable` из ui-kit; меню ⋮ — общее меню из `pages/_tree`.
|
- Таблица — `DataTable` из ui-kit; меню ⋮ — общее меню из `pages/_tree`.
|
||||||
|
- 🔧 **Исправлено при проверке T-WRK (2026-10-19):** дату окончания нельзя поставить раньше даты начала — календарь не даёт, а при сохранении ошибка «Дата окончания не может быть раньше даты начала» (в старом коде проверки не было).
|
||||||
|
- ✨ **Улучшено по просьбе (2026-10-19):** «Ед. изм.» — список с поиском: можно начать печатать, Enter выбирает первое найденное. Выбрать можно только значение из справочника, как и раньше.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -241,6 +245,7 @@
|
|||||||
- Текст после префикса ошибки («Ошибка загрузки данных: …» и т.п.) — сообщение `HttpError` (`{status} {statusText}: {ответ сервера}`), а не формат ошибки PnP.
|
- Текст после префикса ошибки («Ошибка загрузки данных: …» и т.п.) — сообщение `HttpError` (`{status} {statusText}: {ответ сервера}`), а не формат ошибки PnP.
|
||||||
- Счётчик во вкладке — часть подписи («Профессии (0)»): `count` в Tabs ui-kit скрывает ноль.
|
- Счётчик во вкладке — часть подписи («Профессии (0)»): `count` в Tabs ui-kit скрывает ноль.
|
||||||
- Кнопки «+» / «−» — `Button` ui-kit (с подсказками «Добавить» / «Удалить»), сообщение об ошибке закрывается крестиком рядом с `Alert`.
|
- Кнопки «+» / «−» — `Button` ui-kit (с подсказками «Добавить» / «Удалить»), сообщение об ошибке закрывается крестиком рядом с `Alert`.
|
||||||
|
- 🔧 **Исправлено (найдено при проверке T-RES, 2026-10-16):** перед добавлением проверяется, нет ли уже такой привязки на сервере. Раньше при двух открытых вкладках (или двух пользователях) одна профессия / компания добавлялась дважды. Если привязка уже есть — новая запись не создаётся, показывается существующая.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -265,5 +270,6 @@
|
|||||||
|
|
||||||
## Открытые вопросы
|
## Открытые вопросы
|
||||||
|
|
||||||
- Схема списка **«Доступы»**: в репозиториях встречаются и поля `projectId`/`userId`, и lookup `user`/`project`. Проверить на сайте.
|
- ✅ Схема списка **«Доступы»** проверена на сайте (2026-10-16): `project` — Lookup на «Проекты», `user` — Person, права `read` / `edit0` / `aproove`. SharePoint отдаёт к ним `projectId` / `userId` — поэтому оба способа из старых репозиториев (`project/Id` + `$expand` и `projectId` / `userId`) обращаются к одним и тем же полям. Расхождения нет.
|
||||||
- Схема списка **«Объекты»**: родитель читается то как `parentId`, то как `parent/Id`. Проверить на сайте.
|
- ✅ Схема списка **«Объекты»** проверена на сайте (2026-10-16): `parent` — Lookup («Объект»), SharePoint отдаёт к нему `parentId` — чтение через `parent/Id` и запись через `parentId` обращаются к одному полю. `project` — Text (название проекта), как и ожидает код.
|
||||||
|
- ✅ В «Объектах» есть поля `level` (Number) и `skipLevelUpdate` (Boolean), которые не писал ни один старый репозиторий. Проверено 2026-10-16: `level` пустой и у старых настоящих объектов — поле никто не заполняет и ни на что не влияет. Новое приложение его тоже не трогает.
|
||||||
|
|||||||
+7
-5
@@ -6,6 +6,7 @@
|
|||||||
- Всё, что **записывает** данные (создать / изменить / удалить / импорт / утвердить), — **только на проекте «ТЕСТ StroyBot»**. Такие сценарии помечены ✍️.
|
- Всё, что **записывает** данные (создать / изменить / удалить / импорт / утвердить), — **только на проекте «ТЕСТ StroyBot»**. Такие сценарии помечены ✍️.
|
||||||
- Всё, что только **читает**, проверяем на настоящих проектах — **сравнением со старой страницей** (см. «Главный приём»).
|
- Всё, что только **читает**, проверяем на настоящих проектах — **сравнением со старой страницей** (см. «Главный приём»).
|
||||||
- Нашёл расхождение — не исправляй ничего сам, запиши по шаблону «Как сообщить о баге» и иди дальше.
|
- Нашёл расхождение — не исправляй ничего сам, запиши по шаблону «Как сообщить о баге» и иди дальше.
|
||||||
|
- **Оформление — в стиле ui-kit** (решение 2026-10-16). Отличия во внешнем виде от старых страниц (раскладка, цвета, регистр подписей) **багом не считаются**. Сравниваем функции, тексты, данные, права. Баг по виду — только если что-то не видно, обрезано, наезжает или не нажимается.
|
||||||
- Отметки: `[x]` — совпало, `[!]` — баг (номер бага рядом), `[-]` — не применимо.
|
- Отметки: `[x]` — совпало, `[!]` — баг (номер бага рядом), `[-]` — не применимо.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -60,8 +61,8 @@
|
|||||||
- [ ] **T-APP-03** Нажать «назад» в браузере → вернулись на главную. «Вперёд» → снова объекты.
|
- [ ] **T-APP-03** Нажать «назад» в браузере → вернулись на главную. «Вперёд» → снова объекты.
|
||||||
- [ ] **T-APP-04** Скопировать адрес раздела, открыть в новой вкладке → открылся тот же раздел того же проекта.
|
- [ ] **T-APP-04** Скопировать адрес раздела, открыть в новой вкладке → открылся тот же раздел того же проекта.
|
||||||
- [ ] **T-APP-05** F5 на любом разделе → остались в нём.
|
- [ ] **T-APP-05** F5 на любом разделе → остались в нём.
|
||||||
- [ ] **T-APP-06** Закрыть вкладку, открыть web part заново (без `projectId` в адресе) → проект восстановился из сессии.
|
- [ ] **T-APP-06** В **той же вкладке** удалить из адреса `&page=…&projectId=…`, Enter, заново добавить web part → на главной уже выбран прежний проект, меню — со всеми разделами. (Выбор хранится в `sessionStorage` — он живёт только в пределах вкладки; после закрытия вкладки не сохраняется — так было и в старом main.)
|
||||||
- [ ] **T-APP-07** Шрифты: заголовки — с засечками (Lora), текст — Inter. Сравнить «на глаз» со старым main.
|
- [ ] **T-APP-07** Шрифты подгрузились: заголовки разделов — с засечками (Lora), остальной текст — Inter.
|
||||||
- [ ] **T-APP-08** Узкое окно (< 768px) → меню прячется, появляется кнопка-бургер, меню открывается поверх.
|
- [ ] **T-APP-08** Узкое окно (< 768px) → меню прячется, появляется кнопка-бургер, меню открывается поверх.
|
||||||
|
|
||||||
## 2. Главная — `T-HUB` (сравнение со старым stroy-bot-main)
|
## 2. Главная — `T-HUB` (сравнение со старым stroy-bot-main)
|
||||||
@@ -72,8 +73,9 @@
|
|||||||
- [ ] **T-HUB-05** Без выбранного проекта все карточки неактивны, видна подсказка.
|
- [ ] **T-HUB-05** Без выбранного проекта все карточки неактивны, видна подсказка.
|
||||||
- [ ] **T-HUB-06** «ТЕСТ StroyBot», роль Е, статус **текущий** → все 6 карточек активны; меню — все разделы.
|
- [ ] **T-HUB-06** «ТЕСТ StroyBot», роль Е, статус **текущий** → все 6 карточек активны; меню — все разделы.
|
||||||
- [ ] **T-HUB-07** Роль **Р** → неактивна только «Отправить отчёт»; в меню нет «Отправить отчёт» и «Привязка к справочнику».
|
- [ ] **T-HUB-07** Роль **Р** → неактивна только «Отправить отчёт»; в меню нет «Отправить отчёт» и «Привязка к справочнику».
|
||||||
- [ ] **T-HUB-08** Статус **завершен** (роль Е) → неактивна только «Отправить отчёт».
|
- [-] **T-HUB-08** Статус «завершен» — **не применимо**: в SharePoint у поля «Статус» только «Текущий» и «Архивный» (проверено 2026-10-16). Правило в коде остаётся на случай, если вариант появится.
|
||||||
- [ ] **T-HUB-09** Статус **архивный** → активен только «Архив», выгрузка неактивна, в меню только «Проект». Вернуть статус **текущий**.
|
- [ ] **T-HUB-09** Статус **«Архивный»** (менять в SharePoint, список «Проекты») → бейдж золотой, активен только «Архив», выгрузка неактивна, в меню только «Проект». Вернуть **«Текущий»**.
|
||||||
|
- ⚠️ Статус меняем **в списке SharePoint**, а не на портале проектов: портал пишет статус («Текущий» / «Архив») в справочник API, StroyBot его не читает.
|
||||||
- [ ] **T-HUB-10** «Архив проекта» → в новой вкладке папка `Документы/Архив/{проект}` (та же, что открывает старый main).
|
- [ ] **T-HUB-10** «Архив проекта» → в новой вкладке папка `Документы/Архив/{проект}` (та же, что открывает старый main).
|
||||||
- [ ] **T-HUB-11** Ctrl+клик по карточке «Объекты» → раздел открылся в новой вкладке.
|
- [ ] **T-HUB-11** Ctrl+клик по карточке «Объекты» → раздел открылся в новой вкладке.
|
||||||
- [ ] **T-HUB-12** Выгрузка: «?» показывает 3 строки и закрывается кликом мимо. Даты 13.10–19.10 → «Отчёт за период»; 12.10–18.10 (Пн–Вс) → «Еженедельный»; один день → «Ежедневный»; начало позже конца → ошибка, кнопка неактивна.
|
- [ ] **T-HUB-12** Выгрузка: «?» показывает 3 строки и закрывается кликом мимо. Даты 13.10–19.10 → «Отчёт за период»; 12.10–18.10 (Пн–Вс) → «Еженедельный»; один день → «Ежедневный»; начало позже конца → ошибка, кнопка неактивна.
|
||||||
@@ -101,7 +103,7 @@
|
|||||||
- [ ] ✍️ **T-OBJ-05** Меню ⋮ у «Корпус 1» → «Добавить объект» → «Раздел» «Фундамент». У «Фундамент» → «Вид работ» «Бетонирование». У вида работ нет пункта «Добавить объект».
|
- [ ] ✍️ **T-OBJ-05** Меню ⋮ у «Корпус 1» → «Добавить объект» → «Раздел» «Фундамент». У «Фундамент» → «Вид работ» «Бетонирование». У вида работ нет пункта «Добавить объект».
|
||||||
- [ ] ✍️ **T-OBJ-06** Пустое название → сообщение «Поле обязательно…»; Enter сохраняет.
|
- [ ] ✍️ **T-OBJ-06** Пустое название → сообщение «Поле обязательно…»; Enter сохраняет.
|
||||||
- [ ] ✍️ **T-OBJ-07** Редактировать «Фундамент» → сменить родителя (нужен второй объект-пятно «Корпус 2»).
|
- [ ] ✍️ **T-OBJ-07** Редактировать «Фундамент» → сменить родителя (нужен второй объект-пятно «Корпус 2»).
|
||||||
- [ ] ✍️ **T-OBJ-08** Перетащить объект на другой → родитель сменился; на своего потомка — запрещено.
|
- [-] **T-OBJ-08** Перетаскивание — **убрано** (решение 2026-10-16). Проверить, что строку дерева мышкой больше не перетащить.
|
||||||
- [ ] ✍️ **T-OBJ-09** Удалить объект с детьми → подтверждение с числом детей; дети остаются (🐞 так было).
|
- [ ] ✍️ **T-OBJ-09** Удалить объект с детьми → подтверждение с числом детей; дети остаются (🐞 так было).
|
||||||
- [ ] **T-OBJ-10** «Экспорт» → скачался `objects-{id}.xlsx`, содержимое как у старой страницы.
|
- [ ] **T-OBJ-10** «Экспорт» → скачался `objects-{id}.xlsx`, содержимое как у старой страницы.
|
||||||
- [ ] **T-OBJ-11** Меню ⋮ → «Работы» → открылся раздел «Работы» этого объекта.
|
- [ ] **T-OBJ-11** Меню ⋮ → «Работы» → открылся раздел «Работы» этого объекта.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useRouter } from '../routing/RouterContext';
|
|||||||
import { IAccessibleProject } from './projectTypes';
|
import { IAccessibleProject } from './projectTypes';
|
||||||
import { fetchAccessibleProjects } from './projectRepository';
|
import { fetchAccessibleProjects } from './projectRepository';
|
||||||
import { readStoredProject, storeProject } from './projectSession';
|
import { readStoredProject, storeProject } from './projectSession';
|
||||||
|
import { resolveCurrentProject } from './projectRules';
|
||||||
|
|
||||||
export type ProjectsLoadState = 'loading' | 'ready' | 'error';
|
export type ProjectsLoadState = 'loading' | 'ready' | 'error';
|
||||||
|
|
||||||
@@ -51,13 +52,12 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
if (isCancelled) return;
|
if (isCancelled) return;
|
||||||
setProjects(loaded);
|
setProjects(loaded);
|
||||||
setLoadState('ready');
|
setLoadState('ready');
|
||||||
// В сессии могли остаться устаревшие права — обновляем по свежим данным (как в main).
|
// В сессии могли остаться устаревшие статус и права — сохраняем свежие.
|
||||||
setStoredProject((stored) => {
|
setStoredProject((stored) => {
|
||||||
const fresh = stored ? findProject(loaded, stored.id) : undefined;
|
const fresh = stored ? findProject(loaded, stored.id) : undefined;
|
||||||
if (!stored || !fresh) return stored;
|
if (!fresh) return stored;
|
||||||
const updated = { ...stored, canEdit: fresh.canEdit };
|
storeProject(fresh);
|
||||||
storeProject(updated);
|
return fresh;
|
||||||
return updated;
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
@@ -71,13 +71,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
}, [services, reloadKey]);
|
}, [services, reloadKey]);
|
||||||
|
|
||||||
const routeProjectId = route.projectId ? Number(route.projectId) : undefined;
|
const routeProjectId = route.projectId ? Number(route.projectId) : undefined;
|
||||||
let currentProject: IAccessibleProject | undefined;
|
const currentProject = resolveCurrentProject(projects, routeProjectId, storedProject);
|
||||||
if (routeProjectId === undefined) {
|
|
||||||
currentProject = storedProject;
|
|
||||||
} else {
|
|
||||||
currentProject =
|
|
||||||
findProject(projects, routeProjectId) ?? (storedProject?.id === routeProjectId ? storedProject : undefined);
|
|
||||||
}
|
|
||||||
const currentProjectId = route.projectId ?? (storedProject ? String(storedProject.id) : undefined);
|
const currentProjectId = route.projectId ?? (storedProject ? String(storedProject.id) : undefined);
|
||||||
|
|
||||||
const selectProject = React.useCallback(
|
const selectProject = React.useCallback(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
isSectionAvailable,
|
isSectionAvailable,
|
||||||
mergeAccessRows,
|
mergeAccessRows,
|
||||||
pickRecentProjectIds,
|
pickRecentProjectIds,
|
||||||
|
resolveCurrentProject,
|
||||||
sortProjects
|
sortProjects
|
||||||
} from './projectRules';
|
} from './projectRules';
|
||||||
import { IAccessibleProject } from './projectTypes';
|
import { IAccessibleProject } from './projectTypes';
|
||||||
@@ -71,6 +72,25 @@ describe('getStatusKind', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolveCurrentProject', () => {
|
||||||
|
const stored = project({ id: 51, status: 'Текущий', canEdit: true });
|
||||||
|
const fresh = project({ id: 51, status: 'Архив', canEdit: false });
|
||||||
|
|
||||||
|
it('свежие статус и права из списка главнее сохранённых в сессии (баг T-HUB-07/09)', () => {
|
||||||
|
expect(resolveCurrentProject([fresh], undefined, stored)).toBe(fresh);
|
||||||
|
expect(resolveCurrentProject([fresh], 51, stored)).toBe(fresh);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пока список не загрузился — сохранённый выбор', () => {
|
||||||
|
expect(resolveCurrentProject([], undefined, stored)).toBe(stored);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('проект из адреса, которого нет ни в списке, ни в сессии, — не выбран', () => {
|
||||||
|
expect(resolveCurrentProject([fresh], 7, stored)).toBeUndefined();
|
||||||
|
expect(resolveCurrentProject([], undefined, undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('isSectionAvailable', () => {
|
describe('isSectionAvailable', () => {
|
||||||
it('архивный проект — закрыты все разделы, кроме главной', () => {
|
it('архивный проект — закрыты все разделы, кроме главной', () => {
|
||||||
const archived = project({ status: 'архивный' });
|
const archived = project({ status: 'архивный' });
|
||||||
|
|||||||
@@ -85,6 +85,23 @@ export function isSectionAvailable(page: PageId, project: IAccessibleProject): b
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текущий проект: id из адреса, а без него — сохранённый в сессии выбор.
|
||||||
|
* Данные (статус, права) берутся из свежего списка с сервера; сохранённая копия — только пока список
|
||||||
|
* не загрузился или если проекта в нём нет. Иначе после смены статуса / прав F5 показывал бы старые.
|
||||||
|
*/
|
||||||
|
export function resolveCurrentProject(
|
||||||
|
projects: IAccessibleProject[],
|
||||||
|
routeProjectId: number | undefined,
|
||||||
|
storedProject: IAccessibleProject | undefined
|
||||||
|
): IAccessibleProject | undefined {
|
||||||
|
const targetId = routeProjectId ?? storedProject?.id;
|
||||||
|
if (targetId === undefined) return undefined;
|
||||||
|
const fresh = projects.filter((project) => project.id === targetId)[0];
|
||||||
|
if (fresh) return fresh;
|
||||||
|
return storedProject?.id === targetId ? storedProject : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** Выгрузка за период недоступна только для архивного проекта. */
|
/** Выгрузка за период недоступна только для архивного проекта. */
|
||||||
export function isExportAvailable(project: IAccessibleProject): boolean {
|
export function isExportAvailable(project: IAccessibleProject): boolean {
|
||||||
return getStatusKind(project.status) !== 'archive';
|
return getStatusKind(project.status) !== 'archive';
|
||||||
|
|||||||
@@ -50,6 +50,25 @@ $dropdown-max-height: 340px;
|
|||||||
color: $gray-900;
|
color: $gray-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clearButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: $sp-1;
|
||||||
|
margin-right: $sp-2;
|
||||||
|
border: 0;
|
||||||
|
border-radius: $r-full;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-400;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray-100;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.selectedBadge {
|
.selectedBadge {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ export const ProjectPicker: React.FC = () => {
|
|||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Очистка не сбрасывает выбранный проект — только текст поиска, чтобы сразу искать другой.
|
||||||
|
const onClear = (event: React.MouseEvent): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
setQuery('');
|
||||||
|
setIsOpen(true);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
// mousedown, а не click: иначе поле теряет фокус раньше, чем срабатывает выбор.
|
// mousedown, а не click: иначе поле теряет фокус раньше, чем срабатывает выбор.
|
||||||
const onPick = (event: React.MouseEvent, project: IAccessibleProject): void => {
|
const onPick = (event: React.MouseEvent, project: IAccessibleProject): void => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -67,6 +75,17 @@ export const ProjectPicker: React.FC = () => {
|
|||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.clearButton}
|
||||||
|
title={TEXTS.clear}
|
||||||
|
aria-label={TEXTS.clear}
|
||||||
|
onMouseDown={onClear}
|
||||||
|
>
|
||||||
|
<Icon name="close" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{currentProject?.status && (
|
{currentProject?.status && (
|
||||||
<span className={styles.selectedBadge}>
|
<span className={styles.selectedBadge}>
|
||||||
<ProjectStatusBadge status={currentProject.status} />
|
<ProjectStatusBadge status={currentProject.status} />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const HUB_TEXTS = {
|
|||||||
picker: {
|
picker: {
|
||||||
label: 'Проект:',
|
label: 'Проект:',
|
||||||
placeholder: 'Выберите проект',
|
placeholder: 'Выберите проект',
|
||||||
|
clear: 'Очистить',
|
||||||
loading: 'Загрузка проектов…',
|
loading: 'Загрузка проектов…',
|
||||||
noProjects: 'Нет доступных проектов',
|
noProjects: 'Нет доступных проектов',
|
||||||
notFound: 'Проекты не найдены',
|
notFound: 'Проекты не найдены',
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { DropPosition } from './objectsTypes';
|
|
||||||
import { IObjectDrag } from './useObjectDrag';
|
|
||||||
import styles from './ObjectsPage.module.scss';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
targetId: number;
|
|
||||||
position: DropPosition;
|
|
||||||
drag: IObjectDrag;
|
|
||||||
}
|
|
||||||
|
|
||||||
const POSITION_CLASS: Record<DropPosition, string> = {
|
|
||||||
before: styles.dropZoneEdge,
|
|
||||||
inside: styles.dropZoneInside,
|
|
||||||
after: styles.dropZoneEdge
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Тонкая полоса до / внутри / после узла, куда можно бросить перетаскиваемый объект. */
|
|
||||||
export const DropZone: React.FC<Props> = ({ targetId, position, drag }) => {
|
|
||||||
const isActive = drag.activeZone?.targetId === targetId && drag.activeZone.position === position;
|
|
||||||
const className = [styles.dropZone, POSITION_CLASS[position], isActive ? styles.dropZoneActive : '']
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={className}
|
|
||||||
onDragOver={(event) => drag.onZoneDragOver({ targetId, position }, event)}
|
|
||||||
onDragLeave={drag.onZoneDragLeave}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -3,8 +3,6 @@ import { Icon } from '@tnsu/ui-kit';
|
|||||||
import { IMenuAnchor } from '../_tree/ContextMenu';
|
import { IMenuAnchor } from '../_tree/ContextMenu';
|
||||||
import { ObjectNode } from './objectsTypes';
|
import { ObjectNode } from './objectsTypes';
|
||||||
import { IObjectTreeView } from './useObjectTreeView';
|
import { IObjectTreeView } from './useObjectTreeView';
|
||||||
import { IObjectDrag } from './useObjectDrag';
|
|
||||||
import { DropZone } from './DropZone';
|
|
||||||
import { OBJECTS_TEXTS } from './texts';
|
import { OBJECTS_TEXTS } from './texts';
|
||||||
import styles from './ObjectsPage.module.scss';
|
import styles from './ObjectsPage.module.scss';
|
||||||
|
|
||||||
@@ -12,7 +10,6 @@ interface Props {
|
|||||||
node: ObjectNode;
|
node: ObjectNode;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
view: IObjectTreeView;
|
view: IObjectTreeView;
|
||||||
drag: IObjectDrag;
|
|
||||||
onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
|
onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,19 +17,14 @@ interface Props {
|
|||||||
const ROW_BASE_INDENT_PX = 12;
|
const ROW_BASE_INDENT_PX = 12;
|
||||||
const ROW_LEVEL_INDENT_PX = 22;
|
const ROW_LEVEL_INDENT_PX = 22;
|
||||||
|
|
||||||
function rowClassName(hasChildren: boolean, isSelected: boolean, isDragging: boolean): string {
|
function rowClassName(hasChildren: boolean, isSelected: boolean): string {
|
||||||
return [
|
return [styles.nodeRow, hasChildren ? styles.nodeRowParent : '', isSelected ? styles.nodeRowSelected : '']
|
||||||
styles.nodeRow,
|
|
||||||
hasChildren ? styles.nodeRowParent : '',
|
|
||||||
isSelected ? styles.nodeRowSelected : '',
|
|
||||||
isDragging ? styles.nodeRowDragging : ''
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Узел дерева объектов с зонами сброса и (если раскрыт) дочерними узлами. */
|
/** Узел дерева объектов и (если раскрыт) его дочерние узлы. */
|
||||||
export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onOpenMenu }) => {
|
export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, onOpenMenu }) => {
|
||||||
const hasChildren = node.children.length > 0;
|
const hasChildren = node.children.length > 0;
|
||||||
const isExpanded = view.expandedIds.has(node.id);
|
const isExpanded = view.expandedIds.has(node.id);
|
||||||
|
|
||||||
@@ -58,13 +50,9 @@ export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onO
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<DropZone targetId={node.id} position="before" drag={drag} />
|
|
||||||
<div
|
<div
|
||||||
className={rowClassName(hasChildren, view.selectedId === node.id, drag.draggingId === node.id)}
|
className={rowClassName(hasChildren, view.selectedId === node.id)}
|
||||||
style={{ paddingLeft: ROW_BASE_INDENT_PX + node.level * ROW_LEVEL_INDENT_PX }}
|
style={{ paddingLeft: ROW_BASE_INDENT_PX + node.level * ROW_LEVEL_INDENT_PX }}
|
||||||
draggable={canEdit}
|
|
||||||
onDragStart={(event) => drag.onDragStart(node.id, event)}
|
|
||||||
onDragEnd={drag.onDragEnd}
|
|
||||||
onClick={() => view.clickRow(node)}
|
onClick={() => view.clickRow(node)}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
>
|
>
|
||||||
@@ -93,22 +81,13 @@ export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onO
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DropZone targetId={node.id} position="inside" drag={drag} />
|
|
||||||
{hasChildren && isExpanded && (
|
{hasChildren && isExpanded && (
|
||||||
<div>
|
<div>
|
||||||
{node.children.map((child) => (
|
{node.children.map((child) => (
|
||||||
<ObjectTreeNode
|
<ObjectTreeNode key={child.id} node={child} canEdit={canEdit} view={view} onOpenMenu={onOpenMenu} />
|
||||||
key={child.id}
|
|
||||||
node={child}
|
|
||||||
canEdit={canEdit}
|
|
||||||
view={view}
|
|
||||||
drag={drag}
|
|
||||||
onOpenMenu={onOpenMenu}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<DropZone targetId={node.id} position="after" drag={drag} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -91,10 +91,6 @@ $menu-button-size: 28px;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodeRowDragging {
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodeToggle {
|
.nodeToggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -182,32 +178,6 @@ $menu-button-size: 28px;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Зоны сброса при перетаскивании ──────────────────────────────────────────
|
|
||||||
// Высоты 1:1 со старым web part: зона «внутрь» нулевой высоты (см. открытые вопросы в чек-листе).
|
|
||||||
.dropZone {
|
|
||||||
margin: 0 $sp-2;
|
|
||||||
border-radius: 1px;
|
|
||||||
transition: background 0.12s, height 0.12s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropZoneEdge {
|
|
||||||
height: 2px;
|
|
||||||
|
|
||||||
&.dropZoneActive {
|
|
||||||
height: 3px;
|
|
||||||
background: $gold-400;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropZoneInside {
|
|
||||||
height: 0;
|
|
||||||
|
|
||||||
&.dropZoneActive {
|
|
||||||
height: 2px;
|
|
||||||
background: rgba($gold-400, 0.3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Модалка ─────────────────────────────────────────────────────────────────
|
// ── Модалка ─────────────────────────────────────────────────────────────────
|
||||||
.modalBody {
|
.modalBody {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { useObjectsData } from './useObjectsData';
|
|||||||
import { useObjectActions } from './useObjectActions';
|
import { useObjectActions } from './useObjectActions';
|
||||||
import { useObjectsExport } from './useObjectsExport';
|
import { useObjectsExport } from './useObjectsExport';
|
||||||
import { useObjectTreeView } from './useObjectTreeView';
|
import { useObjectTreeView } from './useObjectTreeView';
|
||||||
import { useObjectDrag } from './useObjectDrag';
|
|
||||||
import { ObjectsHeader } from './ObjectsHeader';
|
import { ObjectsHeader } from './ObjectsHeader';
|
||||||
import { ObjectsTree } from './ObjectsTree';
|
import { ObjectsTree } from './ObjectsTree';
|
||||||
import { ObjectModal } from './ObjectModal';
|
import { ObjectModal } from './ObjectModal';
|
||||||
@@ -42,17 +41,9 @@ export const ObjectsPageContent: React.FC<Props> = ({ projectId }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const data = useObjectsData(repository, projectId);
|
const data = useObjectsData(repository, projectId);
|
||||||
const actions = useObjectActions({
|
const actions = useObjectActions({ repository, projectTitle: data.projectTitle, reload: data.reload });
|
||||||
repository,
|
|
||||||
projectTitle: data.projectTitle,
|
|
||||||
tree: data.tree,
|
|
||||||
reload: data.reload
|
|
||||||
});
|
|
||||||
const exporter = useObjectsExport(repository, projectId);
|
const exporter = useObjectsExport(repository, projectId);
|
||||||
const view = useObjectTreeView(data.tree);
|
const view = useObjectTreeView(data.tree);
|
||||||
const drag = useObjectDrag(data.tree, (dragId, targetId, position) => {
|
|
||||||
actions.moveObject(dragId, targetId, position).catch(() => undefined);
|
|
||||||
});
|
|
||||||
const [menu, setMenu] = React.useState<IOpenMenu | undefined>(undefined);
|
const [menu, setMenu] = React.useState<IOpenMenu | undefined>(undefined);
|
||||||
const [modalTarget, setModalTarget] = React.useState<IObjectModalTarget | undefined>(undefined);
|
const [modalTarget, setModalTarget] = React.useState<IObjectModalTarget | undefined>(undefined);
|
||||||
|
|
||||||
@@ -105,7 +96,6 @@ export const ObjectsPageContent: React.FC<Props> = ({ projectId }) => {
|
|||||||
tree={data.tree}
|
tree={data.tree}
|
||||||
canEdit={data.canEdit}
|
canEdit={data.canEdit}
|
||||||
view={view}
|
view={view}
|
||||||
drag={drag}
|
|
||||||
onOpenMenu={(node, anchor) => setMenu({ node, anchor })}
|
onOpenMenu={(node, anchor) => setMenu({ node, anchor })}
|
||||||
onAddRoot={openAddRoot}
|
onAddRoot={openAddRoot}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { IMenuAnchor } from '../_tree/ContextMenu';
|
|||||||
import { countNodes } from '../_tree/treeRules';
|
import { countNodes } from '../_tree/treeRules';
|
||||||
import { ObjectNode, ObjectTree } from './objectsTypes';
|
import { ObjectNode, ObjectTree } from './objectsTypes';
|
||||||
import { IObjectTreeView } from './useObjectTreeView';
|
import { IObjectTreeView } from './useObjectTreeView';
|
||||||
import { IObjectDrag } from './useObjectDrag';
|
|
||||||
import { ObjectTreeNode } from './ObjectTreeNode';
|
import { ObjectTreeNode } from './ObjectTreeNode';
|
||||||
import { OBJECTS_TEXTS } from './texts';
|
import { OBJECTS_TEXTS } from './texts';
|
||||||
import styles from './ObjectsPage.module.scss';
|
import styles from './ObjectsPage.module.scss';
|
||||||
@@ -14,13 +13,12 @@ interface Props {
|
|||||||
tree: ObjectTree;
|
tree: ObjectTree;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
view: IObjectTreeView;
|
view: IObjectTreeView;
|
||||||
drag: IObjectDrag;
|
|
||||||
onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
|
onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
|
||||||
onAddRoot: () => void;
|
onAddRoot: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Панель «N объектов / Развернуть / Свернуть» и дерево объектов (или «Объекты не найдены»). */
|
/** Панель «N объектов / Развернуть / Свернуть» и дерево объектов (или «Объекты не найдены»). */
|
||||||
export const ObjectsTree: React.FC<Props> = ({ tree, canEdit, view, drag, onOpenMenu, onAddRoot }) => (
|
export const ObjectsTree: React.FC<Props> = ({ tree, canEdit, view, onOpenMenu, onAddRoot }) => (
|
||||||
<>
|
<>
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
<span className={styles.totalCount}>{OBJECTS_TEXTS.toolbar.total(countNodes(tree.roots))}</span>
|
<span className={styles.totalCount}>{OBJECTS_TEXTS.toolbar.total(countNodes(tree.roots))}</span>
|
||||||
@@ -48,16 +46,9 @@ export const ObjectsTree: React.FC<Props> = ({ tree, canEdit, view, drag, onOpen
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.tree} onDrop={drag.onDrop}>
|
<div className={styles.tree}>
|
||||||
{tree.roots.map((node) => (
|
{tree.roots.map((node) => (
|
||||||
<ObjectTreeNode
|
<ObjectTreeNode key={node.id} node={node} canEdit={canEdit} view={view} onOpenMenu={onOpenMenu} />
|
||||||
key={node.id}
|
|
||||||
node={node}
|
|
||||||
canEdit={canEdit}
|
|
||||||
view={view}
|
|
||||||
drag={drag}
|
|
||||||
onOpenMenu={onOpenMenu}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -174,17 +174,6 @@ describe('запись', () => {
|
|||||||
expect(bodyOf(sharePoint, 2)).toEqual({ __metadata: { type: ENTITY_TYPE }, Title: 'Новое', parentId: 3 });
|
expect(bodyOf(sharePoint, 2)).toEqual({ __metadata: { type: ENTITY_TYPE }, Title: 'Новое', parentId: 3 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('перенос: меняется только parentId, в корень — null', async () => {
|
|
||||||
const { repository, sharePoint } = setup([ENTITY_TYPE_ROUTE, { match: '/items(', response: { status: 204 } }]);
|
|
||||||
|
|
||||||
await repository.moveObject(7, 2);
|
|
||||||
await repository.moveObject(7, undefined);
|
|
||||||
|
|
||||||
expect(sharePoint.requests[1].request.headers).toEqual(MERGE_HEADERS);
|
|
||||||
expect(bodyOf(sharePoint, 1)).toEqual({ __metadata: { type: ENTITY_TYPE }, parentId: 2 });
|
|
||||||
expect(bodyOf(sharePoint, 2)).toEqual({ __metadata: { type: ENTITY_TYPE }, parentId: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ошибка записи: error.message.value → error.message → statusText', async () => {
|
it('ошибка записи: error.message.value → error.message → statusText', async () => {
|
||||||
const verbose = setup([
|
const verbose = setup([
|
||||||
ENTITY_TYPE_ROUTE,
|
ENTITY_TYPE_ROUTE,
|
||||||
|
|||||||
@@ -165,8 +165,6 @@ export interface IObjectsRepository {
|
|||||||
createObject(projectTitle: string, title: string, parentId: number | undefined): Promise<void>;
|
createObject(projectTitle: string, title: string, parentId: number | undefined): Promise<void>;
|
||||||
/** newParentId — только при смене родителя Раздела; undefined — поле не трогаем. */
|
/** newParentId — только при смене родителя Раздела; undefined — поле не трогаем. */
|
||||||
updateObject(itemId: number, title: string, newParentId: number | undefined): Promise<void>;
|
updateObject(itemId: number, title: string, newParentId: number | undefined): Promise<void>;
|
||||||
/** Перенос (drag & drop): меняется только родитель; undefined — в корень. */
|
|
||||||
moveObject(itemId: number, newParentId: number | undefined): Promise<void>;
|
|
||||||
deleteObject(itemId: number): Promise<void>;
|
deleteObject(itemId: number): Promise<void>;
|
||||||
downloadObjectsExcel(projectId: string): Promise<Blob>;
|
downloadObjectsExcel(projectId: string): Promise<Blob>;
|
||||||
}
|
}
|
||||||
@@ -211,11 +209,6 @@ export function createObjectsRepository(deps: IObjectsRepositoryDeps): IObjectsR
|
|||||||
if (newParentId !== undefined) body[OBJECT_FIELDS.parentWrite] = newParentId;
|
if (newParentId !== undefined) body[OBJECT_FIELDS.parentWrite] = newParentId;
|
||||||
await postVerbose(deps, objectItemUrl(deps.siteUrl, itemId), MERGE_HEADERS, body);
|
await postVerbose(deps, objectItemUrl(deps.siteUrl, itemId), MERGE_HEADERS, body);
|
||||||
},
|
},
|
||||||
moveObject: async (itemId, newParentId) => {
|
|
||||||
// Перенос в корень сбрасывает lookup явным null.
|
|
||||||
const body = { ...(await metadata()), [OBJECT_FIELDS.parentWrite]: newParentId ?? null };
|
|
||||||
await postVerbose(deps, objectItemUrl(deps.siteUrl, itemId), MERGE_HEADERS, body);
|
|
||||||
},
|
|
||||||
deleteObject: (itemId) => deleteObject(deps, itemId),
|
deleteObject: (itemId) => deleteObject(deps, itemId),
|
||||||
downloadObjectsExcel: (projectId) => downloadObjectsExcel(deps, projectId)
|
downloadObjectsExcel: (projectId) => downloadObjectsExcel(deps, projectId)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
buildObjectTree,
|
buildObjectTree,
|
||||||
canAddChild,
|
canAddChild,
|
||||||
canDrop,
|
|
||||||
getChangedParentId,
|
getChangedParentId,
|
||||||
getDeleteConfirmMessage,
|
getDeleteConfirmMessage,
|
||||||
getErrorText,
|
getErrorText,
|
||||||
@@ -9,8 +8,7 @@ import {
|
|||||||
getParentDisplay,
|
getParentDisplay,
|
||||||
getParentOptions,
|
getParentOptions,
|
||||||
getSubmitLabel,
|
getSubmitLabel,
|
||||||
isParentSelectable,
|
isParentSelectable
|
||||||
resolveMove
|
|
||||||
} from './objectsRules';
|
} from './objectsRules';
|
||||||
import { buildObjectMenuItems } from './objectMenu';
|
import { buildObjectMenuItems } from './objectMenu';
|
||||||
import { IProjectObject, ObjectModalMode, ObjectNode, ObjectTree } from './objectsTypes';
|
import { IProjectObject, ObjectModalMode, ObjectNode, ObjectTree } from './objectsTypes';
|
||||||
@@ -100,26 +98,6 @@ describe('модалка', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('перетаскивание', () => {
|
|
||||||
it('нельзя бросить в себя и в своих потомков', () => {
|
|
||||||
expect(canDrop(tree, 1, 1)).toBe(false);
|
|
||||||
expect(canDrop(tree, 1, 4)).toBe(false);
|
|
||||||
expect(canDrop(tree, 4, 1)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('«внутрь» — родитель = цель, «до/после» — родитель цели', () => {
|
|
||||||
expect(resolveMove(tree, 4, 3, 'inside')).toEqual({ parentId: 3 });
|
|
||||||
expect(resolveMove(tree, 4, 3, 'before')).toEqual({ parentId: 1 });
|
|
||||||
expect(resolveMove(tree, 2, 5, 'after')).toEqual({ parentId: undefined });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('родитель не меняется или перенос запрещён — ничего не делаем', () => {
|
|
||||||
expect(resolveMove(tree, 2, 3, 'after')).toBeUndefined();
|
|
||||||
expect(resolveMove(tree, 1, 5, 'before')).toBeUndefined();
|
|
||||||
expect(resolveMove(tree, 1, 4, 'inside')).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('удаление и ошибки', () => {
|
describe('удаление и ошибки', () => {
|
||||||
it('подтверждение с числом дочерних', () => {
|
it('подтверждение с числом дочерних', () => {
|
||||||
expect(getDeleteConfirmMessage(node(1))).toEqual(
|
expect(getDeleteConfirmMessage(node(1))).toEqual(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ISelectOption } from '@tnsu/ui-kit';
|
import { ISelectOption } from '@tnsu/ui-kit';
|
||||||
import { buildTree, isDescendant } from '../_tree/treeRules';
|
import { buildTree, isDescendant } from '../_tree/treeRules';
|
||||||
import { DropPosition, IObjectModalTarget, IProjectObject, ObjectNode, ObjectTree } from './objectsTypes';
|
import { IObjectModalTarget, IProjectObject, ObjectNode, ObjectTree } from './objectsTypes';
|
||||||
import { OBJECTS_TEXTS } from './texts';
|
import { OBJECTS_TEXTS } from './texts';
|
||||||
|
|
||||||
// Правила страницы «Объекты» (stroy-bot-objects), вынесенные из DOM-кода в чистые функции.
|
// Правила страницы «Объекты» (stroy-bot-objects), вынесенные из DOM-кода в чистые функции.
|
||||||
@@ -72,33 +72,6 @@ export function getChangedParentId(node: ObjectNode | undefined, selectValue: st
|
|||||||
return !isNaN(parentId) && parentId !== node.item.parentId ? parentId : undefined;
|
return !isNaN(parentId) && parentId !== node.item.parentId ? parentId : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Бросать узел можно куда угодно, кроме себя и своих потомков. */
|
|
||||||
export function canDrop(tree: ObjectTree, dragId: number, targetId: number): boolean {
|
|
||||||
return dragId !== targetId && !isDescendant(tree.byId, dragId, targetId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IMoveResult {
|
|
||||||
/** undefined — перенос в корень. */
|
|
||||||
parentId: number | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Новый родитель после перетаскивания: «внутрь» — целевой узел, «до/после» — родитель целевого.
|
|
||||||
* undefined — переносить нечего (родитель не меняется или перенос запрещён).
|
|
||||||
*/
|
|
||||||
export function resolveMove(
|
|
||||||
tree: ObjectTree,
|
|
||||||
dragId: number,
|
|
||||||
targetId: number,
|
|
||||||
position: DropPosition
|
|
||||||
): IMoveResult | undefined {
|
|
||||||
const dragNode = tree.byId.get(dragId);
|
|
||||||
if (!dragNode || !canDrop(tree, dragId, targetId)) return undefined;
|
|
||||||
|
|
||||||
const parentId = position === 'inside' ? targetId : tree.byId.get(targetId)?.item.parentId;
|
|
||||||
return dragNode.item.parentId === parentId ? undefined : { parentId };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDeleteConfirmMessage(node: ObjectNode): string {
|
export function getDeleteConfirmMessage(node: ObjectNode): string {
|
||||||
return OBJECTS_TEXTS.deleteConfirm(
|
return OBJECTS_TEXTS.deleteConfirm(
|
||||||
node.item.title || OBJECTS_TEXTS.deleteFallbackTitle(node.id),
|
node.item.title || OBJECTS_TEXTS.deleteFallbackTitle(node.id),
|
||||||
|
|||||||
@@ -22,9 +22,6 @@ export interface IObjectModalTarget {
|
|||||||
node: ObjectNode | undefined;
|
node: ObjectNode | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Куда бросили узел относительно целевого: до, внутрь, после. */
|
|
||||||
export type DropPosition = 'before' | 'inside' | 'after';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Строка «Объекты» в ответе SharePoint ($select=Id,Title,parent/Id&$expand=parent).
|
* Строка «Объекты» в ответе SharePoint ($select=Id,Title,parent/Id&$expand=parent).
|
||||||
* Пустой lookup приходит как null — читается через `?.`.
|
* Пустой lookup приходит как null — читается через `?.`.
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ export const OBJECTS_TEXTS = {
|
|||||||
deleteFallbackTitle: (id: number): string => `ID ${id}`,
|
deleteFallbackTitle: (id: number): string => `ID ${id}`,
|
||||||
serverError: (status: number): string => `Ошибка сервера: ${status}`,
|
serverError: (status: number): string => `Ошибка сервера: ${status}`,
|
||||||
saveError: (message: string): string => `Ошибка сохранения: ${message}`,
|
saveError: (message: string): string => `Ошибка сохранения: ${message}`,
|
||||||
moveError: (message: string): string => `Не удалось переместить: ${message}`,
|
|
||||||
deleteError: (message: string): string => `Не удалось удалить: ${message}`,
|
deleteError: (message: string): string => `Не удалось удалить: ${message}`,
|
||||||
exportError: (message: string): string => `Не удалось скачать файл: ${message}`
|
exportError: (message: string): string => `Не удалось скачать файл: ${message}`
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,28 +1,26 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { confirmModal } from '@tnsu/ui-kit';
|
import { confirmModal } from '@tnsu/ui-kit';
|
||||||
import { IObjectsRepository } from './objectsRepository';
|
import { IObjectsRepository } from './objectsRepository';
|
||||||
import { getChangedParentId, getDeleteConfirmMessage, getErrorText, resolveMove } from './objectsRules';
|
import { getChangedParentId, getDeleteConfirmMessage, getErrorText } from './objectsRules';
|
||||||
import { DropPosition, IObjectModalTarget, ObjectNode, ObjectTree } from './objectsTypes';
|
import { IObjectModalTarget, ObjectNode } from './objectsTypes';
|
||||||
import { OBJECTS_TEXTS } from './texts';
|
import { OBJECTS_TEXTS } from './texts';
|
||||||
|
|
||||||
interface IActionsDeps {
|
interface IActionsDeps {
|
||||||
repository: IObjectsRepository;
|
repository: IObjectsRepository;
|
||||||
projectTitle: string | undefined;
|
projectTitle: string | undefined;
|
||||||
tree: ObjectTree;
|
|
||||||
reload: () => void;
|
reload: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IObjectActions {
|
export interface IObjectActions {
|
||||||
/** Ошибка переноса или удаления (раньше — alert). */
|
/** Ошибка удаления (раньше — alert). */
|
||||||
actionError: string | undefined;
|
actionError: string | undefined;
|
||||||
/** Создание / правка из модалки. Ошибку бросает дальше — модалка покажет её у себя. */
|
/** Создание / правка из модалки. Ошибку бросает дальше — модалка покажет её у себя. */
|
||||||
saveObject: (target: IObjectModalTarget, title: string, parentValue: string) => Promise<void>;
|
saveObject: (target: IObjectModalTarget, title: string, parentValue: string) => Promise<void>;
|
||||||
moveObject: (dragId: number, targetId: number, position: DropPosition) => Promise<void>;
|
|
||||||
deleteObject: (node: ObjectNode) => Promise<void>;
|
deleteObject: (node: ObjectNode) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Пишущие действия над деревом объектов. После каждого успешного действия дерево перечитывается. */
|
/** Пишущие действия над деревом объектов. После каждого успешного действия дерево перечитывается. */
|
||||||
export function useObjectActions({ repository, projectTitle, tree, reload }: IActionsDeps): IObjectActions {
|
export function useObjectActions({ repository, projectTitle, reload }: IActionsDeps): IObjectActions {
|
||||||
const [actionError, setActionError] = React.useState<string | undefined>(undefined);
|
const [actionError, setActionError] = React.useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const saveObject = async (target: IObjectModalTarget, title: string, parentValue: string): Promise<void> => {
|
const saveObject = async (target: IObjectModalTarget, title: string, parentValue: string): Promise<void> => {
|
||||||
@@ -39,18 +37,6 @@ export function useObjectActions({ repository, projectTitle, tree, reload }: IAc
|
|||||||
reload();
|
reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveObject = async (dragId: number, targetId: number, position: DropPosition): Promise<void> => {
|
|
||||||
const move = resolveMove(tree, dragId, targetId, position);
|
|
||||||
if (!move) return;
|
|
||||||
setActionError(undefined);
|
|
||||||
try {
|
|
||||||
await repository.moveObject(dragId, move.parentId);
|
|
||||||
reload();
|
|
||||||
} catch (error) {
|
|
||||||
setActionError(OBJECTS_TEXTS.moveError(getErrorText(error)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteObject = async (node: ObjectNode): Promise<void> => {
|
const deleteObject = async (node: ObjectNode): Promise<void> => {
|
||||||
const isConfirmed = await confirmModal({
|
const isConfirmed = await confirmModal({
|
||||||
message: getDeleteConfirmMessage(node),
|
message: getDeleteConfirmMessage(node),
|
||||||
@@ -67,5 +53,5 @@ export function useObjectActions({ repository, projectTitle, tree, reload }: IAc
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return { actionError, saveObject, moveObject, deleteObject };
|
return { actionError, saveObject, deleteObject };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { canDrop } from './objectsRules';
|
|
||||||
import { DropPosition, ObjectTree } from './objectsTypes';
|
|
||||||
|
|
||||||
export interface IDropZone {
|
|
||||||
targetId: number;
|
|
||||||
position: DropPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IObjectDrag {
|
|
||||||
draggingId: number | undefined;
|
|
||||||
/** Подсвеченная зона сброса. */
|
|
||||||
activeZone: IDropZone | undefined;
|
|
||||||
onDragStart: (nodeId: number, event: React.DragEvent) => void;
|
|
||||||
onDragEnd: () => void;
|
|
||||||
onZoneDragOver: (zone: IDropZone, event: React.DragEvent) => void;
|
|
||||||
onZoneDragLeave: () => void;
|
|
||||||
onDrop: (event: React.DragEvent) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Перетаскивание узлов — как в stroy-bot-objects: узел бросается на зону до / внутрь / после
|
|
||||||
* другого узла. Бросок засчитывается в последнюю «разрешённую» зону, над которой был курсор.
|
|
||||||
*/
|
|
||||||
export function useObjectDrag(
|
|
||||||
tree: ObjectTree,
|
|
||||||
onMove: (dragId: number, targetId: number, position: DropPosition) => void
|
|
||||||
): IObjectDrag {
|
|
||||||
const [draggingId, setDraggingId] = React.useState<number | undefined>(undefined);
|
|
||||||
const [activeZone, setActiveZone] = React.useState<IDropZone | undefined>(undefined);
|
|
||||||
// Последняя разрешённая зона: в старом коде dragleave снимал только подсветку, а цель запоминалась.
|
|
||||||
const dropTargetRef = React.useRef<IDropZone | undefined>(undefined);
|
|
||||||
|
|
||||||
const reset = (): void => {
|
|
||||||
setDraggingId(undefined);
|
|
||||||
setActiveZone(undefined);
|
|
||||||
dropTargetRef.current = undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDragStart = (nodeId: number, event: React.DragEvent): void => {
|
|
||||||
setDraggingId(nodeId);
|
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
|
||||||
event.dataTransfer.setData('text/plain', String(nodeId));
|
|
||||||
};
|
|
||||||
|
|
||||||
const onZoneDragOver = (zone: IDropZone, event: React.DragEvent): void => {
|
|
||||||
if (draggingId === undefined || !canDrop(tree, draggingId, zone.targetId)) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.dataTransfer.dropEffect = 'move';
|
|
||||||
setActiveZone(zone);
|
|
||||||
dropTargetRef.current = zone;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDrop = (event: React.DragEvent): void => {
|
|
||||||
event.preventDefault();
|
|
||||||
const dragId = draggingId;
|
|
||||||
const target = dropTargetRef.current;
|
|
||||||
reset();
|
|
||||||
if (dragId === undefined || !target) return;
|
|
||||||
onMove(dragId, target.targetId, target.position);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
draggingId,
|
|
||||||
activeZone,
|
|
||||||
onDragStart,
|
|
||||||
onDragEnd: reset,
|
|
||||||
onZoneDragOver,
|
|
||||||
onZoneDragLeave: () => setActiveZone(undefined),
|
|
||||||
onDrop
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
assignedItemsUrl,
|
assignedItemsUrl,
|
||||||
assignedItemUrl,
|
assignedItemUrl,
|
||||||
deleteAssignedResource,
|
deleteAssignedResource,
|
||||||
|
existingAssignmentUrl,
|
||||||
fetchAllItems,
|
fetchAllItems,
|
||||||
fetchProjectResources,
|
fetchProjectResources,
|
||||||
poolItemsUrl,
|
poolItemsUrl,
|
||||||
@@ -105,13 +106,29 @@ describe('fetchProjectResources', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('addAssignedResource', () => {
|
describe('addAssignedResource', () => {
|
||||||
|
/** Проверка на дубль: привязки ещё нет. */
|
||||||
|
const notAssignedYet = { match: '$top=1', response: { body: { value: [] } } };
|
||||||
|
|
||||||
|
it('сначала ищет существующую привязку этого элемента к проекту', () => {
|
||||||
|
expect(existingAssignmentUrl(SITE, 'professions', '5', 12)).toEqual(
|
||||||
|
`${items('Профессии по проекту')}?$select=Id&$top=1&$filter=projectIdId eq 5 and professionIdId eq 12`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('привязка уже есть (добавили в другой вкладке) — новая не создаётся, возвращается её Id', async () => {
|
||||||
|
const client = createFakeHttpClient([{ match: '$top=1', response: { body: { value: [{ Id: 77 }] } } }]);
|
||||||
|
|
||||||
|
expect(await addAssignedResource(client, SITE, 'professions', '5', { id: 12, title: 'Бетонщик' })).toEqual(77);
|
||||||
|
expect(client.requests.some((r) => r.request.method === 'POST')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('профессия: POST в items с телом как в assigner, возвращает Id', async () => {
|
it('профессия: POST в items с телом как в assigner, возвращает Id', async () => {
|
||||||
const client = createFakeHttpClient([{ match: 'items', response: { status: 201, body: { Id: 101 } } }]);
|
const client = createFakeHttpClient([notAssignedYet, { match: 'items', response: { status: 201, body: { Id: 101 } } }]);
|
||||||
|
|
||||||
const id = await addAssignedResource(client, SITE, 'professions', '5', { id: 12, title: 'Бетонщик' });
|
const id = await addAssignedResource(client, SITE, 'professions', '5', { id: 12, title: 'Бетонщик' });
|
||||||
|
|
||||||
expect(id).toEqual(101);
|
expect(id).toEqual(101);
|
||||||
expect(client.requests[0]).toEqual({
|
expect(client.requests[1]).toEqual({
|
||||||
url: items('Профессии по проекту'),
|
url: items('Профессии по проекту'),
|
||||||
request: {
|
request: {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -126,16 +143,16 @@ describe('addAssignedResource', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('компания: поле companyIdId', async () => {
|
it('компания: поле companyIdId', async () => {
|
||||||
const client = createFakeHttpClient([{ match: 'items', response: { status: 201, body: { Id: 7 } } }]);
|
const client = createFakeHttpClient([notAssignedYet, { match: 'items', response: { status: 201, body: { Id: 7 } } }]);
|
||||||
|
|
||||||
await addAssignedResource(client, SITE, 'companies', '5', { id: 3, title: 'ТОО Строй' });
|
await addAssignedResource(client, SITE, 'companies', '5', { id: 3, title: 'ТОО Строй' });
|
||||||
|
|
||||||
expect(client.requests[0].url).toEqual(items('Компании по проекту'));
|
expect(client.requests[1].url).toEqual(items('Компании по проекту'));
|
||||||
expect(client.requests[0].request.body).toEqual('{"Title":"ТОО Строй","projectIdId":5,"companyIdId":3}');
|
expect(client.requests[1].request.body).toEqual('{"Title":"ТОО Строй","projectIdId":5,"companyIdId":3}');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ошибка сервера пробрасывается', async () => {
|
it('ошибка сервера пробрасывается', async () => {
|
||||||
const client = createFakeHttpClient([{ match: 'items', response: { status: 500, body: 'boom' } }]);
|
const client = createFakeHttpClient([notAssignedYet, { match: 'items', response: { status: 500, body: 'boom' } }]);
|
||||||
await expect(addAssignedResource(client, SITE, 'companies', '5', { id: 3, title: 'X' })).rejects.toThrow(
|
await expect(addAssignedResource(client, SITE, 'companies', '5', { id: 3, title: 'X' })).rejects.toThrow(
|
||||||
'500'
|
'500'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -130,7 +130,20 @@ export async function fetchProjectResources(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Создаёт привязку и возвращает id новой записи. */
|
/** Уже существующая привязка этого элемента к проекту (`{поле}Id` — id из lookup). */
|
||||||
|
export function existingAssignmentUrl(siteUrl: string, kind: ResourceKind, projectId: string, itemId: number): string {
|
||||||
|
const { assignedList, lookupField } = RESOURCE_LISTS[kind];
|
||||||
|
return (
|
||||||
|
`${listItemsUrl(siteUrl, assignedList)}?$select=Id&$top=1` +
|
||||||
|
`&$filter=${PROJECT_LOOKUP_ID_FIELD} eq ${Number(projectId)} and ${lookupField}Id eq ${itemId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт привязку и возвращает её id. Если такая привязка уже есть (её создали в другой вкладке
|
||||||
|
* или другой пользователь, пока у нас был открыт старый список), новая не создаётся — возвращается
|
||||||
|
* id существующей. В старом assigner такой проверки не было, и получались дубли.
|
||||||
|
*/
|
||||||
export async function addAssignedResource(
|
export async function addAssignedResource(
|
||||||
client: IHttpClient,
|
client: IHttpClient,
|
||||||
siteUrl: string,
|
siteUrl: string,
|
||||||
@@ -138,6 +151,14 @@ export async function addAssignedResource(
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
item: IResourceItem
|
item: IResourceItem
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
|
const existing = await getJson<{ value: Array<{ Id: number }> }>(
|
||||||
|
client,
|
||||||
|
existingAssignmentUrl(siteUrl, kind, projectId, item.id)
|
||||||
|
);
|
||||||
|
if (existing.value.length > 0) {
|
||||||
|
return existing.value[0].Id;
|
||||||
|
}
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
Title: item.title,
|
Title: item.title,
|
||||||
[PROJECT_LOOKUP_ID_FIELD]: Number(projectId),
|
[PROJECT_LOOKUP_ID_FIELD]: Number(projectId),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as React from 'react';
|
|||||||
import { Alert, Button, Dialog, FormField, Select, TextInput } from '@tnsu/ui-kit';
|
import { Alert, Button, Dialog, FormField, Select, TextInput } from '@tnsu/ui-kit';
|
||||||
import { ITree } from '../_tree/treeRules';
|
import { ITree } from '../_tree/treeRules';
|
||||||
import { FormInput } from '../_tree/FormInput';
|
import { FormInput } from '../_tree/FormInput';
|
||||||
|
import { SearchableSelect } from '../../shared/components/SearchableSelect';
|
||||||
import { IWorkEditor } from './useWorkEditor';
|
import { IWorkEditor } from './useWorkEditor';
|
||||||
import { parentOptions, unitOptions } from './worksRules';
|
import { parentOptions, unitOptions } from './worksRules';
|
||||||
import { IWork } from './worksTypes';
|
import { IWork } from './worksTypes';
|
||||||
@@ -60,12 +61,15 @@ export const WorkModal: React.FC<Props> = ({ editor, tree, units, objectTitle })
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={TEXTS.unit} htmlFor="work-unit" full>
|
<FormField label={TEXTS.unit} htmlFor="work-unit" full>
|
||||||
<Select
|
<SearchableSelect
|
||||||
id="work-unit"
|
id="work-unit"
|
||||||
value={form.unit}
|
value={form.unit}
|
||||||
onChange={(value) => setField('unit', value)}
|
onChange={(value) => setField('unit', value)}
|
||||||
options={unitOptions(units, form.unit)}
|
options={unitOptions(units, form.unit)}
|
||||||
placeholder={TEXTS.unitPlaceholder}
|
placeholder={TEXTS.unitPlaceholder}
|
||||||
|
searchPlaceholder={TEXTS.unitSearch}
|
||||||
|
notFoundText={TEXTS.unitNotFound}
|
||||||
|
clearLabel={TEXTS.cancel}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={TEXTS.volume} htmlFor="work-volume" full>
|
<FormField label={TEXTS.volume} htmlFor="work-volume" full>
|
||||||
@@ -93,7 +97,14 @@ export const WorkModal: React.FC<Props> = ({ editor, tree, units, objectTitle })
|
|||||||
<TextInput id="work-start" type="date" value={form.start} onChange={(value) => setField('start', value)} />
|
<TextInput id="work-start" type="date" value={form.start} onChange={(value) => setField('start', value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={TEXTS.finish} htmlFor="work-finish" full>
|
<FormField label={TEXTS.finish} htmlFor="work-finish" full>
|
||||||
<TextInput id="work-finish" type="date" value={form.finish} onChange={(value) => setField('finish', value)} />
|
{/* min — календарь не даёт выбрать окончание раньше начала. */}
|
||||||
|
<TextInput
|
||||||
|
id="work-finish"
|
||||||
|
type="date"
|
||||||
|
value={form.finish}
|
||||||
|
min={form.start || undefined}
|
||||||
|
onChange={(value) => setField('finish', value)}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
{editor.saveError && <Alert variant="err">{editor.saveError}</Alert>}
|
{editor.saveError && <Alert variant="err">{editor.saveError}</Alert>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ export const WORKS_TEXTS = {
|
|||||||
add: 'Добавить',
|
add: 'Добавить',
|
||||||
save: 'Сохранить',
|
save: 'Сохранить',
|
||||||
saving: 'Сохранение...',
|
saving: 'Сохранение...',
|
||||||
saveError: 'Ошибка при сохранении. Проверьте права доступа к Dataverse.'
|
saveError: 'Ошибка при сохранении. Проверьте права доступа к Dataverse.',
|
||||||
|
dateRangeError: 'Дата окончания не может быть раньше даты начала',
|
||||||
|
unitSearch: 'Начните вводить ед. изм.',
|
||||||
|
unitNotFound: 'Ничего не найдено'
|
||||||
},
|
},
|
||||||
confirmDelete: {
|
confirmDelete: {
|
||||||
title: 'Подтвердите удаление',
|
title: 'Подтвердите удаление',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { buildWorkBody, IWorksRepository } from './worksRepository';
|
import { buildWorkBody, IWorksRepository } from './worksRepository';
|
||||||
import { isTitleValid, needsClearParent, resetFormKeepParent, workToForm } from './worksRules';
|
import { isDateRangeValid, isTitleValid, needsClearParent, resetFormKeepParent, workToForm } from './worksRules';
|
||||||
import { EMPTY_WORK_FORM, IWork, IWorkForm, WorkModalMode } from './worksTypes';
|
import { EMPTY_WORK_FORM, IWork, IWorkForm, WorkModalMode } from './worksTypes';
|
||||||
import { WORKS_TEXTS } from './texts';
|
import { WORKS_TEXTS } from './texts';
|
||||||
|
|
||||||
@@ -63,6 +63,11 @@ export function useWorkEditor({ repository, objectId, reloadWorks }: IEditorDeps
|
|||||||
titleInputRef.current?.focus();
|
titleInputRef.current?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// В старом stroy-bot-works проверки не было — исправлено при ручной проверке (T-WRK).
|
||||||
|
if (!isDateRangeValid(form)) {
|
||||||
|
setState((prev) => ({ ...prev, isTitleInvalid: false, saveError: WORKS_TEXTS.modal.dateRangeError }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState((prev) => ({ ...prev, isTitleInvalid: false, isSaving: true, saveError: undefined }));
|
setState((prev) => ({ ...prev, isTitleInvalid: false, isSaving: true, saveError: undefined }));
|
||||||
try {
|
try {
|
||||||
const parentNavProperty = form.parentId.trim() ? await repository.getParentNavProperty() : '';
|
const parentNavProperty = form.parentId.trim() ? await repository.getParentNavProperty() : '';
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
buildWorkTree,
|
buildWorkTree,
|
||||||
formatDateRange,
|
formatDateRange,
|
||||||
formatVolume,
|
formatVolume,
|
||||||
|
isDateRangeValid,
|
||||||
isTitleValid,
|
isTitleValid,
|
||||||
needsClearParent,
|
needsClearParent,
|
||||||
parentOptions,
|
parentOptions,
|
||||||
@@ -80,6 +81,13 @@ describe('форма', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('окончание не раньше начала; пустые даты не проверяются (баг старого кода исправлен)', () => {
|
||||||
|
expect(isDateRangeValid({ ...EMPTY_WORK_FORM, start: '2026-10-19', finish: '2026-10-16' })).toBe(false);
|
||||||
|
expect(isDateRangeValid({ ...EMPTY_WORK_FORM, start: '2026-10-19', finish: '2026-10-19' })).toBe(true);
|
||||||
|
expect(isDateRangeValid({ ...EMPTY_WORK_FORM, start: '2026-10-19', finish: '' })).toBe(true);
|
||||||
|
expect(isDateRangeValid({ ...EMPTY_WORK_FORM, start: '', finish: '2026-10-16' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('наименование обязательно (пробелы не считаются)', () => {
|
it('наименование обязательно (пробелы не считаются)', () => {
|
||||||
expect(isTitleValid({ ...EMPTY_WORK_FORM, title: ' ' })).toBe(false);
|
expect(isTitleValid({ ...EMPTY_WORK_FORM, title: ' ' })).toBe(false);
|
||||||
expect(isTitleValid({ ...EMPTY_WORK_FORM, title: 'Бетон' })).toBe(true);
|
expect(isTitleValid({ ...EMPTY_WORK_FORM, title: 'Бетон' })).toBe(true);
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ export function resetFormKeepParent(form: IWorkForm): IWorkForm {
|
|||||||
return { title: '', unit: '', volume: '', parentId: form.parentId, start: '', finish: '' };
|
return { title: '', unit: '', volume: '', parentId: form.parentId, start: '', finish: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Окончание не раньше начала (даты YYYY-MM-DD сравниваются строками). Пустые даты не проверяются. */
|
||||||
|
export function isDateRangeValid(form: IWorkForm): boolean {
|
||||||
|
return !form.start || !form.finish || form.start <= form.finish;
|
||||||
|
}
|
||||||
|
|
||||||
export function isTitleValid(form: IWorkForm): boolean {
|
export function isTitleValid(form: IWorkForm): boolean {
|
||||||
return form.title.trim() !== '';
|
return form.title.trim() !== '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,14 @@ export function createSharePointClient(context: WebPartContext): IHttpClient {
|
|||||||
export function createAadClient(context: WebPartContext, resource: string): IHttpClient {
|
export function createAadClient(context: WebPartContext, resource: string): IHttpClient {
|
||||||
let clientPromise: Promise<AadHttpClient> | undefined;
|
let clientPromise: Promise<AadHttpClient> | undefined;
|
||||||
|
|
||||||
|
// Неудачную попытку не запоминаем: иначе одна сетевая осечка при получении токена
|
||||||
|
// ломала бы все запросы к этому ресурсу до перезагрузки страницы.
|
||||||
const getClient = (): Promise<AadHttpClient> => {
|
const getClient = (): Promise<AadHttpClient> => {
|
||||||
if (!clientPromise) {
|
if (!clientPromise) {
|
||||||
clientPromise = context.aadHttpClientFactory.getClient(resource);
|
clientPromise = context.aadHttpClientFactory.getClient(resource).catch((error: unknown) => {
|
||||||
|
clientPromise = undefined;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return clientPromise;
|
return clientPromise;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Длинный справочник прокручивается внутри списка, а не растягивает модалку.
|
||||||
|
$dropdown-max-height: 260px;
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-md;
|
||||||
|
padding: 0 $sp-3;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldOpen {
|
||||||
|
border-color: $gold-500;
|
||||||
|
box-shadow: 0 0 0 3px rgba(212, 160, 30, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: $sp-2 0;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-body;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: $sp-1;
|
||||||
|
margin-right: $sp-1;
|
||||||
|
border: 0;
|
||||||
|
border-radius: $r-full;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-400;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray-100;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
display: inline-flex;
|
||||||
|
color: $gray-500;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + #{$sp-1});
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 30;
|
||||||
|
max-height: $dropdown-max-height;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-md;
|
||||||
|
box-shadow: $sh-3;
|
||||||
|
padding: $sp-1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
padding: $sp-2 $sp-3;
|
||||||
|
font-size: $fs-body;
|
||||||
|
color: $gray-900;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $navy-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionSelected {
|
||||||
|
font-weight: 600;
|
||||||
|
background: $gold-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
padding: $sp-3;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Icon, ISelectOption } from '@tnsu/ui-kit';
|
||||||
|
import { useOutsideClose } from '../hooks/useOutsideClose';
|
||||||
|
import { filterOptions } from '../utils/filterOptions';
|
||||||
|
import styles from './SearchableSelect.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
value: string;
|
||||||
|
options: ISelectOption[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
/** Текст в пустом поле, когда ничего не выбрано. */
|
||||||
|
placeholder: string;
|
||||||
|
/** Подсказка в поле, пока пользователь ищет. */
|
||||||
|
searchPlaceholder: string;
|
||||||
|
notFoundText: string;
|
||||||
|
clearLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выпадающий список с поиском: можно прокрутить или начать печатать — список отфильтруется.
|
||||||
|
* Выбрать можно только значение из списка (свободный текст не сохраняется). В ui-kit такого
|
||||||
|
* компонента пока нет — живёт здесь, позже переедет в кит.
|
||||||
|
*/
|
||||||
|
export const SearchableSelect: React.FC<Props> = (props) => {
|
||||||
|
const { id, value, options, onChange, placeholder, searchPlaceholder, notFoundText, clearLabel } = props;
|
||||||
|
const [isOpen, setIsOpen] = React.useState<boolean>(false);
|
||||||
|
const [query, setQuery] = React.useState<string>('');
|
||||||
|
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const selectedLabel = options.filter((option) => option.value === value)[0]?.label ?? value;
|
||||||
|
const filtered = filterOptions(options, query);
|
||||||
|
|
||||||
|
const close = (): void => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setQuery('');
|
||||||
|
};
|
||||||
|
useOutsideClose(wrapperRef, isOpen, close);
|
||||||
|
|
||||||
|
const pick = (option: ISelectOption): void => {
|
||||||
|
onChange(option.value);
|
||||||
|
close();
|
||||||
|
inputRef.current?.blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||||
|
// Enter выбирает первое найденное — быстрее, чем тянуться к мышке.
|
||||||
|
if (event.key === 'Enter' && isOpen && filtered.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
pick(filtered[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// mousedown, а не click: иначе поле теряет фокус раньше, чем срабатывает выбор.
|
||||||
|
const onClear = (event: React.MouseEvent): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
onChange('');
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper} ref={wrapperRef}>
|
||||||
|
<div className={isOpen ? `${styles.field} ${styles.fieldOpen}` : styles.field}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
className={styles.input}
|
||||||
|
value={isOpen ? query : selectedLabel}
|
||||||
|
placeholder={isOpen ? searchPlaceholder : placeholder}
|
||||||
|
autoComplete="off"
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
|
onChange={(event) => {
|
||||||
|
setQuery(event.target.value);
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
/>
|
||||||
|
{value && !isOpen && (
|
||||||
|
<button type="button" className={styles.clearButton} title={clearLabel} aria-label={clearLabel} onMouseDown={onClear}>
|
||||||
|
<Icon name="close" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className={styles.chevron} aria-hidden="true">
|
||||||
|
<Icon name="chevron-down" size="sm" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isOpen && (
|
||||||
|
<div className={styles.dropdown} role="listbox">
|
||||||
|
{filtered.length === 0 && <div className={styles.message}>{notFoundText}</div>}
|
||||||
|
{filtered.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
role="option"
|
||||||
|
aria-selected={option.value === value}
|
||||||
|
className={option.value === value ? `${styles.option} ${styles.optionSelected}` : styles.option}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
pick(option);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { filterOptions } from './filterOptions';
|
||||||
|
|
||||||
|
const units = ['м', 'м2', 'м3', 'кг', 'Комплект'].map((label) => ({ value: label, label }));
|
||||||
|
|
||||||
|
describe('filterOptions', () => {
|
||||||
|
it('пустой запрос — весь список', () => {
|
||||||
|
expect(filterOptions(units, ' ')).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('подстрока без учёта регистра', () => {
|
||||||
|
expect(filterOptions(units, 'м3').map((o) => o.value)).toEqual(['м3']);
|
||||||
|
expect(filterOptions(units, 'КОМ').map((o) => o.value)).toEqual(['Комплект']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ничего не найдено — пустой список', () => {
|
||||||
|
expect(filterOptions(units, 'литр')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { ISelectOption } from '@tnsu/ui-kit';
|
||||||
|
|
||||||
|
/** Поиск по подписи: подстрока без учёта регистра. Пустой запрос — весь список. */
|
||||||
|
export function filterOptions(options: ISelectOption[], query: string): ISelectOption[] {
|
||||||
|
const normalized = query.trim().toLowerCase();
|
||||||
|
if (!normalized) return options;
|
||||||
|
return options.filter((option) => option.label.toLowerCase().indexOf(normalized) !== -1);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user