Compare commits
30 Commits
fad0842640
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a9af74ba02 | |||
| f3e098ee84 | |||
| 10ba9a6623 | |||
| ee282b61b7 | |||
| a98162fce7 | |||
| 18c2959820 | |||
| db8565b0d3 | |||
| 1ba67fe4c1 | |||
| eb646193fa | |||
| 0ae52ccd6b | |||
| df4998c75e | |||
| d606f8371c | |||
| b18cf50efd | |||
| 76878c7617 | |||
| f05ee3e854 | |||
| f6af34cb0a | |||
| 40a39cf828 | |||
| ae383b5231 | |||
| 33f31366cd | |||
| f0272c2170 | |||
| cb810972c4 | |||
| 7ca71c6b6c | |||
| 3128190b5c | |||
| d2852df976 | |||
| 8ed1a02967 | |||
| c69a11d167 | |||
| 45c974d0c6 | |||
| 650bce809a | |||
| bb7093de20 | |||
| 355a1300ce |
+1
-1
@@ -2,5 +2,5 @@
|
|||||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/spfx-serve.schema.json",
|
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/spfx-serve.schema.json",
|
||||||
"port": 4321,
|
"port": 4321,
|
||||||
"https": true,
|
"https": true,
|
||||||
"initialPage": "https://tnsukz.sharepoint.com/_layouts/workbench.aspx"
|
"initialPage": "https://tnsukz.sharepoint.com/sites/stroy/_layouts/workbench.aspx"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
|
|
||||||
## 0. Общее (оболочка)
|
## 0. Общее (оболочка)
|
||||||
|
|
||||||
|
> 🔧 **Исправлено при проверке T-RPL (2026-10-19):** «Отчёты по проекту» и форма отчёта брали id пользователя из контекста страницы (`legacyPageContext.userId`) и путь к DocLib — от сайта страницы. У каждого сайта свой id пользователя, поэтому в workbench корневого сайта «Отчёты» показывали «Нет доступа». Теперь всё — от сайта со списками (`settings.siteUrl`): id через `_api/web/currentuser`, путь папки фото — от `siteUrl`. Workbench по умолчанию открывается на `/sites/stroy`.
|
||||||
|
|
||||||
- [ ] Одна web part, разделы переключаются через `?page=…`, проект — через `?projectId=…`
|
- [ ] Одна web part, разделы переключаются через `?page=…`, проект — через `?projectId=…`
|
||||||
- [ ] Кнопки «назад/вперёд» браузера переключают разделы
|
- [ ] Кнопки «назад/вперёд» браузера переключают разделы
|
||||||
- [ ] Ссылку на любой раздел можно скопировать и открыть в новой вкладке
|
- [ ] Ссылку на любой раздел можно скопировать и открыть в новой вкладке
|
||||||
@@ -73,6 +75,28 @@
|
|||||||
- Адрес выгрузки берётся из настройки «Базовый URL API» (по умолчанию тот же `https://api.tnsu.kz`).
|
- Адрес выгрузки берётся из настройки «Базовый URL API» (по умолчанию тот же `https://api.tnsu.kz`).
|
||||||
- Меню слева скрывает разделы по правилам карточек: архивный — только «Проект»; завершён или нет `edit0` — без «Отправить отчёт»; «Привязка к справочнику» — только с `edit0`.
|
- Меню слева скрывает разделы по правилам карточек: архивный — только «Проект»; завершён или нет `edit0` — без «Отправить отчёт»; «Привязка к справочнику» — только с `edit0`.
|
||||||
|
|
||||||
|
### Сверка погодных условий — `?page=weather&projectId=&start=&end=`
|
||||||
|
Добавлена в stroy-bot-main 16.10.2026 (коммиты `d54a581`…`594fc7b`, Mariya Trishina), перенесена 20.10.2026.
|
||||||
|
- [ ] Кнопка «Сверка погодных условий» рядом с «Выгрузить», активна при обоих датах и верном периоде
|
||||||
|
- [ ] Ссылка «‹ Разделы проекта» — на главную с теми же датами
|
||||||
|
- [ ] Шапка: «Сверка погодных условий», название проекта, «Период: ДД.ММ.ГГГГ — ДД.ММ.ГГГГ» (один день — одна дата)
|
||||||
|
- [ ] Без проекта — «Проект не выбран…», без периода — «Период не указан…», загрузка — «Загрузка данных…»
|
||||||
|
- [ ] Запрос `GET https://api.tnsu.kz/Report/StroyBot/WeatherCheck?start=…&end=…&projectId=…` (unix как у выгрузки); ошибка — «Не удалось загрузить данные: {status} {statusText}»
|
||||||
|
- [ ] Метеостанция: «Метеостанция {name}», «WIGOS {code} · {N} км от объекта»
|
||||||
|
- [ ] «Погода по дням»: Дата, Наблюдений, T мин/макс, Ветер, Порывы, Осадки, Явления; пусто — «Нет данных наблюдений за период»
|
||||||
|
- [ ] «Позиции и оценка условий труда»: Дата, Профессия / вид работ, Смена (+время), Часы, Тип работ (`hours_type`), Чел., Погода в смену, Оценка, Комментарий, Акт простоя; пусто — «Нет позиций за период»
|
||||||
|
- [ ] Погода в смену: T мин…макс, ветер (+порывы), осадки, точка росы, видимость (от 1000 м — км), наблюдений, явления; нет — «нет данных»
|
||||||
|
- [ ] «Запросить оценку ИИ…» → `POST …/WeatherCheck/Assess` с `{ positions }` → «Идёт оценка, это может занять до минуты…» → позиции заменяются ответом, «Оценено позиций: N[, пропущено: M]»; ошибка — «Не удалось получить оценку: …»
|
||||||
|
- [ ] Оценка «В пределах нормы» — зелёная, другая — жёлтая; акт простоя: «Рекомендован» (+причина) / «Не требуется» / «—»
|
||||||
|
- [ ] «Примечания» — список, если API их прислал
|
||||||
|
- [ ] Числа с запятой, пустые — «—»
|
||||||
|
|
||||||
|
**Сознательные отличия от main:**
|
||||||
|
- Вид открывается обычным разделом `?page=weather&start=&end=` вместо хэша `#weathercheck?start=&end=`: «назад/вперёд» и ссылки работают, как у остальных разделов.
|
||||||
|
- Даты на главной сохраняются в адресе (`?start=&end=`), поэтому возвращаются и по ссылке «Разделы проекта», и кнопкой «назад» браузера (в main — только пока страницу не перезагрузили).
|
||||||
|
- Ошибки загрузки и оценки — плашкой ui-kit `Alert`, бейджи — `Badge` ui-kit; иконки облака и «искр» — локальный `InlineIcon` (в ките их пока нет).
|
||||||
|
- Пустое название проекта в списке (правка `?? ''`, которая была незакоммичена в main) у нас уже обработано в `projectRules.ts`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Объекты (stroy-bot-objects) — `?page=objects&projectId=`
|
## 2. Объекты (stroy-bot-objects) — `?page=objects&projectId=`
|
||||||
@@ -89,13 +113,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 +145,7 @@
|
|||||||
- [ ] Навигационное свойство родителя определяется через `EntityDefinitions` (fallback `cree9_parentid`)
|
- [ ] Навигационное свойство родителя определяется через `EntityDefinitions` (fallback `cree9_parentid`)
|
||||||
- [ ] Проект находится подъёмом по родителям объекта до непустого `project`
|
- [ ] Проект находится подъёмом по родителям объекта до непустого `project`
|
||||||
|
|
||||||
🐞 Путь объекта (`#headerTitle`) не выводится. 🐞 Нет проверки «начало ≤ окончание». 🐞 Отступы в списке «Родительская работа» — обычные пробелы, браузер их схлопывает (как было).
|
🐞 Путь объекта (`#headerTitle`) не выводится. 🐞 Отступы в списке «Родительская работа» — обычные пробелы, браузер их схлопывает (как было).
|
||||||
|
|
||||||
**Сознательные отличия от works:**
|
**Сознательные отличия от works:**
|
||||||
- `alert()` → сообщение `Alert`: ошибка сохранения — в модалке, ошибки удаления — над таблицей. Тексты те же.
|
- `alert()` → сообщение `Alert`: ошибка сохранения — в модалке, ошибки удаления — над таблицей. Тексты те же.
|
||||||
@@ -127,6 +153,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 выбирает первое найденное. Выбрать можно только значение из справочника, как и раньше.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -181,18 +209,25 @@
|
|||||||
|
|
||||||
### Сохранение
|
### Сохранение
|
||||||
- [ ] Валидация (все сообщения из исходника)
|
- [ ] Валидация (все сообщения из исходника)
|
||||||
- [ ] Номер = количество неудалённых отчётов проекта + 1
|
- [ ] Номер = наибольший номер в проекте (включая удалённые) + 1 — в старом коде «количество неудалённых + 1», см. 🔧 ниже
|
||||||
- [ ] При правке — удалить все старые строки (сначала дочерние), создать заново
|
- [ ] При правке — удалить все старые строки (сначала дочерние), создать заново
|
||||||
- [ ] Фото → `DocLib/{projectId}_{number}`
|
- [ ] Фото → `DocLib/{projectId}_{number}`
|
||||||
- [ ] «Отчет успешно сохранен!», кнопка меняется на «← Вернуться назад»
|
- [ ] «Отчет успешно сохранен!», кнопка меняется на «← Вернуться назад»
|
||||||
- [ ] Утверждение: подтверждение → `cree9_aprooved = true` → «Отчет утвержден!»
|
- [ ] Утверждение: подтверждение → `cree9_aprooved = true` → «Отчет утвержден!»
|
||||||
- [ ] Права: «Доступы» `projectId` + `userId*` (несколько вариантов имени поля)
|
- [ ] Права: «Доступы» `projectId` + `userId*` (несколько вариантов имени поля)
|
||||||
|
|
||||||
🐞 Create без проверки NoAccess. 🐞 Номер может совпасть после мягкого удаления. 🐞 Дата «сегодня» — UTC.
|
🐞 Create без проверки NoAccess. 🐞 Дата «сегодня» — UTC.
|
||||||
|
|
||||||
|
🔧 **Исправлено при проверке T-REP (2026-10-19):**
|
||||||
|
- Только что созданная из пикера работа попадала в карточку с полным путём вместо названия, без ед. изм. и с планом 0 (в старом коде так же).
|
||||||
|
- Окно выбора на экране шире 640px — по центру (было всегда прижато к низу), поле «Значение» не вылезает за карточку.
|
||||||
|
- После «Утвердить отчет» бейдж оставался «На утверждении» до F5 — теперь сразу «Утверждён».
|
||||||
|
- «← Вернуться назад» во вкладке, открытой из списка отчётов (истории нет), ничего не делал — теперь ведёт в «Отчёты по проекту».
|
||||||
|
- Номер отчёта повторялся после мягкого удаления (номер = количество неудалённых + 1), а вместе с ним и папка фото `{projectId}_{номер}`. Теперь номер = наибольший номер в проекте, включая удалённые, + 1.
|
||||||
|
|
||||||
**Сознательные отличия от report:**
|
**Сознательные отличия от report:**
|
||||||
- `confirm('Утвердить отчет? …')` → `confirmModal` из ui-kit, текст тот же.
|
- `confirm('Утвердить отчет? …')` → `confirmModal` из ui-kit, текст тот же.
|
||||||
- Адрес сайта для списков и ссылки на папку фото — настройка «URL сайта SharePoint» (раньше `pageContext.web.absoluteUrl`); путь папки при создании — по-прежнему от сайта, где стоит страница (`web.serverRelativeUrl`). По умолчанию это один и тот же сайт `/sites/stroy`.
|
- Адрес сайта для списков и ссылки на папку фото — настройка «URL сайта SharePoint» (раньше `pageContext.web.absoluteUrl`); путь папки при создании и id пользователя для проверки прав — тоже от этого сайта (раньше — от сайта, где стоит страница). По умолчанию это один и тот же сайт `/sites/stroy`.
|
||||||
- Если Dataverse ответит на создание записи `204` без тела, старый код брал id из заголовка `OData-EntityId`, а новый клиент заголовков не видит. С `Prefer: return=representation` Dataverse отвечает `201` с телом, поэтому на практике не встречается; при пустом id сохранение останавливается той же ошибкой `reportId is empty`.
|
- Если Dataverse ответит на создание записи `204` без тела, старый код брал id из заголовка `OData-EntityId`, а новый клиент заголовков не видит. С `Prefer: return=representation` Dataverse отвечает `201` с телом, поэтому на практике не встречается; при пустом id сохранение останавливается той же ошибкой `reportId is empty`.
|
||||||
- Логика режимов, валидации, сборки строк и порядка записи / удаления сверена со старым кодом построчно и покрыта тестами (45 тестов).
|
- Логика режимов, валидации, сборки строк и порядка записи / удаления сверена со старым кодом построчно и покрыта тестами (45 тестов).
|
||||||
|
|
||||||
@@ -241,6 +276,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 +301,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` пустой и у старых настоящих объектов — поле никто не заполняет и ни на что не влияет. Новое приложение его тоже не трогает.
|
||||||
|
|||||||
+68
-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,13 +73,25 @@
|
|||||||
- [ ] **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 (Пн–Вс) → «Еженедельный»; один день → «Ежедневный»; начало позже конца → ошибка, кнопка неактивна.
|
||||||
- [ ] **T-HUB-13** «Выгрузить» на большом проекте за неделю → скачался `weekly_…xlsx`. **Сравнить** с файлом из старого main за тот же период — содержимое одинаковое.
|
- [ ] **T-HUB-13** «Выгрузить» на большом проекте за неделю → скачался `weekly_…xlsx`. **Сравнить** с файлом из старого main за тот же период — содержимое одинаковое.
|
||||||
|
|
||||||
|
### Сверка погодных условий — `T-WX` (фича из main от 16.10)
|
||||||
|
- [ ] **T-WX-01** Без дат или начало позже конца — «Сверка погодных условий» неактивна; обе даты верные — активна. Архивный проект — неактивна вместе с карточкой.
|
||||||
|
- [ ] **T-WX-02** Проект 17, неделя с отчётами → открылась сверка: шапка с проектом и периодом, метеостанция, «Погода по дням», «Позиции…». **Сравнить** со старым main за тот же период: те же строки и числа.
|
||||||
|
- [ ] **T-WX-03** «‹ Разделы проекта» → главная, даты остались. Ещё раз открыть сверку → «назад» браузера → главная, даты остались.
|
||||||
|
- [ ] **T-WX-04** Период без отчётов → «Нет позиций за период», кнопка оценки неактивна.
|
||||||
|
- [ ] **T-WX-05** Адрес `?page=weather&projectId=51` без дат → «Период не указан…». Меню слева подсвечивает «Проект».
|
||||||
|
- [ ] ✍️ **T-WX-06** Тестовый проект 51, 19.10.2026 (там есть позиции) → «Запросить оценку ИИ…» → спиннер до минуты → в «Оценке» и «Акте простоя» появились бейджи, сверху «Оценено позиций: N». Уйти назад во время оценки → ошибок в консоли нет.
|
||||||
|
- [ ] **T-WX-07** 360px: таблицы прокручиваются внутри своей рамки, страница целиком вбок не едет.
|
||||||
|
|
||||||
|
> ✅ **2026-10-20:** T-WX-01…05 пройдены. T-WX-02 — проект 35, 13.10–20.10: станция, все 8 дней и позиции совпали со старым main. T-WX-06 проверена на проекте 35 (оценено 138 позиций). T-WX-07 — на телефоне после деплоя (workbench не сужается до 360px), вместе с T-RSP-09.
|
||||||
|
|
||||||
## 3. Ресурсы проекта — `T-RES`
|
## 3. Ресурсы проекта — `T-RES`
|
||||||
- [ ] **T-RES-01** Большой проект: вкладки «Профессии (N)», «Компании (N)» — числа и списки совпадают со старой страницей.
|
- [ ] **T-RES-01** Большой проект: вкладки «Профессии (N)», «Компании (N)» — числа и списки совпадают со старой страницей.
|
||||||
- [ ] **T-RES-02** Поиск слева, «✕» очищает; «уже добавлено» у привязанных.
|
- [ ] **T-RES-02** Поиск слева, «✕» очищает; «уже добавлено» у привязанных.
|
||||||
@@ -101,7 +114,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** Меню ⋮ → «Работы» → открылся раздел «Работы» этого объекта.
|
||||||
@@ -118,6 +131,9 @@
|
|||||||
- [ ] **T-WRK-09** Роль **Р** → нет кнопок добавления и меню ⋮.
|
- [ ] **T-WRK-09** Роль **Р** → нет кнопок добавления и меню ⋮.
|
||||||
|
|
||||||
## 7. Импорт работ — `T-IMP`
|
## 7. Импорт работ — `T-IMP`
|
||||||
|
|
||||||
|
> ⛔ **T-IMP-02…07 заблокированы (2026-10-19):** сначала `GET https://api.tnsu.kz/Project/{oid}/works` отвечал 500 `42703: column l.name does not exist` — команда API исправила. Теперь он отвечает 200, но `totalCount: 0` у всех проектов: сметы в справочник ещё не загружены. Старый web part показывает то же «Работы не найдены». Повторить, когда появится хотя бы один проект со сметой (тестовый проект 51 привязать к нему; настоящие проекты не привязывать). T-IMP-01 ✅.
|
||||||
|
> Проверено по коду: API урезает `pageSize=200` до 100, но число страниц берётся из `totalPages` ответа — работы не теряются.
|
||||||
- [ ] **T-IMP-01** Проект без привязки к справочнику → «Проект не привязан к справочнику…».
|
- [ ] **T-IMP-01** Проект без привязки к справочнику → «Проект не привязан к справочнику…».
|
||||||
- [ ] **T-IMP-02** Большой привязанный проект: «Загрузка работ… X из Y», затем число работ совпадает со старой страницей.
|
- [ ] **T-IMP-02** Большой привязанный проект: «Загрузка работ… X из Y», затем число работ совпадает со старой страницей.
|
||||||
- [ ] **T-IMP-03** Поиск (код СНБ, объём, название), страницы по 100.
|
- [ ] **T-IMP-03** Поиск (код СНБ, объём, название), страницы по 100.
|
||||||
@@ -156,10 +172,57 @@
|
|||||||
- [ ] **T-REP-18** Роль **Р** → отчёт только для чтения, кнопок нет.
|
- [ ] **T-REP-18** Роль **Р** → отчёт только для чтения, кнопок нет.
|
||||||
- [ ] **T-REP-19** Роль **Н** → «Нет доступа» при открытии существующего отчёта.
|
- [ ] **T-REP-19** Роль **Н** → «Нет доступа» при открытии существующего отчёта.
|
||||||
- [ ] **T-REP-20** Большой проект: открыть 3 разных старых отчёта → всё совпадает со старой формой (план-факт, персонал, техника, фото-папка).
|
- [ ] **T-REP-20** Большой проект: открыть 3 разных старых отчёта → всё совпадает со старой формой (план-факт, персонал, техника, фото-папка).
|
||||||
|
- Проверено на проекте 17: №2030 и ещё 2 отчёта совпали, режим правки тот же. Отчёт с персоналом/техникой **внутри работы** найти не удалось (прямого доступа к Dataverse нет) — вложенные строки проверены только на тестовом проекте (T-REP-10/11). Дополнительно: встретится такой отчёт — сверить со старой формой.
|
||||||
- [ ] **T-REP-21** «← Вернуться назад» → вернулись туда, откуда пришли.
|
- [ ] **T-REP-21** «← Вернуться назад» → вернулись туда, откуда пришли.
|
||||||
- [ ] **T-REP-22** Enter в любом поле не перезагружает страницу.
|
- [ ] **T-REP-22** Enter в любом поле не перезагружает страницу.
|
||||||
- [ ] ✍️ **T-REP-23** Удалить тестовый отчёт из «Отчёты по проекту» → «Удалить отчёт?» → пропал из списка (мягкое удаление).
|
- [ ] ✍️ **T-REP-23** Удалить тестовый отчёт из «Отчёты по проекту» → «Удалить отчёт?» → пропал из списка (мягкое удаление).
|
||||||
|
|
||||||
|
## 9a. Импорт отчёта из Excel — `T-RIM` (новая фича)
|
||||||
|
|
||||||
|
> `POST /Import/StroyBot/Reports?projectId=` **только проверяет** файл и ничего не пишет (проверено 2026-10-22: после ответа без ошибок в `cree9_reports` / `cree9_report_datas` записей нет). Отчёты пишет фронт по `ids` из ответа — тем же `saveReportWithData`, что форма «Отправить отчёт».
|
||||||
|
> Правила сборки: один отчёт = дата + компания + смена + объект (самый глубокий); каждая строка Excel — своя строка-работа; ресурс с «Часы работы» — под работу; простой / ремонт — отдельной строкой вне работы, «Комментарий (данные)» — ей. Вид работы сервер требует в каждой строке. ⚠️ Только проект **51**.
|
||||||
|
> Тестовый файл: `Downloads\ТЕСТ импорт отчета - проект 51 (v2).xlsx` (4 строки без ошибок → 2 отчёта, за 01.10 и 02.10; строка 4 — простой).
|
||||||
|
|
||||||
|
- [ ] **T-RIM-01** Меню: «Импорт отчёта из Excel» есть у роли **Е**; у **Р** (без edit0) — нет; по прямому адресу без прав — «Импорт отчётов недоступен…».
|
||||||
|
- [ ] **T-RIM-02** Выбор файла **сразу запускает проверку**: «Проверяем файл…», в F12 → Network один `POST Reports` (+ preflight). Кнопка выбора файла на время проверки неактивна.
|
||||||
|
- [ ] **T-RIM-03** Файл с ошибками → «С ошибками: N», «Исправьте ошибки в Excel и загрузите файл снова.», кнопки «Импортировать» нет.
|
||||||
|
- [ ] **T-RIM-04** Таблица: все 18 колонок файла; красные ячейки с ошибками; наведение — текст ошибки; справа «Ошибки»; фильтр «Только строки с ошибками».
|
||||||
|
- [ ] **T-RIM-05** Файл не .xlsx → «Нужен файл Excel в формате .xlsx», проверка не запускается.
|
||||||
|
- [ ] **T-RIM-06** v2 → «Ошибок нет — файл готов к импорту.», кнопка «Импортировать (отчётов: 2)» → окно «Будет создано отчётов: 2 в проекте «…»» → «Отмена» — ничего не записано.
|
||||||
|
- [ ] ✍️ **T-RIM-07** То же → «Импортировать» → «Сохраняем отчёты… 1 из 2» → «Импорт завершён. Созданы отчёты: №… за 01.10.2026, №… за 02.10.2026» и ссылка на «Отчёты по проекту»; кнопки больше нет. (Проверено 2026-10-22: №5, №6.)
|
||||||
|
- [ ] **T-RIM-08** Отчёт за 01.10 в форме: три работы «Арматура» (5, 3, 2); под первой техника «Бетонный насос» 1×11, под второй персонал «Бетонщик» 10×11; в «Технике» отдельно насос 1×3 «Часы простоя». За 02.10: «Арматура» 4, под ней «Бетонщик» 5×11.
|
||||||
|
|
||||||
|
## 9b. Импорт работ из Excel — `T-WIM` (новая фича)
|
||||||
|
|
||||||
|
> `POST /Import/StroyBot/Works?projectId=` **только проверяет** файл (как и импорт отчётов). Объекты должны уже быть в проекте — иначе ошибка «… не найден внутри …». В `ids` — id самого глубокого объекта и единицы; id работы сервер не возвращает.
|
||||||
|
> Пишет фронт теми же `createWork` / `updateWork`, что окно работы. Работа с тем же названием на объекте (без учёта регистра и пробелов, на любом уровне иерархии) — **обновляются только объём и единица**; иначе — создаётся работа верхнего уровня. Две одноимённые на объекте или одна работа дважды в файле — ошибка, ничего не пишется. ⚠️ Только проект **51**.
|
||||||
|
> Тестовый файл: `Downloads\ТЕСТ импорт работ - проект 51.xlsx` (Тестовый объект / Бетонирование: «Арматура» 120 м — есть, «Тестовая работа импорта» 50 м3 — новая).
|
||||||
|
|
||||||
|
- [ ] **T-WIM-01** Меню: «Импорт работ из Excel» после «Загрузить работы из сметы»; есть у роли **Е**, у **Р** (без edit0) — нет; в архивном проекте — нет; в завершённом — есть.
|
||||||
|
- [ ] **T-WIM-02** Файл с несуществующим объектом → «С ошибками: 1», «Объект 2: «…» не найден внутри «…»», кнопки «Импортировать» нет.
|
||||||
|
- [ ] **T-WIM-03** Тестовый файл → «Сверяем с работами объектов…» → «Будет создано работ: 1, обновлён … у существующих: 1», кнопка «Импортировать (работ: 2)».
|
||||||
|
- [ ] **T-WIM-03a** У работ, где план уже есть, — таблица «Сейчас → Станет»: изменившийся объём подсвечен со стрелкой ▲/▼ (например, «Арматура» 120 м → 150 м ▲), совпадающий — серым «без изменений». План **заменяется** значением из файла, не складывается. В окне подтверждения — «…плановый объём будет заменён значением из файла». (Проверено 2026-10-23.)
|
||||||
|
- [ ] ✍️ **T-WIM-04** «Импортировать» → окно «… будет создано работ: 1, обновлено: 1» → «Сохраняем работы…» → «Импорт завершён: создано работ — 1, обновлено — 1» и ссылка «Открыть «Объекты»». (Проверено 2026-10-22.)
|
||||||
|
- [ ] **T-WIM-05** «Объекты» → «Бетонирование» → «Работы»: «Арматура» — 120 м, родитель «Бетон B25» и даты не изменились; «Тестовая работа импорта» — 50 м3, верхний уровень.
|
||||||
|
- [ ] ✍️ **T-WIM-06** Тот же файл ещё раз → «создано: 0, обновлено: 2» — работы не задваиваются. (Проверено 2026-10-22.)
|
||||||
|
- [ ] **T-WIM-07** Одна работа объекта дважды в файле → «Строки … и …: работа «…» на одном объекте указана дважды», кнопки нет.
|
||||||
|
|
||||||
|
## 10. Адаптивность — `T-RSP`
|
||||||
|
|
||||||
|
> Как: F12 → кнопка «телефон/планшет» (Ctrl+Shift+M) → сверху выбрать ширину → F5. Только просмотр, ничего не сохранять.
|
||||||
|
> Ширины: **360** (обычный телефон), **768** (планшет), **1280** (ноутбук), плюс твой монитор как есть. На каждой ширине — признаки бага:
|
||||||
|
> горизонтальная прокрутка всей страницы, текст/кнопка вылезает за край, элементы налезают друг на друга, что-то нельзя нажать.
|
||||||
|
|
||||||
|
- [ ] **T-RSP-01** 360: меню скрыто, кнопка ☰ открывает его поверх, выбор раздела закрывает меню.
|
||||||
|
- [ ] **T-RSP-02** 360: главная — выбор проекта, даты в столбик, карточки в одну колонку.
|
||||||
|
- [ ] **T-RSP-03** 360: «Отчёты по проекту» — фильтр дат над поиском, карточки в одну колонку, кнопки копирования/удаления нажимаются.
|
||||||
|
- [ ] **T-RSP-04** 360: форма отчёта — поля во всю ширину, «Кол-во/Часы» друг под другом, пикер — шторка снизу, список прокручивается, «Сохранить» видна.
|
||||||
|
- [ ] **T-RSP-05** 360: «Объекты» и «Работы» — дерево не уезжает вправо, длинные названия переносятся/обрезаются, меню ⋮ не уходит за экран.
|
||||||
|
- [ ] **T-RSP-06** 360: «Ресурсы», «Привязка», «Импорт» — колонки складываются, модалки помещаются в экран.
|
||||||
|
- [ ] **T-RSP-07** 768: все разделы — то же беглым просмотром; пикер отчёта — окно по центру.
|
||||||
|
- [ ] **T-RSP-08** 1280 и свой монитор: меню слева, ничего не растянуто на всю ширину неудобно, форма отчёта — узкая колонка.
|
||||||
|
- [ ] **T-RSP-09** После деплоя: открыть страницу на **настоящем телефоне** (браузер и приложение SharePoint) — меню, форма отчёта, пикер (сохранять только на тестовом проекте 51).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Как сообщить о баге
|
## Как сообщить о баге
|
||||||
|
|||||||
Generated
+4
-4
@@ -17,7 +17,7 @@
|
|||||||
"@microsoft/sp-office-ui-fabric-core": "1.22.1",
|
"@microsoft/sp-office-ui-fabric-core": "1.22.1",
|
||||||
"@microsoft/sp-property-pane": "1.22.1",
|
"@microsoft/sp-property-pane": "1.22.1",
|
||||||
"@microsoft/sp-webpart-base": "1.22.1",
|
"@microsoft/sp-webpart-base": "1.22.1",
|
||||||
"@tnsu/ui-kit": "^0.12.0",
|
"@tnsu/ui-kit": "^0.14.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"tslib": "2.3.1"
|
"tslib": "2.3.1"
|
||||||
@@ -6659,9 +6659,9 @@
|
|||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/@tnsu/ui-kit": {
|
"node_modules/@tnsu/ui-kit": {
|
||||||
"version": "0.12.0",
|
"version": "0.14.0",
|
||||||
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.12.0/ui-kit-0.12.0.tgz",
|
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.14.0/ui-kit-0.14.0.tgz",
|
||||||
"integrity": "sha512-lJYf2DoYaydrktbSotRNY0umeJlQw8Ekhh6fu1FGB0p+/TXTozpBfAs+TxK/mvOXlS9yYenEuGBi85fd/dRF2A==",
|
"integrity": "sha512-Vun9AUM9jL8rttbsbKo5Yh6TUz1srkNqxiO6mmzebrSo1JOASNJqNjJPMTS+8Jt3MTrJPklvh0TbeqGQzw7qxQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@fluentui/react": "^8.106.4",
|
"@fluentui/react": "^8.106.4",
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@
|
|||||||
"@microsoft/sp-office-ui-fabric-core": "1.22.1",
|
"@microsoft/sp-office-ui-fabric-core": "1.22.1",
|
||||||
"@microsoft/sp-property-pane": "1.22.1",
|
"@microsoft/sp-property-pane": "1.22.1",
|
||||||
"@microsoft/sp-webpart-base": "1.22.1",
|
"@microsoft/sp-webpart-base": "1.22.1",
|
||||||
"@tnsu/ui-kit": "^0.12.0",
|
"@tnsu/ui-kit": "^0.14.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"tslib": "2.3.1"
|
"tslib": "2.3.1"
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const AppShell: React.FC = () => {
|
|||||||
onToggleCollapsed={() => setIsCollapsed((prev) => !prev)}
|
onToggleCollapsed={() => setIsCollapsed((prev) => !prev)}
|
||||||
mobileOpen={isMobileMenuOpen}
|
mobileOpen={isMobileMenuOpen}
|
||||||
onMobileClose={() => setIsMobileMenuOpen(false)}
|
onMobileClose={() => setIsMobileMenuOpen(false)}
|
||||||
|
dividers
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
mobileToggle={<SidebarToggle onClick={() => setIsMobileMenuOpen(true)} />}
|
mobileToggle={<SidebarToggle onClick={() => setIsMobileMenuOpen(true)} />}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { ReportPage } from '../pages/report/ReportPage';
|
|||||||
import { ImportWorksPage } from '../pages/import/ImportWorksPage';
|
import { ImportWorksPage } from '../pages/import/ImportWorksPage';
|
||||||
import { ResourcesPage } from '../pages/resources/ResourcesPage';
|
import { ResourcesPage } from '../pages/resources/ResourcesPage';
|
||||||
import { BinderPage } from '../pages/binder/BinderPage';
|
import { BinderPage } from '../pages/binder/BinderPage';
|
||||||
|
import { WeatherPage } from '../pages/weather/WeatherPage';
|
||||||
|
import { ReportImportPage } from '../pages/reportImport/ReportImportPage';
|
||||||
|
import { WorksImportPage } from '../pages/worksImport/WorksImportPage';
|
||||||
|
|
||||||
/** Показывает страницу текущего раздела (раздел берётся из ?page=). */
|
/** Показывает страницу текущего раздела (раздел берётся из ?page=). */
|
||||||
export const PageOutlet: React.FC = () => {
|
export const PageOutlet: React.FC = () => {
|
||||||
@@ -28,6 +31,12 @@ export const PageOutlet: React.FC = () => {
|
|||||||
return <ResourcesPage />;
|
return <ResourcesPage />;
|
||||||
case 'binder':
|
case 'binder':
|
||||||
return <BinderPage />;
|
return <BinderPage />;
|
||||||
|
case 'weather':
|
||||||
|
return <WeatherPage />;
|
||||||
|
case 'reportImport':
|
||||||
|
return <ReportImportPage />;
|
||||||
|
case 'worksImport':
|
||||||
|
return <WorksImportPage />;
|
||||||
case 'hub':
|
case 'hub':
|
||||||
default:
|
default:
|
||||||
return <HubPage />;
|
return <HubPage />;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { buildSidebarItems, navPageFor } from './navigation';
|
import { buildSidebarItems, CATALOGS_GROUP, navPageFor } from './navigation';
|
||||||
|
|
||||||
describe('buildSidebarItems', () => {
|
describe('buildSidebarItems', () => {
|
||||||
it('без проекта в меню только главная', () => {
|
it('без проекта в меню только главная и справочники', () => {
|
||||||
expect(buildSidebarItems(undefined).map((item) => item.id)).toEqual(['hub']);
|
expect(buildSidebarItems(undefined).map((item) => item.id)).toEqual(['hub', 'catalogs']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('с проектом — все разделы', () => {
|
it('с проектом — все разделы', () => {
|
||||||
@@ -11,15 +11,34 @@ describe('buildSidebarItems', () => {
|
|||||||
'objects',
|
'objects',
|
||||||
'reports',
|
'reports',
|
||||||
'report',
|
'report',
|
||||||
|
'reportImport',
|
||||||
'import',
|
'import',
|
||||||
|
'worksImport',
|
||||||
'resources',
|
'resources',
|
||||||
'binder'
|
'binder',
|
||||||
|
'catalogs'
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('недоступные разделы скрываются, главная остаётся всегда', () => {
|
it('недоступные разделы скрываются, главная и справочники остаются всегда', () => {
|
||||||
const onlyReports = (page: string): boolean => page === 'reports';
|
const onlyReports = (page: string): boolean => page === 'reports';
|
||||||
expect(buildSidebarItems('5', onlyReports).map((item) => item.id)).toEqual(['hub', 'reports']);
|
expect(buildSidebarItems('5', onlyReports).map((item) => item.id)).toEqual(['hub', 'reports', 'catalogs']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('справочники — группа из 8 ссылок на сайт StroyBot', () => {
|
||||||
|
const links = CATALOGS_GROUP.children || [];
|
||||||
|
expect(links.map((link) => link.title)).toEqual([
|
||||||
|
'Проекты',
|
||||||
|
'Компании',
|
||||||
|
'Профессии',
|
||||||
|
'Единицы измерения',
|
||||||
|
'Техника',
|
||||||
|
'Архив',
|
||||||
|
'Инструкция',
|
||||||
|
'Корзина'
|
||||||
|
]);
|
||||||
|
links.forEach((link) => expect(link.href).toMatch(/^https:\/\/tnsukz\.sharepoint\.com\/sites\/stroy\//));
|
||||||
|
expect(links[2].href).toEqual('https://tnsukz.sharepoint.com/sites/stroy/Lists/List5/AllItems.aspx');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -28,6 +47,10 @@ describe('navPageFor', () => {
|
|||||||
expect(navPageFor('works')).toEqual('objects');
|
expect(navPageFor('works')).toEqual('objects');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('сверка погоды подсвечивает главную', () => {
|
||||||
|
expect(navPageFor('weather')).toEqual('hub');
|
||||||
|
});
|
||||||
|
|
||||||
it('остальные разделы подсвечивают сами себя', () => {
|
it('остальные разделы подсвечивают сами себя', () => {
|
||||||
expect(navPageFor('reports')).toEqual('reports');
|
expect(navPageFor('reports')).toEqual('reports');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { IconName, ISidebarItem } from '@tnsu/ui-kit';
|
import { IconName, ISidebarItem } from '@tnsu/ui-kit';
|
||||||
import { PageId } from './routing/routes';
|
import { PageId } from './routing/routes';
|
||||||
|
import { CATALOG_LINKS } from '../config/catalogLinks';
|
||||||
|
|
||||||
export interface INavItem {
|
export interface INavItem {
|
||||||
page: PageId;
|
page: PageId;
|
||||||
@@ -15,25 +16,44 @@ export const NAV_ITEMS: INavItem[] = [
|
|||||||
{ page: 'objects', title: 'Объекты', icon: 'folder', needsProject: true },
|
{ page: 'objects', title: 'Объекты', icon: 'folder', needsProject: true },
|
||||||
{ page: 'reports', title: 'Отчёты по проекту', icon: 'clipboard', needsProject: true },
|
{ page: 'reports', title: 'Отчёты по проекту', icon: 'clipboard', needsProject: true },
|
||||||
{ page: 'report', title: 'Отправить отчёт', icon: 'send', needsProject: true },
|
{ page: 'report', title: 'Отправить отчёт', icon: 'send', needsProject: true },
|
||||||
|
{ page: 'reportImport', title: 'Импорт отчёта из Excel', icon: 'upload', needsProject: true },
|
||||||
{ page: 'import', title: 'Загрузить работы из сметы', icon: 'download', needsProject: true },
|
{ page: 'import', title: 'Загрузить работы из сметы', icon: 'download', needsProject: true },
|
||||||
|
{ page: 'worksImport', title: 'Импорт работ из Excel', icon: 'document', needsProject: true },
|
||||||
{ page: 'resources', title: 'Ресурсы проекта', icon: 'people', needsProject: true },
|
{ page: 'resources', title: 'Ресурсы проекта', icon: 'people', needsProject: true },
|
||||||
{ page: 'binder', title: 'Привязка к справочнику', icon: 'swap', needsProject: true }
|
{ page: 'binder', title: 'Привязка к справочнику', icon: 'swap', needsProject: true }
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Пункт меню, который подсвечивается для раздела. Работы — подраздел объектов, форма отчёта — свой пункт. */
|
/**
|
||||||
|
* Пункт меню, который подсвечивается для раздела. Работы — подраздел объектов,
|
||||||
|
* сверка погоды открывается с главной, форма отчёта — свой пункт.
|
||||||
|
*/
|
||||||
export function navPageFor(page: PageId): PageId {
|
export function navPageFor(page: PageId): PageId {
|
||||||
return page === 'works' ? 'objects' : page;
|
if (page === 'works') return 'objects';
|
||||||
|
if (page === 'weather') return 'hub';
|
||||||
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Пункты меню для текущего состояния. Без проекта — только главная.
|
* «Справочники» — раскрывающаяся группа ссылок на списки сайта (новая вкладка).
|
||||||
|
* Разделом приложения не является: onChange меню её не получает. Права проверяет SharePoint.
|
||||||
|
*/
|
||||||
|
export const CATALOGS_GROUP: ISidebarItem = {
|
||||||
|
id: 'catalogs',
|
||||||
|
title: 'Справочники',
|
||||||
|
icon: 'info',
|
||||||
|
children: CATALOG_LINKS.map((link) => ({ id: link.id, title: link.title, href: link.url }))
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пункты меню для текущего состояния. Без проекта — только главная и справочники.
|
||||||
* isAvailable — правила доступа к разделу (статус проекта, права); по умолчанию всё доступно.
|
* isAvailable — правила доступа к разделу (статус проекта, права); по умолчанию всё доступно.
|
||||||
*/
|
*/
|
||||||
export function buildSidebarItems(
|
export function buildSidebarItems(
|
||||||
projectId: string | undefined,
|
projectId: string | undefined,
|
||||||
isAvailable: (page: PageId) => boolean = () => true
|
isAvailable: (page: PageId) => boolean = () => true
|
||||||
): ISidebarItem[] {
|
): ISidebarItem[] {
|
||||||
return NAV_ITEMS.filter((item) => !item.needsProject || (projectId !== undefined && isAvailable(item.page))).map(
|
const sections = NAV_ITEMS.filter(
|
||||||
(item) => ({ id: item.page, title: item.title, icon: item.icon })
|
(item) => !item.needsProject || (projectId !== undefined && isAvailable(item.page))
|
||||||
);
|
).map((item) => ({ id: item.page, title: item.title, icon: item.icon }));
|
||||||
|
return [...sections, CATALOGS_GROUP];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 'архивный' });
|
||||||
@@ -99,4 +119,17 @@ describe('isSectionAvailable', () => {
|
|||||||
expect(isSectionAvailable('report', current)).toBe(true);
|
expect(isSectionAvailable('report', current)).toBe(true);
|
||||||
expect(isSectionAvailable('binder', current)).toBe(true);
|
expect(isSectionAvailable('binder', current)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('импорт отчёта из Excel — по тем же правилам, что «Отправить отчёт»', () => {
|
||||||
|
expect(isSectionAvailable('reportImport', project({ status: 'текущий', canEdit: true }))).toBe(true);
|
||||||
|
expect(isSectionAvailable('reportImport', project({ status: 'текущий', canEdit: false }))).toBe(false);
|
||||||
|
expect(isSectionAvailable('reportImport', project({ status: 'завершён', canEdit: true }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('импорт работ из Excel — только с правом edit0, архив закрыт', () => {
|
||||||
|
expect(isSectionAvailable('worksImport', project({ status: 'текущий', canEdit: true }))).toBe(true);
|
||||||
|
expect(isSectionAvailable('worksImport', project({ status: 'завершён', canEdit: true }))).toBe(true);
|
||||||
|
expect(isSectionAvailable('worksImport', project({ status: 'текущий', canEdit: false }))).toBe(false);
|
||||||
|
expect(isSectionAvailable('worksImport', project({ status: 'архивный', canEdit: true }))).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,18 +73,37 @@ export function getStatusKind(status: string | undefined): ProjectStatusKind {
|
|||||||
* - архивный проект — закрыто всё (кроме архива, он не раздел приложения);
|
* - архивный проект — закрыто всё (кроме архива, он не раздел приложения);
|
||||||
* - завершён — нельзя «Отправить отчёт»;
|
* - завершён — нельзя «Отправить отчёт»;
|
||||||
* - нет права edit0 — нельзя «Отправить отчёт».
|
* - нет права edit0 — нельзя «Отправить отчёт».
|
||||||
|
* Импорт отчёта из Excel создаёт отчёты — те же правила, что у «Отправить отчёт».
|
||||||
* Привязка к справочнику — только с правом редактирования (решение при объединении).
|
* Привязка к справочнику — только с правом редактирования (решение при объединении).
|
||||||
|
* Импорт работ из Excel меняет работы проекта — тоже только с правом редактирования.
|
||||||
*/
|
*/
|
||||||
export function isSectionAvailable(page: PageId, project: IAccessibleProject): boolean {
|
export function isSectionAvailable(page: PageId, project: IAccessibleProject): boolean {
|
||||||
if (page === 'hub') return true;
|
if (page === 'hub') return true;
|
||||||
|
|
||||||
const kind = getStatusKind(project.status);
|
const kind = getStatusKind(project.status);
|
||||||
if (kind === 'archive') return false;
|
if (kind === 'archive') return false;
|
||||||
if (page === 'report') return kind !== 'done' && project.canEdit;
|
if (page === 'report' || page === 'reportImport') return kind !== 'done' && project.canEdit;
|
||||||
if (page === 'binder') return project.canEdit;
|
if (page === 'binder' || page === 'worksImport') return project.canEdit;
|
||||||
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';
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { buildSearch, IRoute, parseRoute } from './routes';
|
import { buildSearch, IRoute, parseRoute } from './routes';
|
||||||
|
|
||||||
|
interface INavigateOptions {
|
||||||
|
/** Заменить текущую запись истории (replaceState), а не добавить новую. */
|
||||||
|
replace?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface IRouterValue {
|
interface IRouterValue {
|
||||||
route: IRoute;
|
route: IRoute;
|
||||||
/** Перейти в раздел: меняет адрес (history.pushState) и перерисовывает приложение. */
|
/** Перейти в раздел: меняет адрес (history.pushState) и перерисовывает приложение. */
|
||||||
navigate: (route: IRoute) => void;
|
navigate: (route: IRoute, options?: INavigateOptions) => void;
|
||||||
/** Полный адрес раздела — для настоящих ссылок (Ctrl+клик, «открыть в новой вкладке»). */
|
/** Полный адрес раздела — для настоящих ссылок (Ctrl+клик, «открыть в новой вкладке»). */
|
||||||
buildHref: (route: IRoute) => string;
|
buildHref: (route: IRoute) => string;
|
||||||
}
|
}
|
||||||
@@ -29,8 +34,12 @@ export const RouterProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
|||||||
return () => window.removeEventListener('popstate', onPopState);
|
return () => window.removeEventListener('popstate', onPopState);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const navigate = React.useCallback((next: IRoute): void => {
|
const navigate = React.useCallback((next: IRoute, options: INavigateOptions = {}): void => {
|
||||||
window.history.pushState(undefined, '', buildHref(next));
|
if (options.replace) {
|
||||||
|
window.history.replaceState(undefined, '', buildHref(next));
|
||||||
|
} else {
|
||||||
|
window.history.pushState(undefined, '', buildHref(next));
|
||||||
|
}
|
||||||
setRoute(next);
|
setRoute(next);
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -17,4 +17,20 @@ describe('parseRoute', () => {
|
|||||||
it('адрес только с projectId=7', () => {
|
it('адрес только с projectId=7', () => {
|
||||||
expect(parseRoute('?projectId=7')).toEqual({ page: 'hub', projectId: '7' });
|
expect(parseRoute('?projectId=7')).toEqual({ page: 'hub', projectId: '7' });
|
||||||
});
|
});
|
||||||
|
it('сверка погоды с периодом', () => {
|
||||||
|
expect(parseRoute('?page=weather&projectId=7&start=2026-10-01&end=2026-10-07')).toEqual({
|
||||||
|
page: 'weather',
|
||||||
|
projectId: '7',
|
||||||
|
start: '2026-10-01',
|
||||||
|
end: '2026-10-07'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSearch', () => {
|
||||||
|
it('параметры прошлого маршрута не переносятся, посторонние параметры страницы остаются', () => {
|
||||||
|
expect(buildSearch({ page: 'weather', projectId: '7', start: '2026-10-01' }, '?debug=true&reportId=r1')).toEqual(
|
||||||
|
'?debug=true&page=weather&projectId=7&start=2026-10-01'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,19 @@
|
|||||||
// SharePoint (?page=objects&projectId=5), поэтому ссылку на любой раздел можно
|
// SharePoint (?page=objects&projectId=5), поэтому ссылку на любой раздел можно
|
||||||
// скопировать, открыть в новой вкладке, а «назад» в браузере работает как раньше.
|
// скопировать, открыть в новой вкладке, а «назад» в браузере работает как раньше.
|
||||||
|
|
||||||
export const PAGE_IDS = ['hub', 'objects', 'works', 'reports', 'report', 'import', 'resources', 'binder'] as const;
|
export const PAGE_IDS = [
|
||||||
|
'hub',
|
||||||
|
'objects',
|
||||||
|
'works',
|
||||||
|
'reports',
|
||||||
|
'report',
|
||||||
|
'import',
|
||||||
|
'resources',
|
||||||
|
'binder',
|
||||||
|
'weather',
|
||||||
|
'reportImport',
|
||||||
|
'worksImport'
|
||||||
|
] as const;
|
||||||
|
|
||||||
export type PageId = (typeof PAGE_IDS)[number];
|
export type PageId = (typeof PAGE_IDS)[number];
|
||||||
|
|
||||||
@@ -16,6 +28,10 @@ export interface IRoute {
|
|||||||
reportId?: string;
|
reportId?: string;
|
||||||
/** Форма отчёта: создать на основе существующего (GUID Dataverse). */
|
/** Форма отчёта: создать на основе существующего (GUID Dataverse). */
|
||||||
copyFromId?: string;
|
copyFromId?: string;
|
||||||
|
/** Главная и сверка погоды: начало периода (YYYY-MM-DD). */
|
||||||
|
start?: string;
|
||||||
|
/** Главная и сверка погоды: конец периода (YYYY-MM-DD). */
|
||||||
|
end?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ROUTE_PARAMS = {
|
export const ROUTE_PARAMS = {
|
||||||
@@ -23,10 +39,19 @@ export const ROUTE_PARAMS = {
|
|||||||
projectId: 'projectId',
|
projectId: 'projectId',
|
||||||
objectId: 'objectId',
|
objectId: 'objectId',
|
||||||
reportId: 'reportId',
|
reportId: 'reportId',
|
||||||
copyFromId: 'copyFromId'
|
copyFromId: 'copyFromId',
|
||||||
|
start: 'start',
|
||||||
|
end: 'end'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const ROUTE_VALUE_KEYS: Array<Exclude<keyof IRoute, 'page'>> = ['projectId', 'objectId', 'reportId', 'copyFromId'];
|
const ROUTE_VALUE_KEYS: Array<Exclude<keyof IRoute, 'page'>> = [
|
||||||
|
'projectId',
|
||||||
|
'objectId',
|
||||||
|
'reportId',
|
||||||
|
'copyFromId',
|
||||||
|
'start',
|
||||||
|
'end'
|
||||||
|
];
|
||||||
|
|
||||||
function isPageId(value: string | undefined): value is PageId {
|
function isPageId(value: string | undefined): value is PageId {
|
||||||
return value !== undefined && (PAGE_IDS as readonly string[]).indexOf(value) !== -1;
|
return value !== undefined && (PAGE_IDS as readonly string[]).indexOf(value) !== -1;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { SITE_URL } from './constants';
|
||||||
|
|
||||||
|
// Ссылки группы «Справочники» в боковом меню: списки и страницы сайта StroyBot.
|
||||||
|
// Открываются в новой вкладке. Адреса списков — по внутренним именам (List, List2…), а не по названиям.
|
||||||
|
|
||||||
|
export interface ICatalogLink {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CATALOG_LINKS: ICatalogLink[] = [
|
||||||
|
{ id: 'catalog-projects', title: 'Проекты', url: `${SITE_URL}/Lists/List/AllItems.aspx` },
|
||||||
|
{ id: 'catalog-companies', title: 'Компании', url: `${SITE_URL}/Lists/List2/AllItems.aspx` },
|
||||||
|
{ id: 'catalog-professions', title: 'Профессии', url: `${SITE_URL}/Lists/List5/AllItems.aspx` },
|
||||||
|
{ id: 'catalog-units', title: 'Единицы измерения', url: `${SITE_URL}/Lists/List3/AllItems.aspx` },
|
||||||
|
{ id: 'catalog-technics', title: 'Техника', url: `${SITE_URL}/Lists/List4/AllItems.aspx` },
|
||||||
|
{
|
||||||
|
id: 'catalog-archive',
|
||||||
|
title: 'Архив',
|
||||||
|
url:
|
||||||
|
`${SITE_URL}/Shared%20Documents/Forms/AllItems.aspx` +
|
||||||
|
'?id=%2Fsites%2Fstroy%2FShared%20Documents%2F%D0%90%D1%80%D1%85%D0%B8%D0%B2'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'catalog-manual',
|
||||||
|
title: 'Инструкция',
|
||||||
|
url: `${SITE_URL}/_layouts/15/Doc.aspx?sourcedoc=%7BC380799B-FF81-4D34-BB61-B4F05B6C43A5%7D&file=stroybot_manual.docx&action=default`
|
||||||
|
},
|
||||||
|
{ id: 'catalog-recycle-bin', title: 'Корзина', url: `${SITE_URL}/_layouts/15/RecycleBin.aspx?view=5` }
|
||||||
|
];
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { IExcelImportCheck } from './excelImportTypes';
|
||||||
|
import { ExcelCheckSummary } from './ExcelCheckSummary';
|
||||||
|
import { ExcelCheckTable } from './ExcelCheckTable';
|
||||||
|
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ExcelImport.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
check: IExcelImportCheck;
|
||||||
|
/** Импорт — сразу под итогом проверки, чтобы кнопка не терялась под длинной таблицей. */
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Результат проверки файла: итог, импорт, таблица строк и примечания сервера. */
|
||||||
|
export const ExcelCheckResult: React.FC<Props> = ({ check, actions }) => {
|
||||||
|
const notes = check.notes || [];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ExcelCheckSummary check={check} />
|
||||||
|
{actions}
|
||||||
|
<ExcelCheckTable key={check.file_name} check={check} />
|
||||||
|
{notes.length > 0 && (
|
||||||
|
<section className={styles.notes}>
|
||||||
|
<h3 className={styles.sectionLabel}>{EXCEL_IMPORT_TEXTS.notesTitle}</h3>
|
||||||
|
<ul>
|
||||||
|
{notes.map((note, index) => (
|
||||||
|
<li key={`${index}-${note}`}>{note}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { issuesByColumn } from './excelImportRules';
|
||||||
|
import { IExcelImportRow } from './excelImportTypes';
|
||||||
|
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ExcelImport.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
row: IExcelImportRow;
|
||||||
|
columns: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строка файла: ячейки с ошибкой подсвечены (сообщение — во всплывающей подсказке), справа — список ошибок. */
|
||||||
|
export const ExcelCheckRow: React.FC<Props> = ({ row, columns }) => {
|
||||||
|
const issues = issuesByColumn(row);
|
||||||
|
return (
|
||||||
|
<tr className={row.valid ? undefined : styles.invalidRow}>
|
||||||
|
<td className={styles.rowNumber}>{row.row}</td>
|
||||||
|
{columns.map((column) => {
|
||||||
|
const messages = issues.get(column);
|
||||||
|
const value = row.values?.[column];
|
||||||
|
return (
|
||||||
|
<td key={column} className={messages ? styles.invalidCell : undefined} title={messages?.join('\n')}>
|
||||||
|
{value || <span className={styles.muted}>{EXCEL_IMPORT_TEXTS.empty}</span>}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td className={styles.issues}>
|
||||||
|
{(row.issues || []).map((issue, index) => (
|
||||||
|
<div key={`${index}-${issue.column}`}>
|
||||||
|
<strong>{issue.column}:</strong> {issue.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Alert, Badge } from '@tnsu/ui-kit';
|
||||||
|
import { canImportFile } from './excelImportRules';
|
||||||
|
import { IExcelImportCheck } from './excelImportTypes';
|
||||||
|
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ExcelImport.module.scss';
|
||||||
|
|
||||||
|
const TEXTS = EXCEL_IMPORT_TEXTS.summary;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
check: IExcelImportCheck;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Итог проверки: имя файла, счётчики строк и что делать дальше. */
|
||||||
|
export const ExcelCheckSummary: React.FC<Props> = ({ check }) => {
|
||||||
|
const isEmpty = check.total_rows === 0;
|
||||||
|
return (
|
||||||
|
<div className={styles.summary}>
|
||||||
|
<div className={styles.summaryRow}>
|
||||||
|
<span className={styles.fileName}>{TEXTS.file(check.file_name)}</span>
|
||||||
|
<Badge variant="neutral">{TEXTS.total(check.total_rows)}</Badge>
|
||||||
|
<Badge variant="success">{TEXTS.valid(check.valid_rows)}</Badge>
|
||||||
|
{check.invalid_rows > 0 && <Badge variant="danger">{TEXTS.invalid(check.invalid_rows)}</Badge>}
|
||||||
|
</div>
|
||||||
|
{isEmpty && <Alert variant="warn">{EXCEL_IMPORT_TEXTS.emptyFile}</Alert>}
|
||||||
|
{!isEmpty && canImportFile(check) && <Alert variant="ok">{EXCEL_IMPORT_TEXTS.readyToImport}</Alert>}
|
||||||
|
{check.invalid_rows > 0 && <Alert variant="warn">{EXCEL_IMPORT_TEXTS.fixInExcel}</Alert>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Checkbox } from '@tnsu/ui-kit';
|
||||||
|
import { columnsOf, visibleRows } from './excelImportRules';
|
||||||
|
import { IExcelImportCheck } from './excelImportTypes';
|
||||||
|
import { ExcelCheckRow } from './ExcelCheckRow';
|
||||||
|
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ExcelImport.module.scss';
|
||||||
|
|
||||||
|
const COLUMNS = EXCEL_IMPORT_TEXTS.columns;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
check: IExcelImportCheck;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Таблица строк файла с фильтром «Только строки с ошибками». */
|
||||||
|
export const ExcelCheckTable: React.FC<Props> = ({ check }) => {
|
||||||
|
const [onlyInvalid, setOnlyInvalid] = React.useState<boolean>(false);
|
||||||
|
const columns = columnsOf(check);
|
||||||
|
const rows = visibleRows(check, onlyInvalid);
|
||||||
|
|
||||||
|
if (check.rows.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tableSection}>
|
||||||
|
{check.invalid_rows > 0 && (
|
||||||
|
<Checkbox label={EXCEL_IMPORT_TEXTS.onlyInvalid} checked={onlyInvalid} onChange={setOnlyInvalid} />
|
||||||
|
)}
|
||||||
|
<div className={styles.tableWrap}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{COLUMNS.row}</th>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<th key={column}>{column}</th>
|
||||||
|
))}
|
||||||
|
<th>{COLUMNS.issues}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<ExcelCheckRow key={row.row} row={row} columns={columns} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Button, Icon } from '@tnsu/ui-kit';
|
||||||
|
import { EXCEL_ACCEPT } from './excelImportRules';
|
||||||
|
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ExcelImport.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedFileName: string | undefined;
|
||||||
|
disabled: boolean;
|
||||||
|
onSelect: (file: File) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбор Excel-файла — только выбор, без отправки. Скрытый input сбрасывается,
|
||||||
|
* чтобы тот же файл можно было выбрать повторно.
|
||||||
|
*/
|
||||||
|
export const ExcelFileField: React.FC<Props> = ({ selectedFileName, disabled, onSelect }) => {
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const onChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = '';
|
||||||
|
if (file) onSelect(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.fileField}>
|
||||||
|
<input ref={inputRef} type="file" accept={EXCEL_ACCEPT} className={styles.hiddenInput} onChange={onChange} />
|
||||||
|
<Button variant="secondary" onClick={() => inputRef.current?.click()} disabled={disabled}>
|
||||||
|
<span className={styles.buttonContent}>
|
||||||
|
<Icon name="upload" size="sm" />
|
||||||
|
{selectedFileName ? EXCEL_IMPORT_TEXTS.chooseAnotherFile : EXCEL_IMPORT_TEXTS.chooseFile}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
{selectedFileName && (
|
||||||
|
<span className={styles.fileName}>{EXCEL_IMPORT_TEXTS.selectedFile(selectedFileName)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// В файле может быть много колонок — таблица прокручивается внутри рамки, а не растягивает страницу.
|
||||||
|
$issues-min-width: 260px;
|
||||||
|
$cell-max-width: 280px;
|
||||||
|
|
||||||
|
.fileField {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiddenInput {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonContent {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryRow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileName {
|
||||||
|
margin-right: $sp-2;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-900;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-md;
|
||||||
|
box-shadow: $sh-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: $sp-2 $sp-3;
|
||||||
|
border-bottom: 1px solid $gray-200;
|
||||||
|
background: $gray-50;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
max-width: $cell-max-width;
|
||||||
|
padding: $sp-2 $sp-3;
|
||||||
|
border-bottom: 1px solid $gray-100;
|
||||||
|
vertical-align: top;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowNumber {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalidRow .rowNumber {
|
||||||
|
border-left: 3px solid $red-500;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalidCell {
|
||||||
|
background: $red-50;
|
||||||
|
box-shadow: inset 0 0 0 1px $red-500;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issues {
|
||||||
|
min-width: $issues-min-width;
|
||||||
|
font-size: $fs-label;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: $red-500;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-700;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: $sp-2 0 0;
|
||||||
|
padding-left: $sp-4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionLabel {
|
||||||
|
margin: 0;
|
||||||
|
font-family: $font-serif;
|
||||||
|
font-size: $fs-section-title;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-900;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Alert, Spinner } from '@tnsu/ui-kit';
|
||||||
|
import { canImportFile, isExcelFileName } from './excelImportRules';
|
||||||
|
import { IExcelImportCheck } from './excelImportTypes';
|
||||||
|
import { CheckUrlBuilder, useExcelFileCheck } from './useExcelFileCheck';
|
||||||
|
import { ExcelFileField } from './ExcelFileField';
|
||||||
|
import { ExcelCheckResult } from './ExcelCheckResult';
|
||||||
|
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: number;
|
||||||
|
checkUrl: CheckUrlBuilder;
|
||||||
|
/** Пока раздел записывает данные, новый файл не выбрать: иначе итог записи пропадёт с экрана. */
|
||||||
|
isLocked: boolean;
|
||||||
|
/** Выбран новый файл — раздел сбрасывает итог прошлой записи. */
|
||||||
|
onFileChange: () => void;
|
||||||
|
/** Блок «Импортировать» — только для файла без ошибок. */
|
||||||
|
renderImport: (check: IExcelImportCheck) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выбор Excel-файла, проверка на сервере сразу после выбора и результат проверки. */
|
||||||
|
export const ExcelImportSection: React.FC<Props> = ({ projectId, checkUrl, isLocked, onFileChange, renderImport }) => {
|
||||||
|
const { status, checkFile, reset } = useExcelFileCheck(projectId, checkUrl);
|
||||||
|
const [selectedFileName, setSelectedFileName] = React.useState<string | undefined>(undefined);
|
||||||
|
const [fileError, setFileError] = React.useState<string>('');
|
||||||
|
const isChecking = status.kind === 'checking';
|
||||||
|
|
||||||
|
const selectFile = (file: File): void => {
|
||||||
|
onFileChange();
|
||||||
|
if (!isExcelFileName(file.name)) {
|
||||||
|
reset();
|
||||||
|
setSelectedFileName(undefined);
|
||||||
|
setFileError(EXCEL_IMPORT_TEXTS.wrongExtension);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedFileName(file.name);
|
||||||
|
setFileError('');
|
||||||
|
checkFile(file).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ExcelFileField selectedFileName={selectedFileName} disabled={isChecking || isLocked} onSelect={selectFile} />
|
||||||
|
{fileError && <Alert variant="err">{fileError}</Alert>}
|
||||||
|
{isChecking && <Spinner size="small" label={EXCEL_IMPORT_TEXTS.checking} />}
|
||||||
|
{status.kind === 'error' && <Alert variant="err">{status.message}</Alert>}
|
||||||
|
{status.kind === 'done' && (
|
||||||
|
<ExcelCheckResult
|
||||||
|
check={status.check}
|
||||||
|
actions={canImportFile(status.check) ? renderImport(status.check) : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { checkExcelFile } from './excelImportRepository';
|
||||||
|
import { createFakeHttpClient } from '../../shared/testing/fakeHttpClient';
|
||||||
|
|
||||||
|
const URL = 'https://api.tnsu.kz/Import/StroyBot/Reports?projectId=51';
|
||||||
|
|
||||||
|
describe('проверка Excel-файла', () => {
|
||||||
|
it('POST multipart: файл в поле file, Content-Type не задаём (boundary ставит браузер)', async () => {
|
||||||
|
const body = { total_rows: 0, valid_rows: 0, invalid_rows: 0, rows: [], notes: [] };
|
||||||
|
const client = createFakeHttpClient([{ match: '/Import/StroyBot/Reports', response: { body } }]);
|
||||||
|
const file = new File(['xlsx'], 'отчёт.xlsx');
|
||||||
|
|
||||||
|
expect(await checkExcelFile(client, URL, file)).toEqual(body);
|
||||||
|
|
||||||
|
const sent = client.requests[0].request;
|
||||||
|
expect(sent.method).toEqual('POST');
|
||||||
|
expect(sent.headers).toEqual({ accept: '*/*' });
|
||||||
|
expect(sent.body).toBeInstanceOf(FormData);
|
||||||
|
expect(((sent.body as FormData).get('file') as File).name).toEqual('отчёт.xlsx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ошибка сервера пробрасывается с текстом ответа', async () => {
|
||||||
|
const client = createFakeHttpClient([
|
||||||
|
{ match: '/Import/StroyBot/Reports', response: { status: 400, body: 'неверный формат' } }
|
||||||
|
]);
|
||||||
|
await expect(checkExcelFile(client, URL, new File(['x'], 'a.xlsx'))).rejects.toThrow('неверный формат');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ensureOk, IHttpClient } from '../../services/http/IHttpClient';
|
||||||
|
import { IExcelImportCheck } from './excelImportTypes';
|
||||||
|
|
||||||
|
/** Имя поля формы с файлом — как в Swagger эндпоинтов. */
|
||||||
|
const FILE_FIELD = 'file';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка Excel-файла на сервере. Эндпоинт ничего не сохраняет: разбирает строки, сопоставляет значения
|
||||||
|
* со справочниками (ids) и возвращает ошибки по строкам. Данные по результату записывает фронт.
|
||||||
|
* Content-Type не задаём: для FormData браузер сам ставит multipart/form-data с boundary.
|
||||||
|
*/
|
||||||
|
export async function checkExcelFile(api: IHttpClient, url: string, file: File): Promise<IExcelImportCheck> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append(FILE_FIELD, file, file.name);
|
||||||
|
const response = await ensureOk(
|
||||||
|
await api.fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { accept: '*/*' },
|
||||||
|
body: form
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return (await response.json()) as IExcelImportCheck;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { canImportFile, columnsOf, isExcelFileName, issuesByColumn, visibleRows } from './excelImportRules';
|
||||||
|
import { IExcelImportCheck, IExcelImportRow } from './excelImportTypes';
|
||||||
|
|
||||||
|
// Строки — по ответу сервера на тестовый файл проекта 51 (сокращённо).
|
||||||
|
const row = (
|
||||||
|
num: number,
|
||||||
|
values: IExcelImportRow['values'],
|
||||||
|
issues: IExcelImportRow['issues'] = []
|
||||||
|
): IExcelImportRow => ({ row: num, valid: issues.length === 0, values, ids: {}, issues });
|
||||||
|
|
||||||
|
const check = (rows: IExcelImportRow[]): IExcelImportCheck => ({
|
||||||
|
kind: 'reports',
|
||||||
|
file_name: 'test.xlsx',
|
||||||
|
project_id: 51,
|
||||||
|
project_name: 'TEST',
|
||||||
|
total_rows: rows.length,
|
||||||
|
valid_rows: rows.filter((item) => item.valid).length,
|
||||||
|
invalid_rows: rows.filter((item) => !item.valid).length,
|
||||||
|
rows,
|
||||||
|
notes: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const technicRow = row(2, { Дата: '01.10.2026', Техника: 'Насос', Профессия: null }, [
|
||||||
|
{ column: 'Профессия', message: 'значение не указано' },
|
||||||
|
{ column: 'Вид профессии', message: 'значение не указано' }
|
||||||
|
]);
|
||||||
|
const validRow = row(3, { Дата: '01.10.2026', Техника: null, Профессия: 'Арматурщик' });
|
||||||
|
|
||||||
|
describe('columnsOf', () => {
|
||||||
|
it('колонки в порядке файла; колонка только из ошибок — в конце', () => {
|
||||||
|
expect(columnsOf(check([technicRow, validRow]))).toEqual(['Дата', 'Техника', 'Профессия', 'Вид профессии']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('issuesByColumn', () => {
|
||||||
|
it('несколько сообщений одной колонки собираются вместе', () => {
|
||||||
|
const issues = issuesByColumn(
|
||||||
|
row(2, {}, [
|
||||||
|
{ column: 'Профессия', message: 'значение не указано' },
|
||||||
|
{ column: 'Профессия', message: 'id неоднозначен' }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(issues.get('Профессия')).toEqual(['значение не указано', 'id неоднозначен']);
|
||||||
|
expect(issues.get('Техника')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('visibleRows / canImportFile', () => {
|
||||||
|
it('фильтр «только с ошибками»', () => {
|
||||||
|
expect(visibleRows(check([technicRow, validRow]), true).map((item) => item.row)).toEqual([2]);
|
||||||
|
expect(visibleRows(check([technicRow, validRow]), false)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('импорт — только при строках без единой ошибки', () => {
|
||||||
|
expect(canImportFile(check([validRow]))).toBe(true);
|
||||||
|
expect(canImportFile(check([technicRow, validRow]))).toBe(false);
|
||||||
|
expect(canImportFile(check([]))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isExcelFileName', () => {
|
||||||
|
it('только .xlsx, без учёта регистра', () => {
|
||||||
|
expect(isExcelFileName('Отчёт.XLSX')).toBe(true);
|
||||||
|
expect(isExcelFileName('отчёт.xls')).toBe(false);
|
||||||
|
expect(isExcelFileName('отчёт.csv')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { IExcelImportCheck, IExcelImportRow } from './excelImportTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Колонки таблицы — в порядке, в каком их прислал сервер (это порядок колонок файла).
|
||||||
|
* Колонка, которая есть только в ошибках (её нет в values), добавляется в конец — чтобы ошибка не потерялась.
|
||||||
|
*/
|
||||||
|
export function columnsOf(check: IExcelImportCheck): string[] {
|
||||||
|
const columns: string[] = [];
|
||||||
|
const add = (column: string): void => {
|
||||||
|
if (columns.indexOf(column) === -1) columns.push(column);
|
||||||
|
};
|
||||||
|
check.rows.forEach((row) => Object.keys(row.values || {}).forEach(add));
|
||||||
|
check.rows.forEach((row) => (row.issues || []).forEach((issue) => add(issue.column)));
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ошибки строки по колонкам: одна колонка может иметь несколько сообщений. */
|
||||||
|
export function issuesByColumn(row: IExcelImportRow): Map<string, string[]> {
|
||||||
|
const result = new Map<string, string[]>();
|
||||||
|
(row.issues || []).forEach((issue) => {
|
||||||
|
const messages = result.get(issue.column) ?? [];
|
||||||
|
result.set(issue.column, [...messages, issue.message]);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строки для показа: все или только с ошибками. */
|
||||||
|
export function visibleRows(check: IExcelImportCheck, onlyInvalid: boolean): IExcelImportRow[] {
|
||||||
|
return onlyInvalid ? check.rows.filter((row) => !row.valid) : check.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Импортировать можно, только если в файле есть строки и ни одной с ошибкой. */
|
||||||
|
export function canImportFile(check: IExcelImportCheck): boolean {
|
||||||
|
return check.total_rows > 0 && check.invalid_rows === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Расширения Excel, которые принимает поле выбора файла. */
|
||||||
|
export const EXCEL_ACCEPT = '.xlsx';
|
||||||
|
|
||||||
|
export function isExcelFileName(fileName: string): boolean {
|
||||||
|
return /\.xlsx$/i.test(fileName);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Ответ проверки Excel-файла: POST /Import/StroyBot/{Reports|Works}?projectId= (multipart, поле file).
|
||||||
|
// Эндпоинты ничего не сохраняют — только разбирают файл и сопоставляют значения со справочниками.
|
||||||
|
// Имена полей — как в ответе API (snake_case), поэтому не переименовываем.
|
||||||
|
/* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */
|
||||||
|
|
||||||
|
/** Ошибка в ячейке: колонка — заголовок колонки файла («Профессия», «Вид работы»…). */
|
||||||
|
export interface IExcelImportIssue {
|
||||||
|
column: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строка файла после проверки. */
|
||||||
|
export interface IExcelImportRow {
|
||||||
|
/** Номер строки в Excel (заголовок — строка 1). */
|
||||||
|
row: number;
|
||||||
|
valid: boolean;
|
||||||
|
/** Значения ячеек по заголовкам колонок, как их прочитал сервер. Пустая ячейка — null. */
|
||||||
|
values: Record<string, string | null>;
|
||||||
|
/** Во что сервер сопоставил значения (id объектов, работ, компаний…) — по ним фронт записывает данные. */
|
||||||
|
ids: Record<string, unknown>;
|
||||||
|
issues: IExcelImportIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IExcelImportCheck {
|
||||||
|
/** Что проверялось: 'reports' / 'works'. */
|
||||||
|
kind: string;
|
||||||
|
file_name: string;
|
||||||
|
project_id: number;
|
||||||
|
project_name: string;
|
||||||
|
total_rows: number;
|
||||||
|
valid_rows: number;
|
||||||
|
invalid_rows: number;
|
||||||
|
rows: IExcelImportRow[];
|
||||||
|
notes: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { deepestObjectId, idNumber, textValue, toNumber } from './excelImportValues';
|
||||||
|
import { IExcelImportRow } from './excelImportTypes';
|
||||||
|
|
||||||
|
/* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */
|
||||||
|
|
||||||
|
const row = (values: IExcelImportRow['values'], ids: IExcelImportRow['ids']): IExcelImportRow => ({
|
||||||
|
row: 2,
|
||||||
|
valid: true,
|
||||||
|
values,
|
||||||
|
ids,
|
||||||
|
issues: []
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('значения строки Excel', () => {
|
||||||
|
it('число: запятая как разделитель, пусто → undefined', () => {
|
||||||
|
expect(toNumber('5,5')).toEqual(5.5);
|
||||||
|
expect(toNumber('11')).toEqual(11);
|
||||||
|
expect(toNumber('')).toBeUndefined();
|
||||||
|
expect(toNumber(null)).toBeUndefined();
|
||||||
|
expect(toNumber('abc')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('id: только число; массив (неоднозначно) и отсутствие → undefined', () => {
|
||||||
|
const item = row({}, { 'Ед.изм.': 37, 'Профессия': [180, 360] });
|
||||||
|
expect(idNumber(item, 'Ед.изм.')).toEqual(37);
|
||||||
|
expect(idNumber(item, 'Профессия')).toBeUndefined();
|
||||||
|
expect(idNumber(item, 'Техника')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('текст: без краевых пробелов, пусто → undefined', () => {
|
||||||
|
const item = row({ 'Вид работ': ' Арматура ', 'Комментарий': ' ', 'Объект 3': null }, {});
|
||||||
|
expect(textValue(item, 'Вид работ')).toEqual('Арматура');
|
||||||
|
expect(textValue(item, 'Комментарий')).toBeUndefined();
|
||||||
|
expect(textValue(item, 'Объект 3')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('объект — самый глубокий уровень, даже если сервер прислал только его', () => {
|
||||||
|
const columns = ['Объект 1', 'Объект 2', 'Объект 3'];
|
||||||
|
expect(deepestObjectId(row({}, { 'Объект 1': 1516, 'Объект 2': 1517 }), columns)).toEqual(1517);
|
||||||
|
expect(deepestObjectId(row({}, { 'Объект 2': 1517 }), columns)).toEqual(1517);
|
||||||
|
expect(deepestObjectId(row({}, {}), columns)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { IExcelImportRow } from './excelImportTypes';
|
||||||
|
|
||||||
|
// Чтение значений строки из ответа проверки — общее для импорта отчётов и работ.
|
||||||
|
/* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */
|
||||||
|
|
||||||
|
/** «5», «5,5» → число; пусто или не число → undefined. */
|
||||||
|
export function toNumber(value: string | null | undefined): number | undefined {
|
||||||
|
if (value === null || value === undefined || value.trim() === '') return undefined;
|
||||||
|
const result = Number(value.replace(',', '.'));
|
||||||
|
return isNaN(result) ? undefined : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Id, который сервер сопоставил ячейке; не число (нет, массив при неоднозначности) → undefined. */
|
||||||
|
export function idNumber(row: IExcelImportRow, column: string): number | undefined {
|
||||||
|
const id = row.ids[column];
|
||||||
|
return typeof id === 'number' ? id : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Значение ячейки без краевых пробелов; пустое → undefined. */
|
||||||
|
export function textValue(row: IExcelImportRow, column: string): string | undefined {
|
||||||
|
return row.values[column]?.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Самый глубокий заполненный уровень объекта (Объект 3 → 2 → 1). */
|
||||||
|
export function deepestObjectId(row: IExcelImportRow, objectColumns: readonly string[]): number | undefined {
|
||||||
|
const ids = objectColumns.map((column) => idNumber(row, column)).filter((id) => id !== undefined);
|
||||||
|
return ids[ids.length - 1];
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Общие тексты импорта из Excel: выбор файла и результат проверки.
|
||||||
|
|
||||||
|
export const EXCEL_IMPORT_TEXTS = {
|
||||||
|
chooseFile: 'Выбрать файл',
|
||||||
|
chooseAnotherFile: 'Выбрать другой файл',
|
||||||
|
selectedFile: (name: string): string => `Выбран файл: ${name}`,
|
||||||
|
checking: 'Проверяем файл…',
|
||||||
|
wrongExtension: 'Нужен файл Excel в формате .xlsx',
|
||||||
|
checkError: (details: string): string => `Не удалось проверить файл: ${details}`,
|
||||||
|
summary: {
|
||||||
|
file: (name: string): string => `Файл: ${name}`,
|
||||||
|
total: (count: number): string => `Строк: ${count}`,
|
||||||
|
valid: (count: number): string => `Без ошибок: ${count}`,
|
||||||
|
invalid: (count: number): string => `С ошибками: ${count}`
|
||||||
|
},
|
||||||
|
emptyFile: 'В файле нет строк с данными.',
|
||||||
|
readyToImport: 'Ошибок нет — файл готов к импорту.',
|
||||||
|
fixInExcel: 'Исправьте ошибки в Excel и загрузите файл снова.',
|
||||||
|
onlyInvalid: 'Только строки с ошибками',
|
||||||
|
columns: {
|
||||||
|
row: 'Строка',
|
||||||
|
issues: 'Ошибки'
|
||||||
|
},
|
||||||
|
notesTitle: 'Примечания',
|
||||||
|
empty: '—'
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useAppServices } from '../../app/AppServicesContext';
|
||||||
|
import { checkExcelFile } from './excelImportRepository';
|
||||||
|
import { IExcelImportCheck } from './excelImportTypes';
|
||||||
|
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||||
|
|
||||||
|
export type ExcelFileCheckStatus =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'checking' }
|
||||||
|
| { kind: 'error'; message: string }
|
||||||
|
| { kind: 'done'; check: IExcelImportCheck };
|
||||||
|
|
||||||
|
/** Адрес эндпоинта проверки для раздела (отчёты, работы). */
|
||||||
|
export type CheckUrlBuilder = (apiBaseUrl: string, projectId: number) => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка Excel-файла на сервере. Сервер ничего не сохраняет, поэтому проверку можно
|
||||||
|
* запускать сразу при выборе файла. reset — сбросить результат (файл не Excel).
|
||||||
|
*/
|
||||||
|
export function useExcelFileCheck(
|
||||||
|
projectId: number | undefined,
|
||||||
|
checkUrl: CheckUrlBuilder
|
||||||
|
): {
|
||||||
|
status: ExcelFileCheckStatus;
|
||||||
|
checkFile: (file: File) => Promise<void>;
|
||||||
|
reset: () => void;
|
||||||
|
} {
|
||||||
|
const services = useAppServices();
|
||||||
|
const [status, setStatus] = React.useState<ExcelFileCheckStatus>({ kind: 'idle' });
|
||||||
|
const isMountedRef = React.useRef<boolean>(true);
|
||||||
|
|
||||||
|
React.useEffect(
|
||||||
|
() => () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkFile = async (file: File): Promise<void> => {
|
||||||
|
if (projectId === undefined) return;
|
||||||
|
setStatus({ kind: 'checking' });
|
||||||
|
try {
|
||||||
|
const url = checkUrl(services.settings.apiBaseUrl, projectId);
|
||||||
|
const check = await checkExcelFile(services.tnsuApi, url, file);
|
||||||
|
if (isMountedRef.current) setStatus({ kind: 'done', check });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка проверки Excel-файла:', error);
|
||||||
|
const details = error instanceof Error ? error.message : String(error);
|
||||||
|
if (isMountedRef.current) setStatus({ kind: 'error', message: EXCEL_IMPORT_TEXTS.checkError(details) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = React.useCallback((): void => setStatus({ kind: 'idle' }), []);
|
||||||
|
|
||||||
|
return { status, checkFile, reset };
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ import * as React from 'react';
|
|||||||
import { Alert, Badge, BadgeVariant, Button, Icon, Spinner, TextInput } from '@tnsu/ui-kit';
|
import { Alert, Badge, BadgeVariant, Button, Icon, Spinner, TextInput } from '@tnsu/ui-kit';
|
||||||
import { IAccessibleProject } from '../../app/project/projectTypes';
|
import { IAccessibleProject } from '../../app/project/projectTypes';
|
||||||
import { isExportAvailable } from '../../app/project/projectRules';
|
import { isExportAvailable } from '../../app/project/projectRules';
|
||||||
|
import { useRouter } from '../../app/routing/RouterContext';
|
||||||
|
import { InlineIcon } from '../../shared/components/InlineIcon';
|
||||||
|
import { WEATHER_TEXTS } from '../weather/texts';
|
||||||
import { getReportType, isInvalidRange, ReportKind } from './exportReport';
|
import { getReportType, isInvalidRange, ReportKind } from './exportReport';
|
||||||
import { useReportExport } from './useReportExport';
|
import { useReportExport } from './useReportExport';
|
||||||
import { ExportInfoButton } from './ExportInfoButton';
|
import { ExportInfoButton } from './ExportInfoButton';
|
||||||
@@ -20,17 +23,22 @@ interface Props {
|
|||||||
project: IAccessibleProject | undefined;
|
project: IAccessibleProject | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Карточка «Выгрузить за период». Поля дат видны только при выбранном проекте. */
|
/**
|
||||||
|
* Карточка «Выгрузить за период» и кнопка «Сверка погодных условий». Поля дат видны только при выбранном проекте.
|
||||||
|
* Даты берутся из адреса, если вернулись со сверки погоды, — чтобы не выбирать период заново (как в main).
|
||||||
|
*/
|
||||||
export const ExportCard: React.FC<Props> = ({ project }) => {
|
export const ExportCard: React.FC<Props> = ({ project }) => {
|
||||||
const [dateFrom, setDateFrom] = React.useState<string>('');
|
const { route, navigate } = useRouter();
|
||||||
const [dateTo, setDateTo] = React.useState<string>('');
|
const [dateFrom, setDateFrom] = React.useState<string>(route.start ?? '');
|
||||||
|
const [dateTo, setDateTo] = React.useState<string>(route.end ?? '');
|
||||||
const { isExporting, errorMessage, exportReport } = useReportExport();
|
const { isExporting, errorMessage, exportReport } = useReportExport();
|
||||||
|
|
||||||
const isEnabled = !!project && isExportAvailable(project);
|
const isEnabled = !!project && isExportAvailable(project);
|
||||||
const hasBothDates = !!dateFrom && !!dateTo;
|
const hasBothDates = !!dateFrom && !!dateTo;
|
||||||
const isRangeInvalid = isInvalidRange(dateFrom, dateTo);
|
const isRangeInvalid = isInvalidRange(dateFrom, dateTo);
|
||||||
const reportType = getReportType(dateFrom, dateTo);
|
const reportType = getReportType(dateFrom, dateTo);
|
||||||
const canExport = isEnabled && hasBothDates && !isRangeInvalid && !isExporting;
|
const isPeriodReady = isEnabled && hasBothDates && !isRangeInvalid;
|
||||||
|
const canExport = isPeriodReady && !isExporting;
|
||||||
|
|
||||||
const onExport = (): void => {
|
const onExport = (): void => {
|
||||||
if (project && canExport) {
|
if (project && canExport) {
|
||||||
@@ -38,6 +46,14 @@ export const ExportCard: React.FC<Props> = ({ project }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onOpenWeather = (): void => {
|
||||||
|
if (!project || !isPeriodReady) return;
|
||||||
|
const projectId = String(project.id);
|
||||||
|
// Сначала запоминаем даты в адресе главной: «назад» в браузере вернёт их в поля.
|
||||||
|
navigate({ page: 'hub', projectId, start: dateFrom, end: dateTo }, { replace: true });
|
||||||
|
navigate({ page: 'weather', projectId, start: dateFrom, end: dateTo });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isEnabled ? styles.exportCard : `${styles.exportCard} ${styles.cardDisabled}`}>
|
<div className={isEnabled ? styles.exportCard : `${styles.exportCard} ${styles.cardDisabled}`}>
|
||||||
<span className={styles.cardIcon} aria-hidden="true">
|
<span className={styles.cardIcon} aria-hidden="true">
|
||||||
@@ -64,6 +80,12 @@ export const ExportCard: React.FC<Props> = ({ project }) => {
|
|||||||
<Button variant="accent" onClick={onExport} disabled={!canExport}>
|
<Button variant="accent" onClick={onExport} disabled={!canExport}>
|
||||||
{isExporting ? <Spinner size="xSmall" label={TEXTS.buttonLoading} /> : TEXTS.button}
|
{isExporting ? <Spinner size="xSmall" label={TEXTS.buttonLoading} /> : TEXTS.button}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={onOpenWeather} disabled={!isPeriodReady}>
|
||||||
|
<span className={styles.buttonContent}>
|
||||||
|
<InlineIcon name="cloud" />
|
||||||
|
{WEATHER_TEXTS.openButton}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,12 @@ $card-min-width: 260px;
|
|||||||
color: $gray-700;
|
color: $gray-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.buttonContent {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
}
|
||||||
|
|
||||||
.dateError {
|
.dateError {
|
||||||
font-size: $fs-sm;
|
font-size: $fs-sm;
|
||||||
color: $red-500;
|
color: $red-500;
|
||||||
|
|||||||
@@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { useRouter } from '../../app/routing/RouterContext';
|
||||||
import { goBack } from './goBack';
|
import { goBack } from './goBack';
|
||||||
import { REPORT_TEXTS } from './texts';
|
import { REPORT_TEXTS } from './texts';
|
||||||
import styles from './ReportPage.module.scss';
|
import styles from './ReportPage.module.scss';
|
||||||
|
|
||||||
/** «← Вернуться назад» — назад по истории браузера, как в старой форме. */
|
/** «← Вернуться назад» — назад по истории браузера, как в старой форме; без истории — к списку отчётов проекта. */
|
||||||
export const BackLink: React.FC = () => (
|
export const BackLink: React.FC = () => {
|
||||||
<a
|
const { route, navigate } = useRouter();
|
||||||
href="#"
|
|
||||||
className={styles.backLink}
|
return (
|
||||||
onClick={(event) => {
|
<a
|
||||||
event.preventDefault();
|
href="#"
|
||||||
goBack();
|
className={styles.backLink}
|
||||||
}}
|
onClick={(event) => {
|
||||||
>
|
event.preventDefault();
|
||||||
{REPORT_TEXTS.back}
|
goBack(() => navigate({ page: 'reports', projectId: route.projectId }));
|
||||||
</a>
|
}}
|
||||||
);
|
>
|
||||||
|
{REPORT_TEXTS.back}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ $photo-thumb-min: 80px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.valueInput {
|
.valueInput {
|
||||||
|
box-sizing: border-box; // иначе ширина 100% + отступы вылезают за край карточки работы
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: $sp-2 $sp-3;
|
padding: $sp-2 $sp-3;
|
||||||
border: 1px solid $gray-300;
|
border: 1px solid $gray-300;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
orderRowsForDeletion
|
orderRowsForDeletion
|
||||||
} from './dataverseReportRepository';
|
} from './dataverseReportRepository';
|
||||||
import { createFakeHttpClient, IFakeRoute } from '../../shared/testing/fakeHttpClient';
|
import { createFakeHttpClient, IFakeRoute } from '../../shared/testing/fakeHttpClient';
|
||||||
|
import { IHttpRequest } from '../../services/http/IHttpClient';
|
||||||
import { IReportPayload } from './reportTypes';
|
import { IReportPayload } from './reportTypes';
|
||||||
|
|
||||||
const DV = 'https://tansu.crm5.dynamics.com';
|
const DV = 'https://tansu.crm5.dynamics.com';
|
||||||
@@ -21,13 +22,13 @@ const REPORT: IReportPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Тело запроса как объект — чтобы сравнивать без зависимости от порядка ключей. */
|
/** Тело запроса как объект — чтобы сравнивать без зависимости от порядка ключей. */
|
||||||
const bodyOf = (body: string | ArrayBuffer | undefined): unknown => JSON.parse(String(body));
|
const bodyOf = (body: IHttpRequest['body']): unknown => JSON.parse(String(body));
|
||||||
|
|
||||||
describe('адреса Dataverse (1:1 со stroy-bot-report)', () => {
|
describe('адреса Dataverse (1:1 со stroy-bot-report)', () => {
|
||||||
it('отчёт, счётчик, строки, факт', () => {
|
it('отчёт, счётчик, строки, факт', () => {
|
||||||
expect(DV_PATHS.report('r1')).toEqual('cree9_reports(r1)');
|
expect(DV_PATHS.report('r1')).toEqual('cree9_reports(r1)');
|
||||||
expect(DV_PATHS.reportCount(5)).toEqual(
|
expect(DV_PATHS.lastReportNumber(5)).toEqual(
|
||||||
'cree9_reports?$filter=cree9_projectid eq 5 and cree9_deleted ne true&$count=true&$top=1&$select=cree9_number'
|
'cree9_reports?$filter=cree9_projectid eq 5&$orderby=cree9_number desc&$top=1&$select=cree9_number'
|
||||||
);
|
);
|
||||||
expect(DV_PATHS.reportData('r1')).toEqual('cree9_report_datas?$filter=_cree9_column1_value eq r1');
|
expect(DV_PATHS.reportData('r1')).toEqual('cree9_report_datas?$filter=_cree9_column1_value eq r1');
|
||||||
expect(DV_PATHS.jobTotalFact('j1')).toEqual('cree9_report_datas?$filter=_cree9_jobid_value eq j1&$select=cree9_value');
|
expect(DV_PATHS.jobTotalFact('j1')).toEqual('cree9_report_datas?$filter=_cree9_jobid_value eq j1&$select=cree9_value');
|
||||||
@@ -165,11 +166,18 @@ describe('saveReportWithData', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('прочие операции', () => {
|
describe('прочие операции', () => {
|
||||||
it('номер нового отчёта = количество неудалённых + 1', async () => {
|
it('номер нового отчёта = наибольший номер в проекте (включая удалённые) + 1', async () => {
|
||||||
const client = createFakeHttpClient([{ match: '$count=true', response: { body: { '@odata.count': 7, value: [] } } }]);
|
const client = createFakeHttpClient([
|
||||||
|
{ match: 'cree9_number desc', response: { body: { value: [{ cree9_number: 7 }] } } }
|
||||||
|
]);
|
||||||
expect(await createDataverseReportRepository(client, DV).getNextReportNumber(5)).toEqual(8);
|
expect(await createDataverseReportRepository(client, DV).getNextReportNumber(5)).toEqual(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('первый отчёт проекта получает номер 1', async () => {
|
||||||
|
const client = createFakeHttpClient([{ match: 'cree9_number desc', response: { body: { value: [] } } }]);
|
||||||
|
expect(await createDataverseReportRepository(client, DV).getNextReportNumber(5)).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('утверждение — PATCH cree9_aprooved: true', async () => {
|
it('утверждение — PATCH cree9_aprooved: true', async () => {
|
||||||
const client = createFakeHttpClient([{ match: 'cree9_reports(r1)', response: { status: 204 } }]);
|
const client = createFakeHttpClient([{ match: 'cree9_reports(r1)', response: { status: 204 } }]);
|
||||||
await createDataverseReportRepository(client, DV).approveReport('r1');
|
await createDataverseReportRepository(client, DV).approveReport('r1');
|
||||||
|
|||||||
@@ -50,9 +50,10 @@ export const DV_PATHS = {
|
|||||||
`?$select=ReferencingAttribute,ReferencingEntityNavigationPropertyName` +
|
`?$select=ReferencingAttribute,ReferencingEntityNavigationPropertyName` +
|
||||||
`&$filter=ReferencingAttribute eq 'cree9_parentid'`,
|
`&$filter=ReferencingAttribute eq 'cree9_parentid'`,
|
||||||
report: (reportId: string): string => `${DV_TABLES.reports}(${reportId})`,
|
report: (reportId: string): string => `${DV_TABLES.reports}(${reportId})`,
|
||||||
reportCount: (projectId: number): string =>
|
// Удалённые отчёты не исключаются: их номера (и папки фото {projectId}_{номер}) повторно не выдаются.
|
||||||
`${DV_TABLES.reports}?$filter=cree9_projectid eq ${projectId} and cree9_deleted ne true` +
|
lastReportNumber: (projectId: number): string =>
|
||||||
`&$count=true&$top=1&$select=cree9_number`,
|
`${DV_TABLES.reports}?$filter=cree9_projectid eq ${projectId}` +
|
||||||
|
`&$orderby=cree9_number desc&$top=1&$select=cree9_number`,
|
||||||
jobTotalFact: (jobId: string): string =>
|
jobTotalFact: (jobId: string): string =>
|
||||||
`${DV_TABLES.reportData}?$filter=_cree9_jobid_value eq ${jobId}&$select=cree9_value`,
|
`${DV_TABLES.reportData}?$filter=_cree9_jobid_value eq ${jobId}&$select=cree9_value`,
|
||||||
reportData: (reportId: string): string => `${DV_TABLES.reportData}?$filter=_cree9_column1_value eq ${reportId}`,
|
reportData: (reportId: string): string => `${DV_TABLES.reportData}?$filter=_cree9_column1_value eq ${reportId}`,
|
||||||
@@ -252,9 +253,11 @@ export function createDataverseReportRepository(client: IHttpClient, baseUrl: st
|
|||||||
getReportById: (reportId) => get<IReportRow>(DV_PATHS.report(reportId)),
|
getReportById: (reportId) => get<IReportRow>(DV_PATHS.report(reportId)),
|
||||||
|
|
||||||
getNextReportNumber: async (projectId) => {
|
getNextReportNumber: async (projectId) => {
|
||||||
// 🐞 Номер = число неудалённых отчётов + 1: после мягкого удаления номер может повториться.
|
// В старом коде номер = число неудалённых отчётов + 1, и после мягкого удаления номера повторялись —
|
||||||
const result = await get<{ '@odata.count'?: number }>(DV_PATHS.reportCount(projectId));
|
// исправлено при проверке T-REP-23: номер = наибольший номер в проекте + 1.
|
||||||
return (result['@odata.count'] || 0) + 1;
|
const result = await get<{ value?: Array<{ cree9_number?: number }> }>(DV_PATHS.lastReportNumber(projectId));
|
||||||
|
const lastNumber = result.value?.[0]?.cree9_number || 0;
|
||||||
|
return lastNumber + 1;
|
||||||
},
|
},
|
||||||
|
|
||||||
approveReport: (reportId) => patch(DV_PATHS.report(reportId), { cree9_aprooved: true }),
|
approveReport: (reportId) => patch(DV_PATHS.report(reportId), { cree9_aprooved: true }),
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
|
/** В новой вкладке (так отчёты открываются из списка) история состоит из одной текущей страницы. */
|
||||||
|
const NO_HISTORY_LENGTH = 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* «← Вернуться назад» — как в старом коде: если страница открыта во фрейме, назад идёт верхнее окно,
|
* «← Вернуться назад» — как в старом коде: если страница открыта во фрейме, назад идёт верхнее окно,
|
||||||
* при ошибке доступа к нему (другой домен) — текущее.
|
* при ошибке доступа к нему (другой домен) — текущее.
|
||||||
|
* Если возвращаться некуда (вкладка открыта из списка отчётов), вызывается onNoHistory: в старом коде
|
||||||
|
* кнопка в этом случае ничего не делала — исправлено при проверке T-REP-21.
|
||||||
*/
|
*/
|
||||||
export function goBack(): void {
|
export function goBack(onNoHistory: () => void): void {
|
||||||
|
if (window.history.length <= NO_HISTORY_LENGTH) {
|
||||||
|
onNoHistory();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (window.top && window.top !== window) {
|
if (window.top && window.top !== window) {
|
||||||
window.top.history.back();
|
window.top.history.back();
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ $slide-duration: 0.2s;
|
|||||||
animation: slideUp $slide-duration ease-out;
|
animation: slideUp $slide-duration ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Как в старом PickerModal: шторка снизу — только на телефоне, на экране шире 640px — окно по центру.
|
||||||
|
@include above($bp-sm) {
|
||||||
|
.overlay {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
border-radius: $r-lg;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IAppServices } from '../../services/AppServices';
|
import { IAppServices } from '../../services/AppServices';
|
||||||
|
import { siteServerRelativeUrl } from '../../services/siteUser';
|
||||||
import { createSharePointReportRepository, ISharePointReportRepository } from './sharePointReportRepository';
|
import { createSharePointReportRepository, ISharePointReportRepository } from './sharePointReportRepository';
|
||||||
import { createDataverseReportRepository, IDataverseReportRepository } from './dataverseReportRepository';
|
import { createDataverseReportRepository, IDataverseReportRepository } from './dataverseReportRepository';
|
||||||
|
|
||||||
@@ -9,13 +10,15 @@ export interface IReportServices {
|
|||||||
siteUrl: string;
|
siteUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Всё — от сайта со списками (settings.siteUrl), а не от сайта страницы: в workbench корневого
|
||||||
|
// сайта id пользователя и путь к библиотеке DocLib там другие.
|
||||||
export function createReportServices(services: IAppServices): IReportServices {
|
export function createReportServices(services: IAppServices): IReportServices {
|
||||||
return {
|
return {
|
||||||
sp: createSharePointReportRepository({
|
sp: createSharePointReportRepository({
|
||||||
client: services.sharePoint,
|
client: services.sharePoint,
|
||||||
siteUrl: services.settings.siteUrl,
|
siteUrl: services.settings.siteUrl,
|
||||||
userId: services.page.userId,
|
getCurrentUserId: services.getCurrentUserId,
|
||||||
webServerRelativeUrl: services.page.webServerRelativeUrl
|
webServerRelativeUrl: siteServerRelativeUrl(services.settings.siteUrl)
|
||||||
}),
|
}),
|
||||||
dv: createDataverseReportRepository(services.dataverse, services.settings.dataverseUrl),
|
dv: createDataverseReportRepository(services.dataverse, services.settings.dataverseUrl),
|
||||||
siteUrl: services.settings.siteUrl
|
siteUrl: services.settings.siteUrl
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const repositoryWith = (routes: IFakeRoute[]): {
|
|||||||
const repository = createSharePointReportRepository({
|
const repository = createSharePointReportRepository({
|
||||||
client,
|
client,
|
||||||
siteUrl: SITE,
|
siteUrl: SITE,
|
||||||
userId: 42,
|
getCurrentUserId: async () => 42,
|
||||||
webServerRelativeUrl: '/sites/stroy/'
|
webServerRelativeUrl: '/sites/stroy/'
|
||||||
});
|
});
|
||||||
return { client, repository };
|
return { client, repository };
|
||||||
|
|||||||
@@ -111,9 +111,13 @@ export function fileAddUrl(siteUrl: string, serverRelativeFolderUrl: string, fil
|
|||||||
export interface ISharePointReportDeps {
|
export interface ISharePointReportDeps {
|
||||||
client: IHttpClient;
|
client: IHttpClient;
|
||||||
siteUrl: string;
|
siteUrl: string;
|
||||||
/** Id текущего пользователя (legacyPageContext.userId) — по нему ищется строка «Доступы». */
|
/**
|
||||||
userId: number | undefined;
|
* Id текущего пользователя на сайте со списками — по нему ищется строка «Доступы».
|
||||||
/** Серверный относительный URL сайта — для пути к папке фото. */
|
* Старый код брал legacyPageContext.userId (id на сайте страницы); в workbench корневого сайта
|
||||||
|
* это другой id, и доступ не находился. Теперь — `_api/web/currentuser` сайта со списками.
|
||||||
|
*/
|
||||||
|
getCurrentUserId: () => Promise<number>;
|
||||||
|
/** Серверный относительный URL сайта со списками (`/sites/stroy`) — для пути к папке фото. */
|
||||||
webServerRelativeUrl: string;
|
webServerRelativeUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,8 +228,11 @@ export function createSharePointReportRepository(deps: ISharePointReportDeps): I
|
|||||||
createListItem,
|
createListItem,
|
||||||
|
|
||||||
checkAccess: async (projectId) => {
|
checkAccess: async (projectId) => {
|
||||||
const rows = await getItems<IAccessRaw>(LISTS.access, { filter: `projectId eq ${projectId}` });
|
const [rows, userId] = await Promise.all([
|
||||||
return pickAccess(rows, deps.userId);
|
getItems<IAccessRaw>(LISTS.access, { filter: `projectId eq ${projectId}` }),
|
||||||
|
deps.getCurrentUserId()
|
||||||
|
]);
|
||||||
|
return pickAccess(rows, userId);
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadPhotos: async (projectId, reportNumber, photos) => {
|
uploadPhotos: async (projectId, reportNumber, photos) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { LISTS } from '../../config/lists';
|
import { LISTS } from '../../config/lists';
|
||||||
import { IReportLookups, IWorkItem } from './reportTypes';
|
import { IJobWithPath, IReportLookups, IWorkItem } from './reportTypes';
|
||||||
import { IPickerCreateData, IPickerItem, IPickerOptions } from './picker/pickerTypes';
|
import { IPickerCreateData, IPickerItem, IPickerOptions } from './picker/pickerTypes';
|
||||||
import { PickerKind, PICKER_OPTIONS, toPickerItems } from './reportPicker';
|
import { PickerKind, PICKER_OPTIONS, toPickerItems } from './reportPicker';
|
||||||
import { addResource, addWork, createLocalId, createResourceItem } from './reportFormUpdates';
|
import { addResource, addWork, createLocalId, createResourceItem } from './reportFormUpdates';
|
||||||
@@ -32,8 +32,14 @@ interface IPickerDeps {
|
|||||||
projectId: number;
|
projectId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function newWorkItem(item: IPickerItem, lookups: IReportLookups, totalFact: number): IWorkItem {
|
function newWorkItem(
|
||||||
const job = lookups.jobs.filter((candidate) => candidate.cree9_tableid === item.id)[0];
|
item: IPickerItem,
|
||||||
|
lookups: IReportLookups,
|
||||||
|
createdJobs: Map<string, IJobWithPath>,
|
||||||
|
totalFact: number
|
||||||
|
): IWorkItem {
|
||||||
|
const job =
|
||||||
|
lookups.jobs.filter((candidate) => candidate.cree9_tableid === item.id)[0] ?? createdJobs.get(String(item.id));
|
||||||
return {
|
return {
|
||||||
id: createLocalId(),
|
id: createLocalId(),
|
||||||
jobId: String(item.id),
|
jobId: String(item.id),
|
||||||
@@ -75,6 +81,10 @@ export function usePickerModal(deps: IPickerDeps): IPickerModalApi {
|
|||||||
const { services, report } = deps;
|
const { services, report } = deps;
|
||||||
const [active, setActive] = React.useState<IActivePicker | undefined>(undefined);
|
const [active, setActive] = React.useState<IActivePicker | undefined>(undefined);
|
||||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||||
|
// Работы, созданные из пикера в этой форме. Нужны сразу при выборе: в lookups.jobs текущего рендера
|
||||||
|
// их ещё нет (обновление state придёт позже), и без этого карточка получала путь вместо названия,
|
||||||
|
// пустую ед. изм. и план 0 — баг старого кода, исправлен при проверке T-REP-15b.
|
||||||
|
const createdJobsRef = React.useRef<Map<string, IJobWithPath>>(new Map());
|
||||||
|
|
||||||
const open = (kind: PickerKind, targetWorkId?: string): void => {
|
const open = (kind: PickerKind, targetWorkId?: string): void => {
|
||||||
setActive({ kind, targetWorkId });
|
setActive({ kind, targetWorkId });
|
||||||
@@ -100,10 +110,8 @@ export function usePickerModal(deps: IPickerDeps): IPickerModalApi {
|
|||||||
} else if (kind === 'company') {
|
} else if (kind === 'company') {
|
||||||
report.updateForm((prev) => ({ ...prev, companyId: Number(item.id), companyName: item.title }));
|
report.updateForm((prev) => ({ ...prev, companyId: Number(item.id), companyName: item.title }));
|
||||||
} else if (kind === 'work') {
|
} else if (kind === 'work') {
|
||||||
// 🐞 Как в старом коде: только что созданной работы ещё нет в lookups.jobs этого рендера,
|
|
||||||
// поэтому у неё название = полный путь, ед. изм. пустая и план 0.
|
|
||||||
const totalFact = await services.dv.getJobTotalFact(String(item.id));
|
const totalFact = await services.dv.getJobTotalFact(String(item.id));
|
||||||
const work = newWorkItem(item, report.lookups, totalFact);
|
const work = newWorkItem(item, report.lookups, createdJobsRef.current, totalFact);
|
||||||
report.updateForm((prev) => addWork(prev, work));
|
report.updateForm((prev) => addWork(prev, work));
|
||||||
} else {
|
} else {
|
||||||
const resource = createResourceItem(Number(item.id), item.title);
|
const resource = createResourceItem(Number(item.id), item.title);
|
||||||
@@ -134,7 +142,9 @@ export function usePickerModal(deps: IPickerDeps): IPickerModalApi {
|
|||||||
parentId: data.parentId
|
parentId: data.parentId
|
||||||
});
|
});
|
||||||
const fullPath = newJobPath(data.title, data.parentId, report.lookups.jobs);
|
const fullPath = newJobPath(data.title, data.parentId, report.lookups.jobs);
|
||||||
report.updateLookups((prev) => ({ jobs: [...prev.jobs, { ...created, fullPath }] }));
|
const createdWithPath = { ...created, fullPath };
|
||||||
|
createdJobsRef.current.set(created.cree9_tableid, createdWithPath);
|
||||||
|
report.updateLookups((prev) => ({ jobs: [...prev.jobs, createdWithPath] }));
|
||||||
return { id: created.cree9_tableid, title: fullPath, subtitle: data.unit };
|
return { id: created.cree9_tableid, title: fullPath, subtitle: data.unit };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ export function useReportForm(services: IReportServices, params: IReportRoutePar
|
|||||||
try {
|
try {
|
||||||
await services.dv.approveReport(existingReportId);
|
await services.dv.approveReport(existingReportId);
|
||||||
setSuccessMessage(MESSAGES.approved);
|
setSuccessMessage(MESSAGES.approved);
|
||||||
// 🐞 Как в старом коде: флаг «утверждён» не обновляется, бейдж остаётся «На утверждении».
|
// В старом коде флаг не обновлялся и бейдж оставался «На утверждении» до F5 — исправлено при проверке T-REP-16.
|
||||||
|
setIsApproved(true);
|
||||||
setMode('readonly');
|
setMode('readonly');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrorMessage(MESSAGES.approveError(messageOf(error)));
|
setErrorMessage(MESSAGES.approveError(messageOf(error)));
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Button, confirmModal, Spinner } from '@tnsu/ui-kit';
|
||||||
|
import { IImportedReport } from './reportImportBuilder';
|
||||||
|
import { ReportImportStatus } from './useReportImport';
|
||||||
|
import { REPORT_IMPORT_TEXTS } from './texts';
|
||||||
|
|
||||||
|
const TEXTS = REPORT_IMPORT_TEXTS.import;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
reports: IImportedReport[];
|
||||||
|
projectTitle: string;
|
||||||
|
status: ReportImportStatus;
|
||||||
|
onImport: (reports: IImportedReport[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Кнопка «Импортировать» с подтверждением; во время записи — прогресс. После записи кнопки нет. */
|
||||||
|
export const ReportImportActions: React.FC<Props> = ({ reports, projectTitle, status, onImport }) => {
|
||||||
|
if (status.kind === 'saving') {
|
||||||
|
return <Spinner size="small" label={TEXTS.saving(status.savedCount, status.total)} />;
|
||||||
|
}
|
||||||
|
if (status.kind === 'finished') return null;
|
||||||
|
|
||||||
|
const startImport = async (): Promise<void> => {
|
||||||
|
const isConfirmed = await confirmModal({
|
||||||
|
title: TEXTS.confirmTitle,
|
||||||
|
message: TEXTS.confirmMessage(reports.length, projectTitle),
|
||||||
|
confirmText: TEXTS.confirm,
|
||||||
|
cancelText: TEXTS.cancel
|
||||||
|
});
|
||||||
|
if (isConfirmed) await onImport(reports);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button variant="accent" onClick={() => startImport().catch(() => undefined)}>
|
||||||
|
{TEXTS.button(reports.length)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Alert } from '@tnsu/ui-kit';
|
||||||
|
import { RouteLink } from '../../app/routing/RouteLink';
|
||||||
|
import { formatReportDate } from '../reports/reportsRules';
|
||||||
|
import { IImportSaveResult, ISavedImportReport } from './reportImportSave';
|
||||||
|
import { REPORT_IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ReportImportPage.module.scss';
|
||||||
|
|
||||||
|
const TEXTS = REPORT_IMPORT_TEXTS.import;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
result: IImportSaveResult;
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function savedList(saved: ISavedImportReport[]): string {
|
||||||
|
return saved.map((report) => TEXTS.savedReport(report.reportNumber, formatReportDate(report.reportDate))).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Итог записи. При ошибке явно говорим, что уже записано, — повторная загрузка файла задвоит отчёты. */
|
||||||
|
export const ReportImportOutcome: React.FC<Props> = ({ result, projectId }) => {
|
||||||
|
const reportsLink = (
|
||||||
|
<RouteLink className={styles.reportsLink} to={{ page: 'reports', projectId }}>
|
||||||
|
{TEXTS.openReports}
|
||||||
|
</RouteLink>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.errorMessage) {
|
||||||
|
return (
|
||||||
|
<Alert variant="ok">
|
||||||
|
{TEXTS.success(savedList(result.saved))} {reportsLink}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const failedRows = (result.failedRows || []).join(', ');
|
||||||
|
return (
|
||||||
|
<Alert variant="err">
|
||||||
|
<p className={styles.outcomeLine}>{TEXTS.failed(failedRows, result.errorMessage)}</p>
|
||||||
|
{result.saved.length > 0 && <p className={styles.outcomeLine}>{TEXTS.partlySaved(savedList(result.saved))}</p>}
|
||||||
|
<p className={styles.outcomeLine}>{TEXTS.mayBePartial}</p>
|
||||||
|
{reportsLink}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-4;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
margin: 0;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outcomeLine {
|
||||||
|
margin: 0 0 $sp-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reportsLink {
|
||||||
|
color: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Alert } from '@tnsu/ui-kit';
|
||||||
|
import { useProject } from '../../app/project/ProjectContext';
|
||||||
|
import { isSectionAvailable } from '../../app/project/projectRules';
|
||||||
|
import { TreePageHeader } from '../_tree/TreePageHeader';
|
||||||
|
import { ExcelImportSection } from '../_excelImport/ExcelImportSection';
|
||||||
|
import { reportImportUrl } from './reportImportRepository';
|
||||||
|
import { useReportImport } from './useReportImport';
|
||||||
|
import { ReportImportStep } from './ReportImportStep';
|
||||||
|
import { REPORT_IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ReportImportPage.module.scss';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Импорт отчёта из Excel: `?page=reportImport&projectId=`.
|
||||||
|
* Файл проверяется на сервере сразу после выбора (сервер ничего не сохраняет); если ошибок нет —
|
||||||
|
* «Импортировать» записывает отчёты в Dataverse тем же кодом, что форма «Отправить отчёт».
|
||||||
|
*/
|
||||||
|
export const ReportImportPage: React.FC = () => {
|
||||||
|
const { currentProject } = useProject();
|
||||||
|
const importer = useReportImport(currentProject?.id);
|
||||||
|
|
||||||
|
const renderContent = (): React.ReactNode => {
|
||||||
|
if (!currentProject) return <Alert variant="info">{REPORT_IMPORT_TEXTS.noProject}</Alert>;
|
||||||
|
if (!isSectionAvailable('reportImport', currentProject)) {
|
||||||
|
return <Alert variant="warn">{REPORT_IMPORT_TEXTS.noAccess}</Alert>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className={styles.intro}>{REPORT_IMPORT_TEXTS.intro}</p>
|
||||||
|
<ExcelImportSection
|
||||||
|
projectId={currentProject.id}
|
||||||
|
checkUrl={reportImportUrl}
|
||||||
|
isLocked={importer.status.kind === 'saving'}
|
||||||
|
onFileChange={importer.reset}
|
||||||
|
renderImport={(check) => (
|
||||||
|
<ReportImportStep
|
||||||
|
check={check}
|
||||||
|
projectId={currentProject.id}
|
||||||
|
projectTitle={currentProject.title}
|
||||||
|
status={importer.status}
|
||||||
|
onImport={importer.importReports}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<TreePageHeader icon="upload" label={REPORT_IMPORT_TEXTS.headerLabel} title={currentProject?.title ?? ''} />
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Alert } from '@tnsu/ui-kit';
|
||||||
|
import { IExcelImportCheck } from '../_excelImport/excelImportTypes';
|
||||||
|
import { IImportedReport, tryBuildImportedReports } from './reportImportBuilder';
|
||||||
|
import { ReportImportStatus } from './useReportImport';
|
||||||
|
import { ReportImportActions } from './ReportImportActions';
|
||||||
|
import { ReportImportOutcome } from './ReportImportOutcome';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
check: IExcelImportCheck;
|
||||||
|
projectId: number;
|
||||||
|
projectTitle: string;
|
||||||
|
status: ReportImportStatus;
|
||||||
|
onImport: (reports: IImportedReport[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Импорт проверенного файла без ошибок: отчёты собираются заранее, чтобы ошибку сборки показать до записи. */
|
||||||
|
export const ReportImportStep: React.FC<Props> = ({ check, projectId, projectTitle, status, onImport }) => {
|
||||||
|
const build = React.useMemo(() => tryBuildImportedReports(check, projectId), [check, projectId]);
|
||||||
|
|
||||||
|
if (build.kind === 'error') return <Alert variant="err">{build.message}</Alert>;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ReportImportActions reports={build.reports} projectTitle={projectTitle} status={status} onImport={onImport} />
|
||||||
|
{status.kind === 'finished' && <ReportImportOutcome result={status.result} projectId={String(projectId)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { buildImportedReports, toIsoDate } from './reportImportBuilder';
|
||||||
|
import { IExcelImportCheck, IExcelImportRow } from '../_excelImport/excelImportTypes';
|
||||||
|
|
||||||
|
/* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */
|
||||||
|
|
||||||
|
const JOB = 'f4cc373e-77cb-f111-a05c-70a8a5039971';
|
||||||
|
|
||||||
|
/** Строка как в ответе сервера на «ТЕСТ импорт отчета - проект 51.xlsx»; overrides — отличия. */
|
||||||
|
function makeRow(
|
||||||
|
row: number,
|
||||||
|
values: Record<string, string | null>,
|
||||||
|
ids: Record<string, unknown>
|
||||||
|
): IExcelImportRow {
|
||||||
|
return {
|
||||||
|
row,
|
||||||
|
valid: true,
|
||||||
|
values: {
|
||||||
|
'Дата': '01.10.2026 5:00:00',
|
||||||
|
'Компания': 'TANSU',
|
||||||
|
'Смена': '1',
|
||||||
|
'Объект 1': 'Тестовый объект',
|
||||||
|
'Объект 2': 'Бетонирование',
|
||||||
|
'Объект 3': null,
|
||||||
|
'Вид работы': 'Арматура',
|
||||||
|
'Факт': null,
|
||||||
|
'Техника': null,
|
||||||
|
'Профессия': null,
|
||||||
|
'Количество': null,
|
||||||
|
'Часы': null,
|
||||||
|
'Тип часов': 'Часы работы',
|
||||||
|
'Комментарий': null,
|
||||||
|
'Комментарий (данные)': null,
|
||||||
|
...values
|
||||||
|
},
|
||||||
|
ids: { 'Объект 1': 1516, 'Объект 2': 1517, 'Вид работы': JOB, 'Компания': 29, 'Смена': 1, ...ids },
|
||||||
|
issues: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCheck(rows: IExcelImportRow[]): IExcelImportCheck {
|
||||||
|
return {
|
||||||
|
kind: 'reports',
|
||||||
|
file_name: 'test.xlsx',
|
||||||
|
project_id: 51,
|
||||||
|
project_name: 'TEST',
|
||||||
|
total_rows: rows.length,
|
||||||
|
valid_rows: rows.length,
|
||||||
|
invalid_rows: 0,
|
||||||
|
rows,
|
||||||
|
notes: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const technicRow = makeRow(2, { 'Факт': '5', 'Количество': '1', 'Часы': '11' }, { 'Техника': 327 });
|
||||||
|
const professionRow = makeRow(3, { 'Факт': '3', 'Количество': '10', 'Часы': '11' }, { 'Профессия': 181 });
|
||||||
|
|
||||||
|
describe('сборка отчётов из ответа проверки', () => {
|
||||||
|
it('тестовый файл: один отчёт, две работы, ресурсы вложены в работы', () => {
|
||||||
|
const reports = buildImportedReports(makeCheck([technicRow, professionRow]), 51);
|
||||||
|
|
||||||
|
expect(reports).toEqual([
|
||||||
|
{
|
||||||
|
payload: {
|
||||||
|
cree9_report_date: '2026-10-01',
|
||||||
|
cree9_companyid: 29,
|
||||||
|
cree9_shiftid: 1,
|
||||||
|
cree9_objectid: 1517,
|
||||||
|
cree9_projectid: 51,
|
||||||
|
cree9_comment: undefined
|
||||||
|
},
|
||||||
|
rowNumbers: [2, 3],
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
data: { _cree9_jobid_value: JOB, cree9_value: 5, cree9_comment: undefined },
|
||||||
|
children: [{ cree9_technicid: 327, cree9_count: 1, cree9_hours: 11, cree9_hourstype: 'Часы работы' }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: { _cree9_jobid_value: JOB, cree9_value: 3, cree9_comment: undefined },
|
||||||
|
children: [{ cree9_professionid: 181, cree9_count: 10, cree9_hours: 11, cree9_hourstype: 'Часы работы' }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('простой под работой: работа остаётся, техника — отдельной строкой с типом часов и комментарием', () => {
|
||||||
|
const idleRow = makeRow(
|
||||||
|
2,
|
||||||
|
{ 'Факт': '5', 'Количество': '1', 'Часы': '3', 'Тип часов': 'Часы простоя', 'Комментарий (данные)': 'нет бетона' },
|
||||||
|
{ 'Техника': 327 }
|
||||||
|
);
|
||||||
|
const [report] = buildImportedReports(makeCheck([idleRow]), 51);
|
||||||
|
|
||||||
|
expect(report.nodes).toEqual([
|
||||||
|
{ data: { _cree9_jobid_value: JOB, cree9_value: 5, cree9_comment: undefined }, children: undefined },
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
cree9_technicid: 327,
|
||||||
|
cree9_count: 1,
|
||||||
|
cree9_hours: 3,
|
||||||
|
cree9_hourstype: 'Часы простоя',
|
||||||
|
cree9_comment: 'нет бетона'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('комментарий строки с часами работы остаётся на работе', () => {
|
||||||
|
const row = makeRow(2, { 'Факт': '5', 'Комментарий (данные)': 'залили' }, { 'Техника': 327 });
|
||||||
|
const [report] = buildImportedReports(makeCheck([row]), 51);
|
||||||
|
|
||||||
|
expect(report.nodes[0].data.cree9_comment).toEqual('залили');
|
||||||
|
expect(report.nodes[0].children?.[0].cree9_comment).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('отдельные строки идут после работ: сначала персонал, потом техника — как в форме', () => {
|
||||||
|
const technic = makeRow(2, { 'Количество': '1', 'Часы': '2', 'Тип часов': 'Часы ремонта' }, { 'Техника': 327 });
|
||||||
|
const profession = makeRow(3, { 'Количество': '2', 'Часы': '4', 'Тип часов': 'Часы простоя' }, { 'Профессия': 181 });
|
||||||
|
const [report] = buildImportedReports(makeCheck([technic, profession]), 51);
|
||||||
|
|
||||||
|
expect(report.nodes.slice(2).map((node) => node.data)).toEqual([
|
||||||
|
{ cree9_professionid: 181, cree9_count: 2, cree9_hours: 4, cree9_hourstype: 'Часы простоя' },
|
||||||
|
{ cree9_technicid: 327, cree9_count: 1, cree9_hours: 2, cree9_hourstype: 'Часы ремонта' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('разные компании или даты — разные отчёты', () => {
|
||||||
|
const otherCompany = makeRow(3, {}, { 'Компания': 163 });
|
||||||
|
const otherDate = makeRow(4, { 'Дата': '02.10.2026 5:00:00' }, {});
|
||||||
|
const reports = buildImportedReports(makeCheck([technicRow, otherCompany, otherDate]), 51);
|
||||||
|
|
||||||
|
expect(reports.map((report) => report.rowNumbers)).toEqual([[2], [3], [4]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('объект — самый глубокий заполненный уровень', () => {
|
||||||
|
const row = makeRow(2, {}, { 'Объект 3': 1600 });
|
||||||
|
expect(buildImportedReports(makeCheck([row]), 51)[0].payload.cree9_objectid).toEqual(1600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('нет компании — ошибка с номером строки, ничего не собирается', () => {
|
||||||
|
const row = makeRow(7, {}, { 'Компания': undefined });
|
||||||
|
expect(() => buildImportedReports(makeCheck([row]), 51)).toThrow('Строка 7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('строка без вида работы — ошибка (сервер такие не пропускает, но не пишем молча)', () => {
|
||||||
|
const row = makeRow(5, {}, { 'Вид работы': undefined, 'Профессия': 181 });
|
||||||
|
expect(() => buildImportedReports(makeCheck([row]), 51)).toThrow('Строка 5: не определён вид работы.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('разбор значений Excel', () => {
|
||||||
|
it('дата: дд.мм.гггг с временем → гггг-мм-дд', () => {
|
||||||
|
expect(toIsoDate('01.10.2026 5:00:00')).toEqual('2026-10-01');
|
||||||
|
expect(toIsoDate(null)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { IReportDataFields, IReportDataNode, IReportPayload } from '../report/reportTypes';
|
||||||
|
import { HOURS_TYPES } from '../report/reportConstants';
|
||||||
|
import { IExcelImportCheck, IExcelImportRow } from '../_excelImport/excelImportTypes';
|
||||||
|
import { deepestObjectId, idNumber, textValue, toNumber } from '../_excelImport/excelImportValues';
|
||||||
|
import { IMPORT_COLUMNS as COL } from './reportImportColumns';
|
||||||
|
import { REPORT_IMPORT_TEXTS } from './texts';
|
||||||
|
|
||||||
|
// Ответ проверки → отчёты в том же виде, в каком их сохраняет форма «Отправить отчёт».
|
||||||
|
/* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */
|
||||||
|
|
||||||
|
/** Отчёт к записи. Номер назначается при сохранении (наибольший в проекте + 1). */
|
||||||
|
export interface IImportedReport {
|
||||||
|
payload: Omit<IReportPayload, 'cree9_number'>;
|
||||||
|
nodes: IReportDataNode[];
|
||||||
|
/** Строки Excel, из которых собран отчёт, — для сообщений пользователю. */
|
||||||
|
rowNumbers: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «01.10.2026 5:00:00» → «2026-10-01» (формат даты, который пишет форма). Время отбрасываем. */
|
||||||
|
export function toIsoDate(value: string | null): string | undefined {
|
||||||
|
const match = /^(\d{2})\.(\d{2})\.(\d{4})/.exec(value || '');
|
||||||
|
return match ? `${match[3]}-${match[2]}-${match[1]}` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(row: IExcelImportRow, projectId: number): IImportedReport['payload'] {
|
||||||
|
const reportDate = toIsoDate(row.values[COL.date]);
|
||||||
|
const companyId = idNumber(row, COL.company);
|
||||||
|
const shiftId = idNumber(row, COL.shift);
|
||||||
|
const objectId = deepestObjectId(row, COL.objects);
|
||||||
|
// Сервер уже проверил строку; если поля всё же нет — не пишем отчёт без шапки.
|
||||||
|
if (!reportDate || !companyId || !shiftId || !objectId) {
|
||||||
|
throw new Error(REPORT_IMPORT_TEXTS.build.missingHeader(row.row));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
cree9_report_date: reportDate,
|
||||||
|
cree9_companyid: companyId,
|
||||||
|
cree9_shiftid: shiftId,
|
||||||
|
cree9_objectid: objectId,
|
||||||
|
cree9_projectid: projectId,
|
||||||
|
cree9_comment: textValue(row, COL.reportComment)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Персонал и техника строки (порядок как в форме: сначала персонал). */
|
||||||
|
function buildResources(row: IExcelImportRow, hoursType: string): IReportDataFields[] {
|
||||||
|
const common = { cree9_count: toNumber(row.values[COL.count]), cree9_hours: toNumber(row.values[COL.hours]) };
|
||||||
|
const resources: IReportDataFields[] = [];
|
||||||
|
const professionId = idNumber(row, COL.profession);
|
||||||
|
const technicId = idNumber(row, COL.technic);
|
||||||
|
if (professionId) resources.push({ cree9_professionid: professionId, ...common, cree9_hourstype: hoursType });
|
||||||
|
if (technicId) resources.push({ cree9_technicid: technicId, ...common, cree9_hourstype: hoursType });
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Узлы одной строки Excel: своя строка-работа на каждую строку (вид работы сервер требует всегда).
|
||||||
|
* Ресурс с «Часы работы» — под работу, комментарий строки — работе.
|
||||||
|
* Простой / ремонт — отдельной строкой, как в форме (под работой форма хранит только часы работы),
|
||||||
|
* и комментарий строки — ей: в форме это причина простоя. Пустой комментарий импорт не блокирует —
|
||||||
|
* сервер такой файл принимает; обязательность из формы сработает, только когда отчёт будут править.
|
||||||
|
*/
|
||||||
|
function buildRowNodes(row: IExcelImportRow): { work: IReportDataNode; general: IReportDataFields[] } {
|
||||||
|
const jobId = row.ids[COL.job];
|
||||||
|
if (typeof jobId !== 'string') throw new Error(REPORT_IMPORT_TEXTS.build.missingJob(row.row));
|
||||||
|
|
||||||
|
const hoursType = textValue(row, COL.hoursType) || HOURS_TYPES.work;
|
||||||
|
const resources = buildResources(row, hoursType);
|
||||||
|
const comment = textValue(row, COL.rowComment);
|
||||||
|
const isWorkHours = hoursType === HOURS_TYPES.work;
|
||||||
|
const work: IReportDataNode = {
|
||||||
|
data: {
|
||||||
|
_cree9_jobid_value: jobId,
|
||||||
|
cree9_value: toNumber(row.values[COL.fact]),
|
||||||
|
cree9_comment: isWorkHours ? comment : undefined
|
||||||
|
},
|
||||||
|
children: isWorkHours && resources.length > 0 ? resources : undefined
|
||||||
|
};
|
||||||
|
const general = isWorkHours ? [] : resources.map((item) => ({ ...item, cree9_comment: comment }));
|
||||||
|
return { work, general };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImportBuildResult = { kind: 'ok'; reports: IImportedReport[] } | { kind: 'error'; message: string };
|
||||||
|
|
||||||
|
/** Сборка без исключений — для показа на экране до записи. */
|
||||||
|
export function tryBuildImportedReports(check: IExcelImportCheck, projectId: number): ImportBuildResult {
|
||||||
|
try {
|
||||||
|
return { kind: 'ok', reports: buildImportedReports(check, projectId) };
|
||||||
|
} catch (error) {
|
||||||
|
return { kind: 'error', message: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportKey(payload: IImportedReport['payload']): string {
|
||||||
|
return [payload.cree9_report_date, payload.cree9_companyid, payload.cree9_shiftid, payload.cree9_objectid].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IReportDraft {
|
||||||
|
report: IImportedReport;
|
||||||
|
works: IReportDataNode[];
|
||||||
|
general: IReportDataFields[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строки с одинаковыми датой, компанией, сменой и объектом — один отчёт.
|
||||||
|
* Порядок узлов как в форме: работы, затем общий персонал, затем общая техника.
|
||||||
|
*/
|
||||||
|
export function buildImportedReports(check: IExcelImportCheck, projectId: number): IImportedReport[] {
|
||||||
|
const drafts = new Map<string, IReportDraft>();
|
||||||
|
check.rows.forEach((row) => {
|
||||||
|
const payload = buildPayload(row, projectId);
|
||||||
|
const key = reportKey(payload);
|
||||||
|
const draft = drafts.get(key) ?? { report: { payload, nodes: [], rowNumbers: [] }, works: [], general: [] };
|
||||||
|
const { work, general } = buildRowNodes(row);
|
||||||
|
draft.works.push(work);
|
||||||
|
draft.general.push(...general);
|
||||||
|
draft.report.rowNumbers.push(row.row);
|
||||||
|
drafts.set(key, draft);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(drafts.values()).map(({ report, works, general }) => ({
|
||||||
|
...report,
|
||||||
|
nodes: [
|
||||||
|
...works,
|
||||||
|
...general.filter((item) => item.cree9_professionid).map((data) => ({ data })),
|
||||||
|
...general.filter((item) => item.cree9_technicid).map((data) => ({ data }))
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Заголовки колонок Excel-формы отчёта — ключи values / ids в ответе проверки.
|
||||||
|
|
||||||
|
export const IMPORT_COLUMNS = {
|
||||||
|
date: 'Дата',
|
||||||
|
company: 'Компания',
|
||||||
|
shift: 'Смена',
|
||||||
|
objects: ['Объект 1', 'Объект 2', 'Объект 3'],
|
||||||
|
job: 'Вид работы',
|
||||||
|
fact: 'Факт',
|
||||||
|
technic: 'Техника',
|
||||||
|
profession: 'Профессия',
|
||||||
|
count: 'Количество',
|
||||||
|
hours: 'Часы',
|
||||||
|
hoursType: 'Тип часов',
|
||||||
|
reportComment: 'Комментарий',
|
||||||
|
rowComment: 'Комментарий (данные)'
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { reportImportUrl } from './reportImportRepository';
|
||||||
|
|
||||||
|
describe('импорт отчётов: адрес проверки', () => {
|
||||||
|
it('как в Swagger', () => {
|
||||||
|
expect(reportImportUrl('https://api.tnsu.kz', 51)).toEqual('https://api.tnsu.kz/Import/StroyBot/Reports?projectId=51');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function reportImportUrl(apiBaseUrl: string, projectId: number): string {
|
||||||
|
return `${apiBaseUrl}/Import/StroyBot/Reports?projectId=${projectId}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { IReportDataNode, IReportPayload } from '../report/reportTypes';
|
||||||
|
import { IImportedReport } from './reportImportBuilder';
|
||||||
|
import { ImportReportWriter, saveImportedReports } from './reportImportSave';
|
||||||
|
|
||||||
|
function makeReport(date: string, rowNumbers: number[]): IImportedReport {
|
||||||
|
return {
|
||||||
|
payload: {
|
||||||
|
cree9_report_date: date,
|
||||||
|
cree9_companyid: 29,
|
||||||
|
cree9_shiftid: 1,
|
||||||
|
cree9_objectid: 1517,
|
||||||
|
cree9_projectid: 51,
|
||||||
|
cree9_comment: undefined
|
||||||
|
},
|
||||||
|
nodes: [{ data: { _cree9_jobid_value: 'job', cree9_value: 5 } }],
|
||||||
|
rowNumbers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writer, который выдаёт номера с 10 и запоминает, что записано. failOnCall — номер вызова сохранения с ошибкой. */
|
||||||
|
function makeWriter(failOnCall?: number): ImportReportWriter & { saved: IReportPayload[]; nodes: IReportDataNode[][] } {
|
||||||
|
let nextNumber = 10;
|
||||||
|
const writer = {
|
||||||
|
saved: [] as IReportPayload[],
|
||||||
|
nodes: [] as IReportDataNode[][],
|
||||||
|
getNextReportNumber: async (): Promise<number> => nextNumber++,
|
||||||
|
saveReportWithData: async (report: IReportPayload, nodes: IReportDataNode[]): Promise<string> => {
|
||||||
|
if (writer.saved.length + 1 === failOnCall) throw new Error('Dataverse недоступен');
|
||||||
|
writer.saved.push(report);
|
||||||
|
writer.nodes.push(nodes);
|
||||||
|
return `id-${report.cree9_number}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return writer;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('запись импортированных отчётов', () => {
|
||||||
|
it('по одному, каждому — свой номер; узлы передаются как есть', async () => {
|
||||||
|
const writer = makeWriter();
|
||||||
|
const progress: number[] = [];
|
||||||
|
const reports = [makeReport('2026-10-01', [2, 3, 4]), makeReport('2026-10-02', [5])];
|
||||||
|
|
||||||
|
const result = await saveImportedReports(writer, 51, reports, (count) => progress.push(count));
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
saved: [
|
||||||
|
{ reportNumber: 10, reportDate: '2026-10-01', rowNumbers: [2, 3, 4] },
|
||||||
|
{ reportNumber: 11, reportDate: '2026-10-02', rowNumbers: [5] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(writer.saved.map((report) => report.cree9_number)).toEqual([10, 11]);
|
||||||
|
expect(writer.nodes[0]).toEqual(reports[0].nodes);
|
||||||
|
expect(progress).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ошибка останавливает запись и возвращает уже записанные отчёты', async () => {
|
||||||
|
const writer = makeWriter(2);
|
||||||
|
const reports = [makeReport('2026-10-01', [2]), makeReport('2026-10-02', [3]), makeReport('2026-10-03', [4])];
|
||||||
|
|
||||||
|
const result = await saveImportedReports(writer, 51, reports, () => undefined);
|
||||||
|
|
||||||
|
expect(result.saved.map((report) => report.reportNumber)).toEqual([10]);
|
||||||
|
expect(result.errorMessage).toEqual('Dataverse недоступен');
|
||||||
|
expect(result.failedRows).toEqual([3]);
|
||||||
|
expect(writer.saved).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { IDataverseReportRepository } from '../report/dataverseReportRepository';
|
||||||
|
import { IImportedReport } from './reportImportBuilder';
|
||||||
|
|
||||||
|
/** Нужная импорту часть репозитория формы отчёта — тот же код записи, что у «Отправить отчёт». */
|
||||||
|
export type ImportReportWriter = Pick<IDataverseReportRepository, 'getNextReportNumber' | 'saveReportWithData'>;
|
||||||
|
|
||||||
|
export interface ISavedImportReport {
|
||||||
|
reportNumber: number;
|
||||||
|
/** «гггг-мм-дд» */
|
||||||
|
reportDate: string;
|
||||||
|
rowNumbers: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IImportSaveResult {
|
||||||
|
saved: ISavedImportReport[];
|
||||||
|
/** Есть — запись остановилась на отчёте из строк failedRows. */
|
||||||
|
errorMessage?: string;
|
||||||
|
failedRows?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Записывает отчёты по одному: номер выдаётся перед каждым (наибольший в проекте + 1),
|
||||||
|
* поэтому параллельно нельзя — номера совпали бы. Ошибка останавливает запись; уже записанные
|
||||||
|
* отчёты возвращаем, чтобы пользователь не загрузил тот же файл целиком повторно.
|
||||||
|
*/
|
||||||
|
export async function saveImportedReports(
|
||||||
|
writer: ImportReportWriter,
|
||||||
|
projectId: number,
|
||||||
|
reports: IImportedReport[],
|
||||||
|
onProgress: (savedCount: number) => void
|
||||||
|
): Promise<IImportSaveResult> {
|
||||||
|
const saved: ISavedImportReport[] = [];
|
||||||
|
for (const report of reports) {
|
||||||
|
try {
|
||||||
|
const reportNumber = await writer.getNextReportNumber(projectId);
|
||||||
|
await writer.saveReportWithData({ ...report.payload, cree9_number: reportNumber }, report.nodes);
|
||||||
|
saved.push({ reportNumber, reportDate: report.payload.cree9_report_date, rowNumbers: report.rowNumbers });
|
||||||
|
onProgress(saved.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка записи импортированного отчёта:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
return { saved, errorMessage, failedRows: report.rowNumbers };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { saved };
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Тексты раздела «Импорт отчёта из Excel».
|
||||||
|
|
||||||
|
export const REPORT_IMPORT_TEXTS = {
|
||||||
|
headerLabel: 'Импорт отчёта из Excel',
|
||||||
|
noProject: 'Проект не выбран. Выберите проект на главной.',
|
||||||
|
noAccess: 'Импорт отчётов недоступен: нужно право редактирования, проект не должен быть архивным или завершённым.',
|
||||||
|
intro:
|
||||||
|
'Загрузите заполненную форму ежедневного отчёта (.xlsx) — файл сразу проверится. ' +
|
||||||
|
'Ошибки подсветятся в таблице; если ошибок нет, отчёты можно будет импортировать.',
|
||||||
|
import: {
|
||||||
|
button: (reportCount: number): string => `Импортировать (отчётов: ${reportCount})`,
|
||||||
|
saving: (savedCount: number, total: number): string => `Сохраняем отчёты… ${savedCount} из ${total}`,
|
||||||
|
confirmTitle: 'Импорт отчётов',
|
||||||
|
confirmMessage: (reportCount: number, projectTitle: string): string =>
|
||||||
|
`Будет создано отчётов: ${reportCount} в проекте «${projectTitle}». Продолжить?`,
|
||||||
|
confirm: 'Импортировать',
|
||||||
|
cancel: 'Отмена',
|
||||||
|
savedReport: (reportNumber: number, date: string): string => `№${reportNumber} за ${date}`,
|
||||||
|
success: (list: string): string => `Импорт завершён. Созданы отчёты: ${list}.`,
|
||||||
|
failed: (rows: string, details: string): string => `Не удалось записать отчёт из строк ${rows}: ${details}`,
|
||||||
|
partlySaved: (list: string): string =>
|
||||||
|
`Уже созданы отчёты: ${list}. Не загружайте этот файл повторно целиком — уберите из него эти строки, ` +
|
||||||
|
'иначе отчёты задвоятся.',
|
||||||
|
mayBePartial:
|
||||||
|
'Отчёт, на котором произошла ошибка, мог записаться частично — проверьте «Отчёты по проекту» ' +
|
||||||
|
'перед повторной загрузкой.',
|
||||||
|
openReports: 'Открыть «Отчёты по проекту»'
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
missingHeader: (row: number): string => `Строка ${row}: не определены дата, компания, смена или объект.`,
|
||||||
|
missingJob: (row: number): string => `Строка ${row}: не определён вид работы.`
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useAppServices } from '../../app/AppServicesContext';
|
||||||
|
import { createDataverseReportRepository } from '../report/dataverseReportRepository';
|
||||||
|
import { IImportedReport } from './reportImportBuilder';
|
||||||
|
import { IImportSaveResult, saveImportedReports } from './reportImportSave';
|
||||||
|
|
||||||
|
export type ReportImportStatus =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'saving'; savedCount: number; total: number }
|
||||||
|
| { kind: 'finished'; result: IImportSaveResult };
|
||||||
|
|
||||||
|
/** Запись собранных отчётов в Dataverse с прогрессом. reset — при выборе нового файла. */
|
||||||
|
export function useReportImport(projectId: number | undefined): {
|
||||||
|
status: ReportImportStatus;
|
||||||
|
importReports: (reports: IImportedReport[]) => Promise<void>;
|
||||||
|
reset: () => void;
|
||||||
|
} {
|
||||||
|
const services = useAppServices();
|
||||||
|
const [status, setStatus] = React.useState<ReportImportStatus>({ kind: 'idle' });
|
||||||
|
const isMountedRef = React.useRef<boolean>(true);
|
||||||
|
|
||||||
|
React.useEffect(
|
||||||
|
() => () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const finish = (result: IImportSaveResult): void => {
|
||||||
|
if (isMountedRef.current) setStatus({ kind: 'finished', result });
|
||||||
|
};
|
||||||
|
|
||||||
|
const importReports = async (reports: IImportedReport[]): Promise<void> => {
|
||||||
|
if (projectId === undefined) return;
|
||||||
|
const total = reports.length;
|
||||||
|
setStatus({ kind: 'saving', savedCount: 0, total });
|
||||||
|
const writer = createDataverseReportRepository(services.dataverse, services.settings.dataverseUrl);
|
||||||
|
const result = await saveImportedReports(writer, projectId, reports, (savedCount) => {
|
||||||
|
if (isMountedRef.current) setStatus({ kind: 'saving', savedCount, total });
|
||||||
|
});
|
||||||
|
finish(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = React.useCallback((): void => setStatus({ kind: 'idle' }), []);
|
||||||
|
|
||||||
|
return { status, importReports, reset };
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Badge, Icon } from '@tnsu/ui-kit';
|
import { Badge, Icon } from '@tnsu/ui-kit';
|
||||||
import { useRouter } from '../../app/routing/RouterContext';
|
import { useRouter } from '../../app/routing/RouterContext';
|
||||||
|
import { IRoute } from '../../app/routing/routes';
|
||||||
import { IReportAccess, IReportCard } from './reportsTypes';
|
import { IReportAccess, IReportCard } from './reportsTypes';
|
||||||
import { canCreateReport, canDeleteReport } from './reportsRules';
|
import { canCreateReport, canDeleteReport } from './reportsRules';
|
||||||
import { REPORTS_TEXTS } from './texts';
|
import { REPORTS_TEXTS } from './texts';
|
||||||
@@ -17,16 +18,26 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Карточка отчёта. Отчёт и «Создать на основе» открываются в новой вкладке — как в старом
|
* Карточка отчёта. Отчёт и «Создать на основе» открываются в этой же вкладке — переходом
|
||||||
* web part (window.open … '_blank'), но адрес теперь — раздел формы отчёта этого приложения.
|
* на раздел формы отчёта без перезагрузки страницы.
|
||||||
*/
|
*/
|
||||||
export const ReportCard: React.FC<Props> = ({ report, access, projectId, onDelete }) => {
|
export const ReportCard: React.FC<Props> = ({ report, access, projectId, onDelete }) => {
|
||||||
const { buildHref } = useRouter();
|
const { navigate, buildHref } = useRouter();
|
||||||
const reportHref = buildHref({ page: 'report', projectId, reportId: report.id });
|
const reportRoute: IRoute = { page: 'report', projectId, reportId: report.id };
|
||||||
const copyHref = buildHref({ page: 'report', projectId, copyFromId: report.id });
|
const copyRoute: IRoute = { page: 'report', projectId, copyFromId: report.id };
|
||||||
|
|
||||||
const openReport = (): void => {
|
const openReport = (): void => {
|
||||||
window.open(reportHref, '_blank', 'noopener,noreferrer');
|
navigate(reportRoute);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ссылка остаётся настоящим <a href>, чтобы Ctrl+клик / средняя кнопка по-прежнему
|
||||||
|
// открывали копию в новой вкладке; обычный клик — переход внутри приложения.
|
||||||
|
const onCopyClick = (event: React.MouseEvent<HTMLAnchorElement>): void => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const opensNewTab = event.ctrlKey || event.metaKey || event.shiftKey || event.button !== 0;
|
||||||
|
if (opensNewTab) return;
|
||||||
|
event.preventDefault();
|
||||||
|
navigate(copyRoute);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Как в старом коде: Enter/Space внутри карточки (в том числе на её кнопках) открывают отчёт.
|
// Как в старом коде: Enter/Space внутри карточки (в том числе на её кнопках) открывают отчёт.
|
||||||
@@ -65,12 +76,10 @@ export const ReportCard: React.FC<Props> = ({ report, access, projectId, onDelet
|
|||||||
{canCreateReport(access) && (
|
{canCreateReport(access) && (
|
||||||
<a
|
<a
|
||||||
className={styles.cardAction}
|
className={styles.cardAction}
|
||||||
href={copyHref}
|
href={buildHref(copyRoute)}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
title={TEXTS.copyTitle(report.number)}
|
title={TEXTS.copyTitle(report.number)}
|
||||||
aria-label={TEXTS.copyAriaLabel}
|
aria-label={TEXTS.copyAriaLabel}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={onCopyClick}
|
||||||
>
|
>
|
||||||
<Icon name="copy" size="sm" />
|
<Icon name="copy" size="sm" />
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ export interface IReportListDeps {
|
|||||||
dataverse: IHttpClient;
|
dataverse: IHttpClient;
|
||||||
siteUrl: string;
|
siteUrl: string;
|
||||||
dataverseUrl: string;
|
dataverseUrl: string;
|
||||||
userId: number | undefined;
|
/** Id пользователя на сайте со списками (не на сайте страницы) — см. IAppServices.getCurrentUserId. */
|
||||||
|
getCurrentUserId: () => Promise<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Справочники для подписей карточек: компания по Id, путь объекта. */
|
/** Справочники для подписей карточек: компания по Id, путь объекта. */
|
||||||
@@ -49,7 +50,8 @@ export async function loadReportList(
|
|||||||
projectId: number,
|
projectId: number,
|
||||||
range: IDateRange
|
range: IDateRange
|
||||||
): Promise<ReportListInitResult> {
|
): Promise<ReportListInitResult> {
|
||||||
const accessResult = await fetchReportAccess(deps.sharePoint, deps.siteUrl, deps.userId, projectId);
|
const userId = await deps.getCurrentUserId();
|
||||||
|
const accessResult = await fetchReportAccess(deps.sharePoint, deps.siteUrl, userId, projectId);
|
||||||
if (!accessResult) return { kind: 'denied' };
|
if (!accessResult) return { kind: 'denied' };
|
||||||
|
|
||||||
const base = { kind: 'ready' as const, projectName: accessResult.projectName, access: accessResult.access };
|
const base = { kind: 'ready' as const, projectName: accessResult.projectName, access: accessResult.access };
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ describe('loadReportList', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
const deps = { sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, userId: 42 };
|
const deps = { sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, getCurrentUserId: async () => 42 };
|
||||||
|
|
||||||
const result = await loadReportList(deps, 5, WEEK);
|
const result = await loadReportList(deps, 5, WEEK);
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ describe('loadReportList', () => {
|
|||||||
it('нет доступа — denied, справочники не грузятся', async () => {
|
it('нет доступа — denied, справочники не грузятся', async () => {
|
||||||
const sharePoint = createFakeHttpClient([{ match: 'Доступы', response: { body: { value: [] } } }]);
|
const sharePoint = createFakeHttpClient([{ match: 'Доступы', response: { body: { value: [] } } }]);
|
||||||
const dataverse = createFakeHttpClient([]);
|
const dataverse = createFakeHttpClient([]);
|
||||||
const result = await loadReportList({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, userId: 42 }, 5, WEEK);
|
const result = await loadReportList({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, getCurrentUserId: async () => 42 }, 5, WEEK);
|
||||||
expect(result).toEqual({ kind: 'denied' });
|
expect(result).toEqual({ kind: 'denied' });
|
||||||
expect(sharePoint.requests).toHaveLength(1);
|
expect(sharePoint.requests).toHaveLength(1);
|
||||||
});
|
});
|
||||||
@@ -182,7 +182,7 @@ describe('loadReportList', () => {
|
|||||||
it('ошибка отчётов — экран с «Ошибка инициализации: …» и без карточек', async () => {
|
it('ошибка отчётов — экран с «Ошибка инициализации: …» и без карточек', async () => {
|
||||||
const sharePoint = createFakeHttpClient([access, companies, objects]);
|
const sharePoint = createFakeHttpClient([access, companies, objects]);
|
||||||
const dataverse = createFakeHttpClient([{ match: 'cree9_reports', response: { status: 500, body: 'fail' } }]);
|
const dataverse = createFakeHttpClient([{ match: 'cree9_reports', response: { status: 500, body: 'fail' } }]);
|
||||||
const result = await loadReportList({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, userId: 42 }, 5, WEEK);
|
const result = await loadReportList({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, getCurrentUserId: async () => 42 }, 5, WEEK);
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
kind: 'ready',
|
kind: 'ready',
|
||||||
reports: undefined,
|
reports: undefined,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export interface IReportListState {
|
|||||||
|
|
||||||
/** Данные страницы отчётов: первая загрузка, «Применить» и удаление. */
|
/** Данные страницы отчётов: первая загрузка, «Применить» и удаление. */
|
||||||
export function useReportList(projectId: number, initialRange: IDateRange): IReportListState {
|
export function useReportList(projectId: number, initialRange: IDateRange): IReportListState {
|
||||||
const { sharePoint, dataverse, settings, page } = useAppServices();
|
const { sharePoint, dataverse, settings, getCurrentUserId } = useAppServices();
|
||||||
const [phase, setPhase] = React.useState<ReportListPhase>('loading');
|
const [phase, setPhase] = React.useState<ReportListPhase>('loading');
|
||||||
const [projectName, setProjectName] = React.useState<string>('');
|
const [projectName, setProjectName] = React.useState<string>('');
|
||||||
const [access, setAccess] = React.useState<IReportAccess>(NO_ACCESS);
|
const [access, setAccess] = React.useState<IReportAccess>(NO_ACCESS);
|
||||||
@@ -44,9 +44,9 @@ export function useReportList(projectId: number, initialRange: IDateRange): IRep
|
|||||||
dataverse,
|
dataverse,
|
||||||
siteUrl: settings.siteUrl,
|
siteUrl: settings.siteUrl,
|
||||||
dataverseUrl: settings.dataverseUrl,
|
dataverseUrl: settings.dataverseUrl,
|
||||||
userId: page.userId
|
getCurrentUserId
|
||||||
}),
|
}),
|
||||||
[sharePoint, dataverse, settings.siteUrl, settings.dataverseUrl, page.userId]
|
[sharePoint, dataverse, settings.siteUrl, settings.dataverseUrl, getCurrentUserId]
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Badge } from '@tnsu/ui-kit';
|
||||||
|
import { isNormalAssessment } from './weatherFormat';
|
||||||
|
import { Nullable } from './weatherTypes';
|
||||||
|
import { EmptyValue } from './EmptyValue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
assessment: Nullable<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Оценка ИИ: «В пределах нормы» — зелёная, любая другая — предупреждение. До оценки — прочерк. */
|
||||||
|
export const AssessmentBadge: React.FC<Props> = ({ assessment }) => {
|
||||||
|
if (!assessment) return <EmptyValue />;
|
||||||
|
return <Badge variant={isNormalAssessment(assessment) ? 'success' : 'warning'}>{assessment}</Badge>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Badge } from '@tnsu/ui-kit';
|
||||||
|
import { IWeatherPosition } from './weatherTypes';
|
||||||
|
import { EmptyValue } from './EmptyValue';
|
||||||
|
import { WEATHER_TEXTS } from './texts';
|
||||||
|
import styles from './WeatherPage.module.scss';
|
||||||
|
|
||||||
|
const TEXTS = WEATHER_TEXTS.positions;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
position: IWeatherPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Акт простоя»: рекомендован (с причиной) / не требуется / нет оценки. */
|
||||||
|
export const DowntimeActCell: React.FC<Props> = ({ position }) => {
|
||||||
|
if (position.downtime_act_recommended === false) {
|
||||||
|
return <Badge variant="success">{TEXTS.downtimeNotRequired}</Badge>;
|
||||||
|
}
|
||||||
|
if (position.downtime_act_recommended !== true) return <EmptyValue />;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Badge variant="danger">{TEXTS.downtimeRecommended}</Badge>
|
||||||
|
{position.downtime_act_reason && <div className={styles.reason}>{position.downtime_act_reason}</div>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { WEATHER_TEXTS } from './texts';
|
||||||
|
import styles from './WeatherPage.module.scss';
|
||||||
|
|
||||||
|
/** Серый прочерк вместо пустого значения в таблицах сверки. */
|
||||||
|
export const EmptyValue: React.FC = () => <span className={styles.muted}>{WEATHER_TEXTS.empty}</span>;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { formatWeatherDate, formatWeatherNumber } from './weatherFormat';
|
||||||
|
import { IWeatherPosition } from './weatherTypes';
|
||||||
|
import { PositionWeatherCell } from './PositionWeatherCell';
|
||||||
|
import { AssessmentBadge } from './AssessmentBadge';
|
||||||
|
import { DowntimeActCell } from './DowntimeActCell';
|
||||||
|
import { EmptyValue } from './EmptyValue';
|
||||||
|
import { WEATHER_TEXTS } from './texts';
|
||||||
|
import styles from './WeatherPage.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
position: IWeatherPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строка таблицы «Позиции и оценка условий труда». */
|
||||||
|
export const PositionRow: React.FC<Props> = ({ position }) => (
|
||||||
|
<tr>
|
||||||
|
<td className={styles.nowrap}>{formatWeatherDate(position.date)}</td>
|
||||||
|
<td>
|
||||||
|
<div className={styles.strong}>{position.profession || WEATHER_TEXTS.empty}</div>
|
||||||
|
{position.work_type && <div className={styles.muted}>{position.work_type}</div>}
|
||||||
|
</td>
|
||||||
|
<td className={styles.nowrap}>
|
||||||
|
<div>{position.shift || WEATHER_TEXTS.empty}</div>
|
||||||
|
<div className={styles.muted}>
|
||||||
|
{position.shift_start || ''}–{position.shift_end || ''}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className={styles.num}>{formatWeatherNumber(position.hours)}</td>
|
||||||
|
<td>{position.hours_type || <EmptyValue />}</td>
|
||||||
|
<td className={styles.num}>{formatWeatherNumber(position.headcount)}</td>
|
||||||
|
<td>
|
||||||
|
<PositionWeatherCell weather={position.weather} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<AssessmentBadge assessment={position.assessment} />
|
||||||
|
</td>
|
||||||
|
<td className={styles.comment}>{position.comment || <EmptyValue />}</td>
|
||||||
|
<td>
|
||||||
|
<DowntimeActCell position={position} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { formatVisibility, formatWeatherNumber } from './weatherFormat';
|
||||||
|
import { IPositionWeather, Nullable } from './weatherTypes';
|
||||||
|
import { WeatherPhenomena } from './WeatherPhenomena';
|
||||||
|
import { WEATHER_TEXTS } from './texts';
|
||||||
|
import styles from './WeatherPage.module.scss';
|
||||||
|
|
||||||
|
const TEXTS = WEATHER_TEXTS.positions.weather;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
weather: Nullable<IPositionWeather>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Погода в смену»: сводка наблюдений за часы смены позиции. */
|
||||||
|
export const PositionWeatherCell: React.FC<Props> = ({ weather }) => {
|
||||||
|
if (!weather) {
|
||||||
|
return <span className={styles.muted}>{TEXTS.noData}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasGust = weather.gust_max !== null && weather.gust_max !== undefined;
|
||||||
|
return (
|
||||||
|
<div className={styles.weatherCell}>
|
||||||
|
<div>{TEXTS.temperature(formatWeatherNumber(weather.temp_min), formatWeatherNumber(weather.temp_max))}</div>
|
||||||
|
<div>
|
||||||
|
{TEXTS.wind(formatWeatherNumber(weather.wind_max))}
|
||||||
|
{hasGust && TEXTS.gust(formatWeatherNumber(weather.gust_max))}
|
||||||
|
</div>
|
||||||
|
<div>{TEXTS.precipitation(formatWeatherNumber(weather.precipitation))}</div>
|
||||||
|
<div>{TEXTS.dewpoint(formatWeatherNumber(weather.dewpoint_max))}</div>
|
||||||
|
<div>{TEXTS.visibility(formatVisibility(weather.visibility_min))}</div>
|
||||||
|
<div className={styles.muted}>{TEXTS.observations(formatWeatherNumber(weather.observations))}</div>
|
||||||
|
<WeatherPhenomena phenomena={weather.weather} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { formatWeatherDate, formatWeatherNumber } from './weatherFormat';
|
||||||
|
import { IWeatherDay } from './weatherTypes';
|
||||||
|
import { WeatherPhenomena } from './WeatherPhenomena';
|
||||||
|
import { WEATHER_TEXTS } from './texts';
|
||||||
|
import styles from './WeatherPage.module.scss';
|
||||||
|
|
||||||
|
const TEXTS = WEATHER_TEXTS.days;
|
||||||
|
const COLUMNS = TEXTS.columns;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
days: IWeatherDay[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Погода по дням»: наблюдения метеостанции за каждый день периода. */
|
||||||
|
export const WeatherDaysTable: React.FC<Props> = ({ days }) => {
|
||||||
|
if (days.length === 0) {
|
||||||
|
return <div className={styles.state}>{TEXTS.empty}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tableWrap}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{COLUMNS.date}</th>
|
||||||
|
<th className={styles.num}>{COLUMNS.observations}</th>
|
||||||
|
<th className={styles.num}>{COLUMNS.tempMin}</th>
|
||||||
|
<th className={styles.num}>{COLUMNS.tempMax}</th>
|
||||||
|
<th className={styles.num}>{COLUMNS.wind}</th>
|
||||||
|
<th className={styles.num}>{COLUMNS.gust}</th>
|
||||||
|
<th className={styles.num}>{COLUMNS.precipitation}</th>
|
||||||
|
<th>{COLUMNS.phenomena}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{days.map((day) => (
|
||||||
|
<tr key={day.date}>
|
||||||
|
<td className={styles.nowrap}>{formatWeatherDate(day.date)}</td>
|
||||||
|
<td className={styles.num}>{formatWeatherNumber(day.observations)}</td>
|
||||||
|
<td className={styles.num}>{formatWeatherNumber(day.temp_min)}</td>
|
||||||
|
<td className={styles.num}>{formatWeatherNumber(day.temp_max)}</td>
|
||||||
|
<td className={styles.num}>{formatWeatherNumber(day.wind_max)}</td>
|
||||||
|
<td className={styles.num}>{formatWeatherNumber(day.gust_max)}</td>
|
||||||
|
<td className={styles.num}>{formatWeatherNumber(day.precipitation)}</td>
|
||||||
|
<td>
|
||||||
|
<WeatherPhenomena phenomena={day.weather} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Icon } from '@tnsu/ui-kit';
|
||||||
|
import { formatWeatherPeriod } from './weatherFormat';
|
||||||
|
import { WEATHER_TEXTS } from './texts';
|
||||||
|
import styles from './WeatherPage.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectTitle: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ссылка «Разделы проекта», название проекта и период сверки. */
|
||||||
|
export const WeatherHeader: React.FC<Props> = ({ projectTitle, start, end, onBack }) => {
|
||||||
|
const period = formatWeatherPeriod(start, end);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className={styles.backLink}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onBack();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="chevron-left" size="sm" /> {WEATHER_TEXTS.back}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<header className={styles.header}>
|
||||||
|
<div className={styles.headerLabel}>{WEATHER_TEXTS.headerLabel}</div>
|
||||||
|
<h2 className={styles.headerTitle}>{projectTitle}</h2>
|
||||||
|
{period && (
|
||||||
|
<div className={styles.headerMeta}>
|
||||||
|
{WEATHER_TEXTS.periodPrefix} {period}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Icon } from '@tnsu/ui-kit';
|
||||||
|
import styles from './WeatherPage.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подсказка, почему сверку не загрузить (нет проекта / периода). */
|
||||||
|
export const WeatherHint: React.FC<Props> = ({ text }) => (
|
||||||
|
<div className={styles.hint}>
|
||||||
|
<Icon name="info" size="sm" /> {text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Размеры из сверки погоды stroy-bot-main.
|
||||||
|
// Таблицы сверки широкие — странице даём больше места, чем главной.
|
||||||
|
$page-max-width: 1500px;
|
||||||
|
$station-icon-size: 40px;
|
||||||
|
$phenomenon-min-width: 160px;
|
||||||
|
$phenomenon-max-width: 240px;
|
||||||
|
$comment-min-width: 240px;
|
||||||
|
$comment-max-width: 380px;
|
||||||
|
|
||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-5;
|
||||||
|
width: 100%;
|
||||||
|
max-width: $page-max-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backLink {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
align-self: flex-start;
|
||||||
|
gap: $sp-1;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $gray-500;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $gold-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
@include page-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerLabel {
|
||||||
|
margin-bottom: $sp-1;
|
||||||
|
font-size: $fs-overline;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1.6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $gold-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-family: $font-serif;
|
||||||
|
font-size: $fs-page-title;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerMeta {
|
||||||
|
margin-top: $sp-2;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: rgba(255, 255, 255, 0.7); // белый с прозрачностью — второстепенный текст на тёмной шапке
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
padding: $sp-3 $sp-4;
|
||||||
|
border: 1px solid $gold-100;
|
||||||
|
border-radius: $r-md;
|
||||||
|
background: $gold-50;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $amber-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-3;
|
||||||
|
padding: $sp-3 $sp-4;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-md;
|
||||||
|
box-shadow: $sh-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stationIcon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: $station-icon-size;
|
||||||
|
height: $station-icon-size;
|
||||||
|
border: 1px solid $navy-100;
|
||||||
|
border-radius: $r-md;
|
||||||
|
background: $navy-50;
|
||||||
|
color: $navy-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stationName {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionRow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $sp-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionLabel {
|
||||||
|
margin: 0;
|
||||||
|
font-family: $font-serif;
|
||||||
|
font-size: $fs-section-title;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hairline {
|
||||||
|
@include hairline;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonContent {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-md;
|
||||||
|
box-shadow: $sh-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: $sp-2 $sp-3;
|
||||||
|
border-bottom: 1px solid $gray-200;
|
||||||
|
background: $gray-50;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: $sp-2 $sp-3;
|
||||||
|
border-bottom: 1px solid $gray-100;
|
||||||
|
vertical-align: top;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover td {
|
||||||
|
background: $gold-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Колонки с числами: выравнивание вправо, перебивает text-align: left у th.
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nowrap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
font-size: $fs-label;
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weatherCell {
|
||||||
|
font-size: $fs-label;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phenomenon {
|
||||||
|
min-width: $phenomenon-min-width;
|
||||||
|
max-width: $phenomenon-max-width;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
white-space: normal;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
min-width: $comment-min-width;
|
||||||
|
max-width: $comment-max-width;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason {
|
||||||
|
min-width: $phenomenon-min-width;
|
||||||
|
margin-top: $sp-1;
|
||||||
|
font-size: $fs-label;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
padding: $sp-5;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-md;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assessSummary {
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $green-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: $sp-4;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: $gray-700;
|
||||||
|
|
||||||
|
li + li {
|
||||||
|
margin-top: $sp-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Alert, Spinner } from '@tnsu/ui-kit';
|
||||||
|
import { useRouter } from '../../app/routing/RouterContext';
|
||||||
|
import { useProject } from '../../app/project/ProjectContext';
|
||||||
|
import { parseWeatherPeriod } from './weatherFormat';
|
||||||
|
import { useWeatherCheck } from './useWeatherCheck';
|
||||||
|
import { useWeatherAssessment } from './useWeatherAssessment';
|
||||||
|
import { WeatherHeader } from './WeatherHeader';
|
||||||
|
import { WeatherReport } from './WeatherReport';
|
||||||
|
import { WeatherHint } from './WeatherHint';
|
||||||
|
import { WEATHER_TEXTS } from './texts';
|
||||||
|
import styles from './WeatherPage.module.scss';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сверка погодных условий (из stroy-bot-main): `?page=weather&projectId=&start=YYYY-MM-DD&end=YYYY-MM-DD`.
|
||||||
|
* В main это был отдельный вид той же web part по хэшу #weathercheck.
|
||||||
|
*/
|
||||||
|
export const WeatherPage: React.FC = () => {
|
||||||
|
const { route, navigate } = useRouter();
|
||||||
|
const { currentProject, currentProjectId } = useProject();
|
||||||
|
const period = parseWeatherPeriod(route.start, route.end);
|
||||||
|
const { status, setData } = useWeatherCheck(currentProject?.id, period);
|
||||||
|
const data = status.kind === 'ready' ? status.data : undefined;
|
||||||
|
const assessment = useWeatherAssessment(data, setData);
|
||||||
|
|
||||||
|
// Назад — на главную с теми же датами, чтобы не выбирать период заново (как в main).
|
||||||
|
const goBack = (): void => navigate({ page: 'hub', projectId: currentProjectId, start: route.start, end: route.end });
|
||||||
|
|
||||||
|
const renderBody = (): React.ReactNode => {
|
||||||
|
if (!currentProject) return <WeatherHint text={WEATHER_TEXTS.noProject} />;
|
||||||
|
if (!period) return <WeatherHint text={WEATHER_TEXTS.noPeriod} />;
|
||||||
|
if (status.kind === 'error') return <Alert variant="err">{status.message}</Alert>;
|
||||||
|
if (status.kind === 'ready') return <WeatherReport data={status.data} assessment={assessment} />;
|
||||||
|
return (
|
||||||
|
<div className={styles.state}>
|
||||||
|
<Spinner label={WEATHER_TEXTS.loading} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<WeatherHeader
|
||||||
|
projectTitle={data?.project || currentProject?.title || ''}
|
||||||
|
start={data?.start_date || period?.start || ''}
|
||||||
|
end={data?.end_date || period?.end || ''}
|
||||||
|
onBack={goBack}
|
||||||
|
/>
|
||||||
|
{renderBody()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Nullable } from './weatherTypes';
|
||||||
|
import styles from './WeatherPage.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
phenomena: Nullable<string[]> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Погодные явления (дождь, туман…) — каждое отдельной плашкой. */
|
||||||
|
export const WeatherPhenomena: React.FC<Props> = ({ phenomena }) => {
|
||||||
|
if (!phenomena || phenomena.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{phenomena.map((phenomenon, index) => (
|
||||||
|
<div key={`${index}-${phenomenon}`} className={styles.phenomenon}>
|
||||||
|
{phenomenon}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Alert, Button, Spinner } from '@tnsu/ui-kit';
|
||||||
|
import { InlineIcon } from '../../shared/components/InlineIcon';
|
||||||
|
import { IWeatherPosition } from './weatherTypes';
|
||||||
|
import { IWeatherAssessment } from './useWeatherAssessment';
|
||||||
|
import { PositionRow } from './PositionRow';
|
||||||
|
import { WEATHER_TEXTS } from './texts';
|
||||||
|
import styles from './WeatherPage.module.scss';
|
||||||
|
|
||||||
|
const TEXTS = WEATHER_TEXTS.positions;
|
||||||
|
const COLUMNS = TEXTS.columns;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
positions: IWeatherPosition[];
|
||||||
|
assessment: IWeatherAssessment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Позиции и оценка условий труда»: кнопка оценки ИИ, её итог и таблица позиций. */
|
||||||
|
export const WeatherPositionsSection: React.FC<Props> = ({ positions, assessment }) => {
|
||||||
|
const hasPositions = positions.length > 0;
|
||||||
|
const { isAssessing, errorMessage, summary, requestAssessment } = assessment;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={styles.section}>
|
||||||
|
<div className={styles.sectionRow}>
|
||||||
|
<h3 className={styles.sectionLabel}>{TEXTS.title}</h3>
|
||||||
|
<Button variant="secondary" onClick={requestAssessment} disabled={!hasPositions || isAssessing}>
|
||||||
|
{isAssessing ? (
|
||||||
|
<Spinner size="xSmall" label={TEXTS.assessLoading} />
|
||||||
|
) : (
|
||||||
|
<span className={styles.buttonContent}>
|
||||||
|
<InlineIcon name="sparkle" />
|
||||||
|
{TEXTS.assessButton}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<hr className={styles.hairline} />
|
||||||
|
|
||||||
|
{errorMessage && <Alert variant="err">{errorMessage}</Alert>}
|
||||||
|
{!errorMessage && summary && (
|
||||||
|
<div className={styles.assessSummary}>{TEXTS.assessSummary(summary.assessed, summary.skipped)}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasPositions && <div className={styles.state}>{TEXTS.empty}</div>}
|
||||||
|
{hasPositions && (
|
||||||
|
<div className={styles.tableWrap}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{COLUMNS.date}</th>
|
||||||
|
<th>{COLUMNS.profession}</th>
|
||||||
|
<th>{COLUMNS.shift}</th>
|
||||||
|
<th className={styles.num}>{COLUMNS.hours}</th>
|
||||||
|
<th>{COLUMNS.hoursType}</th>
|
||||||
|
<th className={styles.num}>{COLUMNS.headcount}</th>
|
||||||
|
<th>{COLUMNS.weather}</th>
|
||||||
|
<th>{COLUMNS.assessment}</th>
|
||||||
|
<th>{COLUMNS.comment}</th>
|
||||||
|
<th>{COLUMNS.downtime}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{positions.map((position, index) => (
|
||||||
|
// У позиции нет id: строка — профессия в смену, порядок задаёт API.
|
||||||
|
<PositionRow key={`${position.date}-${position.shift}-${position.profession}-${index}`} position={position} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { IWeatherCheck } from './weatherTypes';
|
||||||
|
import { IWeatherAssessment } from './useWeatherAssessment';
|
||||||
|
import { WeatherStation } from './WeatherStation';
|
||||||
|
import { WeatherDaysTable } from './WeatherDaysTable';
|
||||||
|
import { WeatherPositionsSection } from './WeatherPositionsSection';
|
||||||
|
import { WEATHER_TEXTS } from './texts';
|
||||||
|
import styles from './WeatherPage.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: IWeatherCheck;
|
||||||
|
assessment: IWeatherAssessment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Загруженная сверка: метеостанция, погода по дням, позиции с оценкой, примечания API. */
|
||||||
|
export const WeatherReport: React.FC<Props> = ({ data, assessment }) => {
|
||||||
|
const notes = data.notes || [];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<WeatherStation data={data} />
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h3 className={styles.sectionLabel}>{WEATHER_TEXTS.days.title}</h3>
|
||||||
|
<hr className={styles.hairline} />
|
||||||
|
<WeatherDaysTable days={data.days || []} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<WeatherPositionsSection positions={data.positions || []} assessment={assessment} />
|
||||||
|
|
||||||
|
{notes.length > 0 && (
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h3 className={styles.sectionLabel}>{WEATHER_TEXTS.notesTitle}</h3>
|
||||||
|
<hr className={styles.hairline} />
|
||||||
|
<ul className={styles.notes}>
|
||||||
|
{notes.map((note, index) => (
|
||||||
|
<li key={`${index}-${note}`}>{note}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { InlineIcon } from '../../shared/components/InlineIcon';
|
||||||
|
import { formatWeatherNumber } from './weatherFormat';
|
||||||
|
import { IWeatherCheck } from './weatherTypes';
|
||||||
|
import { WEATHER_TEXTS } from './texts';
|
||||||
|
import styles from './WeatherPage.module.scss';
|
||||||
|
|
||||||
|
const TEXTS = WEATHER_TEXTS.station;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: IWeatherCheck;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Метеостанция, по которой сверяется погода, и её удалённость от объекта. */
|
||||||
|
export const WeatherStation: React.FC<Props> = ({ data }) => {
|
||||||
|
const distance =
|
||||||
|
data.wigos_station_distance_km !== null && data.wigos_station_distance_km !== undefined
|
||||||
|
? TEXTS.distance(formatWeatherNumber(data.wigos_station_distance_km))
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.station}>
|
||||||
|
<span className={styles.stationIcon}>
|
||||||
|
<InlineIcon name="cloud" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className={styles.stationName}>{TEXTS.name(data.wigos_station_name || WEATHER_TEXTS.empty)}</div>
|
||||||
|
<div className={styles.muted}>
|
||||||
|
{TEXTS.code(data.wigos_station || WEATHER_TEXTS.empty)}
|
||||||
|
{distance}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Тексты сверки погодных условий — дословно из stroy-bot-main.
|
||||||
|
|
||||||
|
export const WEATHER_TEXTS = {
|
||||||
|
openButton: 'Сверка погодных условий',
|
||||||
|
back: 'Разделы проекта',
|
||||||
|
headerLabel: 'Сверка погодных условий',
|
||||||
|
periodPrefix: 'Период:',
|
||||||
|
noProject: 'Проект не выбран. Вернитесь назад и выберите проект.',
|
||||||
|
noPeriod: 'Период не указан. Вернитесь назад и выберите даты начала и конца.',
|
||||||
|
loading: 'Загрузка данных…',
|
||||||
|
loadError: (details: string): string => `Не удалось загрузить данные: ${details}`,
|
||||||
|
station: {
|
||||||
|
name: (name: string): string => `Метеостанция ${name}`,
|
||||||
|
code: (code: string): string => `WIGOS ${code}`,
|
||||||
|
distance: (km: string): string => ` · ${km} км от объекта`
|
||||||
|
},
|
||||||
|
days: {
|
||||||
|
title: 'Погода по дням',
|
||||||
|
empty: 'Нет данных наблюдений за период',
|
||||||
|
columns: {
|
||||||
|
date: 'Дата',
|
||||||
|
observations: 'Наблюдений',
|
||||||
|
tempMin: 'T мин, °C',
|
||||||
|
tempMax: 'T макс, °C',
|
||||||
|
wind: 'Ветер, м/с',
|
||||||
|
gust: 'Порывы, м/с',
|
||||||
|
precipitation: 'Осадки, мм',
|
||||||
|
phenomena: 'Явления'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
positions: {
|
||||||
|
title: 'Позиции и оценка условий труда',
|
||||||
|
empty: 'Нет позиций за период',
|
||||||
|
assessButton: 'Запросить оценку ИИ по международным стандартам',
|
||||||
|
assessLoading: 'Идёт оценка, это может занять до минуты…',
|
||||||
|
assessError: (details: string): string => `Не удалось получить оценку: ${details}`,
|
||||||
|
assessSummary: (assessed: number, skipped: number): string =>
|
||||||
|
`Оценено позиций: ${assessed}` + (skipped ? `, пропущено: ${skipped}` : ''),
|
||||||
|
columns: {
|
||||||
|
date: 'Дата',
|
||||||
|
profession: 'Профессия / вид работ',
|
||||||
|
shift: 'Смена',
|
||||||
|
hours: 'Часы',
|
||||||
|
hoursType: 'Тип работ',
|
||||||
|
headcount: 'Чел.',
|
||||||
|
weather: 'Погода в смену',
|
||||||
|
assessment: 'Оценка',
|
||||||
|
comment: 'Комментарий',
|
||||||
|
downtime: 'Акт простоя'
|
||||||
|
},
|
||||||
|
weather: {
|
||||||
|
temperature: (min: string, max: string): string => `T ${min}…${max} °C`,
|
||||||
|
wind: (wind: string): string => `Ветер ${wind} м/с`,
|
||||||
|
gust: (gust: string): string => `, порывы ${gust} м/с`,
|
||||||
|
precipitation: (value: string): string => `Осадки ${value} мм`,
|
||||||
|
dewpoint: (value: string): string => `Точка росы до ${value} °C`,
|
||||||
|
visibility: (value: string): string => `Видимость от ${value}`,
|
||||||
|
observations: (value: string): string => `Наблюдений: ${value}`,
|
||||||
|
noData: 'нет данных'
|
||||||
|
},
|
||||||
|
downtimeRecommended: 'Рекомендован',
|
||||||
|
downtimeNotRequired: 'Не требуется'
|
||||||
|
},
|
||||||
|
notesTitle: 'Примечания',
|
||||||
|
empty: '—'
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useAppServices } from '../../app/AppServicesContext';
|
||||||
|
import { requestWeatherAssessment } from './weatherRepository';
|
||||||
|
import { IWeatherCheck } from './weatherTypes';
|
||||||
|
import { weatherErrorDetails } from './useWeatherCheck';
|
||||||
|
import { WEATHER_TEXTS } from './texts';
|
||||||
|
|
||||||
|
export interface IWeatherAssessment {
|
||||||
|
isAssessing: boolean;
|
||||||
|
errorMessage: string;
|
||||||
|
summary: { assessed: number; skipped: number } | undefined;
|
||||||
|
requestAssessment: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Какая сверка на экране: другой проект или период — другой ключ. */
|
||||||
|
function loadKeyOf(data: IWeatherCheck | undefined): string {
|
||||||
|
return data ? `${data.project_id}|${data.start_date}|${data.end_date}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Запросить оценку ИИ»: отправляет позиции и подменяет их ответом.
|
||||||
|
* Ответ, пришедший после ухода со страницы или для другого периода, игнорируется (как в main).
|
||||||
|
*/
|
||||||
|
export function useWeatherAssessment(
|
||||||
|
data: IWeatherCheck | undefined,
|
||||||
|
setData: (update: (data: IWeatherCheck) => IWeatherCheck) => void
|
||||||
|
): IWeatherAssessment {
|
||||||
|
const services = useAppServices();
|
||||||
|
const [isAssessing, setIsAssessing] = React.useState<boolean>(false);
|
||||||
|
const [errorMessage, setErrorMessage] = React.useState<string>('');
|
||||||
|
const [summary, setSummary] = React.useState<IWeatherAssessment['summary']>(undefined);
|
||||||
|
const loadKey = loadKeyOf(data);
|
||||||
|
const loadKeyRef = React.useRef<string>(loadKey);
|
||||||
|
loadKeyRef.current = loadKey;
|
||||||
|
|
||||||
|
// Новая сверка — итоги прошлой оценки к ней не относятся.
|
||||||
|
React.useEffect(() => {
|
||||||
|
setIsAssessing(false);
|
||||||
|
setErrorMessage('');
|
||||||
|
setSummary(undefined);
|
||||||
|
}, [loadKey]);
|
||||||
|
|
||||||
|
// После ухода со страницы ключ пустой — поздний ответ не тронет state.
|
||||||
|
React.useEffect(
|
||||||
|
() => () => {
|
||||||
|
loadKeyRef.current = '';
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestAssessment = (): void => {
|
||||||
|
if (!data || data.positions.length === 0 || isAssessing) return;
|
||||||
|
const requestedKey = loadKey;
|
||||||
|
const isStillCurrent = (): boolean => loadKeyRef.current === requestedKey;
|
||||||
|
|
||||||
|
setIsAssessing(true);
|
||||||
|
setErrorMessage('');
|
||||||
|
requestWeatherAssessment(services.tnsuApi, services.settings.apiBaseUrl, data.positions)
|
||||||
|
.then((result) => {
|
||||||
|
if (!isStillCurrent()) return;
|
||||||
|
if (result.positions && result.positions.length > 0) {
|
||||||
|
setData((prev) => ({ ...prev, positions: result.positions }));
|
||||||
|
}
|
||||||
|
setSummary({ assessed: result.assessed, skipped: result.skipped });
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error('Ошибка оценки ИИ:', error);
|
||||||
|
if (isStillCurrent()) setErrorMessage(WEATHER_TEXTS.positions.assessError(weatherErrorDetails(error)));
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (isStillCurrent()) setIsAssessing(false);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { isAssessing, errorMessage, summary, requestAssessment };
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useAppServices } from '../../app/AppServicesContext';
|
||||||
|
import { HttpError } from '../../services/http/IHttpClient';
|
||||||
|
import { fetchWeatherCheck } from './weatherRepository';
|
||||||
|
import { IWeatherCheck, IWeatherPeriod } from './weatherTypes';
|
||||||
|
import { WEATHER_TEXTS } from './texts';
|
||||||
|
|
||||||
|
export type WeatherLoadStatus =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'loading' }
|
||||||
|
| { kind: 'error'; message: string }
|
||||||
|
| { kind: 'ready'; data: IWeatherCheck };
|
||||||
|
|
||||||
|
/** Текст ошибки как в main: «статус текст» для ответа API, иначе сообщение ошибки. */
|
||||||
|
export function weatherErrorDetails(error: unknown): string {
|
||||||
|
if (error instanceof HttpError) return `${error.status} ${error.statusText}`;
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка сверки погоды за период. Без проекта или периода — ничего не грузит (idle).
|
||||||
|
* setData — для замены позиций после оценки ИИ.
|
||||||
|
*/
|
||||||
|
export function useWeatherCheck(
|
||||||
|
projectId: number | undefined,
|
||||||
|
period: IWeatherPeriod | undefined
|
||||||
|
): { status: WeatherLoadStatus; setData: (update: (data: IWeatherCheck) => IWeatherCheck) => void } {
|
||||||
|
const services = useAppServices();
|
||||||
|
const [status, setStatus] = React.useState<WeatherLoadStatus>({ kind: 'idle' });
|
||||||
|
const start = period?.start;
|
||||||
|
const end = period?.end;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (projectId === undefined || !start || !end) {
|
||||||
|
setStatus({ kind: 'idle' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let isCancelled = false;
|
||||||
|
setStatus({ kind: 'loading' });
|
||||||
|
fetchWeatherCheck(services.tnsuApi, services.settings.apiBaseUrl, projectId, { start, end })
|
||||||
|
.then((data) => {
|
||||||
|
if (!isCancelled) setStatus({ kind: 'ready', data });
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error('Ошибка загрузки сверки погоды:', error);
|
||||||
|
if (!isCancelled) setStatus({ kind: 'error', message: WEATHER_TEXTS.loadError(weatherErrorDetails(error)) });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [services, projectId, start, end]);
|
||||||
|
|
||||||
|
const setData = React.useCallback((update: (data: IWeatherCheck) => IWeatherCheck): void => {
|
||||||
|
setStatus((prev) => (prev.kind === 'ready' ? { kind: 'ready', data: update(prev.data) } : prev));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { status, setData };
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
formatVisibility,
|
||||||
|
formatWeatherDate,
|
||||||
|
formatWeatherNumber,
|
||||||
|
formatWeatherPeriod,
|
||||||
|
isNormalAssessment,
|
||||||
|
parseWeatherPeriod
|
||||||
|
} from './weatherFormat';
|
||||||
|
|
||||||
|
describe('parseWeatherPeriod', () => {
|
||||||
|
it('корректный период', () => {
|
||||||
|
expect(parseWeatherPeriod('2026-10-01', '2026-10-07')).toEqual({ start: '2026-10-01', end: '2026-10-07' });
|
||||||
|
expect(parseWeatherPeriod('2026-10-01', '2026-10-01')).toEqual({ start: '2026-10-01', end: '2026-10-01' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('нет даты, неверный формат или начало позже конца — периода нет', () => {
|
||||||
|
expect(parseWeatherPeriod(undefined, '2026-10-07')).toBeUndefined();
|
||||||
|
expect(parseWeatherPeriod('01.10.2026', '2026-10-07')).toBeUndefined();
|
||||||
|
expect(parseWeatherPeriod('2026-10-08', '2026-10-07')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('форматирование', () => {
|
||||||
|
it('дата ДД.ММ.ГГГГ, хвост со временем отбрасывается, мусор — как есть', () => {
|
||||||
|
expect(formatWeatherDate('2026-10-05')).toEqual('05.10.2026');
|
||||||
|
expect(formatWeatherDate('2026-10-05T08:00:00')).toEqual('05.10.2026');
|
||||||
|
expect(formatWeatherDate('вчера')).toEqual('вчера');
|
||||||
|
expect(formatWeatherDate('')).toEqual('—');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('период: один день — одна дата', () => {
|
||||||
|
expect(formatWeatherPeriod('2026-10-01', '2026-10-07')).toEqual('01.10.2026 — 07.10.2026');
|
||||||
|
expect(formatWeatherPeriod('2026-10-01', '2026-10-01')).toEqual('01.10.2026');
|
||||||
|
expect(formatWeatherPeriod('', '')).toEqual('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('число с запятой, пустое — прочерк, ноль — ноль', () => {
|
||||||
|
expect(formatWeatherNumber(-3.5)).toEqual('-3,5');
|
||||||
|
expect(formatWeatherNumber(0)).toEqual('0');
|
||||||
|
expect(formatWeatherNumber(null)).toEqual('—');
|
||||||
|
expect(formatWeatherNumber(undefined)).toEqual('—');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('видимость: от 1000 м — км с одним знаком', () => {
|
||||||
|
expect(formatVisibility(800)).toEqual('800 м');
|
||||||
|
expect(formatVisibility(1000)).toEqual('1 км');
|
||||||
|
expect(formatVisibility(1450)).toEqual('1,5 км');
|
||||||
|
expect(formatVisibility(10000)).toEqual('10 км');
|
||||||
|
expect(formatVisibility(null)).toEqual('—');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('«В пределах нормы» — норма, без учёта регистра и пробелов', () => {
|
||||||
|
expect(isNormalAssessment(' В пределах нормы ')).toBe(true);
|
||||||
|
expect(isNormalAssessment('Неблагоприятные условия')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { IWeatherPeriod, Nullable } from './weatherTypes';
|
||||||
|
|
||||||
|
// Форматирование значений сверки погоды — как в stroy-bot-main.
|
||||||
|
|
||||||
|
const EMPTY_VALUE = '—';
|
||||||
|
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
const METERS_PER_KM = 1000;
|
||||||
|
const NORMAL_ASSESSMENT = 'в пределах нормы';
|
||||||
|
|
||||||
|
/** Период из адреса. undefined — дат нет, формат не YYYY-MM-DD или начало позже конца. */
|
||||||
|
export function parseWeatherPeriod(start: string | undefined, end: string | undefined): IWeatherPeriod | undefined {
|
||||||
|
if (!start || !end || !ISO_DATE.test(start) || !ISO_DATE.test(end) || start > end) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YYYY-MM-DD… → ДД.ММ.ГГГГ. Нераспознанное значение показывается как есть. */
|
||||||
|
export function formatWeatherDate(iso: string): string {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso || '');
|
||||||
|
return match ? `${match[3]}.${match[2]}.${match[1]}` : iso || EMPTY_VALUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «01.10.2026 — 07.10.2026», один день — одна дата. */
|
||||||
|
export function formatWeatherPeriod(start: string, end: string): string {
|
||||||
|
if (!start) return '';
|
||||||
|
return end && end !== start ? `${formatWeatherDate(start)} — ${formatWeatherDate(end)}` : formatWeatherDate(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Число с запятой, пустое значение — прочерк. */
|
||||||
|
export function formatWeatherNumber(value: Nullable<number> | undefined): string {
|
||||||
|
return value === null || value === undefined ? EMPTY_VALUE : String(value).replace('.', ',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Видимость: от 1000 м — в километрах с одним знаком (1450 → «1,5 км»), меньше — в метрах. */
|
||||||
|
export function formatVisibility(meters: Nullable<number> | undefined): string {
|
||||||
|
if (meters === null || meters === undefined) return EMPTY_VALUE;
|
||||||
|
if (meters < METERS_PER_KM) return `${meters} м`;
|
||||||
|
const kmWithOneDecimal = Math.round(meters / 100) / 10;
|
||||||
|
return `${formatWeatherNumber(kmWithOneDecimal)} км`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNormalAssessment(assessment: string): boolean {
|
||||||
|
return assessment.trim().toLowerCase() === NORMAL_ASSESSMENT;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user