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",
|
||||
"port": 4321,
|
||||
"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. Общее (оболочка)
|
||||
|
||||
> 🔧 **Исправлено при проверке 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=…`
|
||||
- [ ] Кнопки «назад/вперёд» браузера переключают разделы
|
||||
- [ ] Ссылку на любой раздел можно скопировать и открыть в новой вкладке
|
||||
@@ -73,6 +75,28 @@
|
||||
- Адрес выгрузки берётся из настройки «Базовый URL API» (по умолчанию тот же `https://api.tnsu.kz`).
|
||||
- Меню слева скрывает разделы по правилам карточек: архивный — только «Проект»; завершён или нет `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=`
|
||||
@@ -89,13 +113,15 @@
|
||||
- [ ] Модалка: «Название *» (обязательно, Enter — сохранить), «Родительский объект» (select только при правке Раздела: узлы уровня 0, сортировка ru, без себя и потомков), «Проект» (read-only)
|
||||
- [ ] Заголовки модалки: «Добавить Объект-пятно / Раздел / Вид работ», «Редактировать …», «— (корневой)»
|
||||
- [ ] Удаление: подтверждение с числом дочерних, «Это действие нельзя отменить.»
|
||||
- [ ] Drag & drop (только `canEdit`): до / внутрь / после, запрет на себя и потомков, ошибка «Не удалось переместить: …»
|
||||
- [-] ~~Drag & drop~~ — **убрано по решению 2026-10-16** (неудобно, зона «внутрь» не работала, лишние случайные переносы). Родителя Раздела меняют через «Редактировать».
|
||||
- [ ] «Работы» → раздел Работы с `objectId`
|
||||
- [ ] После любой операции дерево перезагружается
|
||||
- [ ] Список «Объекты»: `Title`, `project` (текст = название проекта), `parent` (self-lookup)
|
||||
- [ ] Права: «Доступы» `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:**
|
||||
- `alert()` → сообщение `Alert` на странице (ошибки переноса, удаления, экспорта) или в модалке (ошибка сохранения). Тексты те же.
|
||||
@@ -119,7 +145,7 @@
|
||||
- [ ] Навигационное свойство родителя определяется через `EntityDefinitions` (fallback `cree9_parentid`)
|
||||
- [ ] Проект находится подъёмом по родителям объекта до непустого `project`
|
||||
|
||||
🐞 Путь объекта (`#headerTitle`) не выводится. 🐞 Нет проверки «начало ≤ окончание». 🐞 Отступы в списке «Родительская работа» — обычные пробелы, браузер их схлопывает (как было).
|
||||
🐞 Путь объекта (`#headerTitle`) не выводится. 🐞 Отступы в списке «Родительская работа» — обычные пробелы, браузер их схлопывает (как было).
|
||||
|
||||
**Сознательные отличия от works:**
|
||||
- `alert()` → сообщение `Alert`: ошибка сохранения — в модалке, ошибки удаления — над таблицей. Тексты те же.
|
||||
@@ -127,6 +153,8 @@
|
||||
- PATCH и DELETE идут через тот же AAD-клиент Dataverse, а не через `fetch` с токеном вручную; заголовки те же.
|
||||
- Адрес сайта — настройка «URL сайта SharePoint» (раньше `pageContext.web.absoluteUrl`), Dataverse — настройка «URL Dataverse»; имя таблицы — константа (раньше настройка `worksTable`).
|
||||
- Таблица — `DataTable` из ui-kit; меню ⋮ — общее меню из `pages/_tree`.
|
||||
- 🔧 **Исправлено при проверке T-WRK (2026-10-19):** дату окончания нельзя поставить раньше даты начала — календарь не даёт, а при сохранении ошибка «Дата окончания не может быть раньше даты начала» (в старом коде проверки не было).
|
||||
- ✨ **Улучшено по просьбе (2026-10-19):** «Ед. изм.» — список с поиском: можно начать печатать, Enter выбирает первое найденное. Выбрать можно только значение из справочника, как и раньше.
|
||||
|
||||
---
|
||||
|
||||
@@ -181,18 +209,25 @@
|
||||
|
||||
### Сохранение
|
||||
- [ ] Валидация (все сообщения из исходника)
|
||||
- [ ] Номер = количество неудалённых отчётов проекта + 1
|
||||
- [ ] Номер = наибольший номер в проекте (включая удалённые) + 1 — в старом коде «количество неудалённых + 1», см. 🔧 ниже
|
||||
- [ ] При правке — удалить все старые строки (сначала дочерние), создать заново
|
||||
- [ ] Фото → `DocLib/{projectId}_{number}`
|
||||
- [ ] «Отчет успешно сохранен!», кнопка меняется на «← Вернуться назад»
|
||||
- [ ] Утверждение: подтверждение → `cree9_aprooved = true` → «Отчет утвержден!»
|
||||
- [ ] Права: «Доступы» `projectId` + `userId*` (несколько вариантов имени поля)
|
||||
|
||||
🐞 Create без проверки NoAccess. 🐞 Номер может совпасть после мягкого удаления. 🐞 Дата «сегодня» — UTC.
|
||||
🐞 Create без проверки NoAccess. 🐞 Дата «сегодня» — UTC.
|
||||
|
||||
🔧 **Исправлено при проверке T-REP (2026-10-19):**
|
||||
- Только что созданная из пикера работа попадала в карточку с полным путём вместо названия, без ед. изм. и с планом 0 (в старом коде так же).
|
||||
- Окно выбора на экране шире 640px — по центру (было всегда прижато к низу), поле «Значение» не вылезает за карточку.
|
||||
- После «Утвердить отчет» бейдж оставался «На утверждении» до F5 — теперь сразу «Утверждён».
|
||||
- «← Вернуться назад» во вкладке, открытой из списка отчётов (истории нет), ничего не делал — теперь ведёт в «Отчёты по проекту».
|
||||
- Номер отчёта повторялся после мягкого удаления (номер = количество неудалённых + 1), а вместе с ним и папка фото `{projectId}_{номер}`. Теперь номер = наибольший номер в проекте, включая удалённые, + 1.
|
||||
|
||||
**Сознательные отличия от report:**
|
||||
- `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`.
|
||||
- Логика режимов, валидации, сборки строк и порядка записи / удаления сверена со старым кодом построчно и покрыта тестами (45 тестов).
|
||||
|
||||
@@ -241,6 +276,7 @@
|
||||
- Текст после префикса ошибки («Ошибка загрузки данных: …» и т.п.) — сообщение `HttpError` (`{status} {statusText}: {ответ сервера}`), а не формат ошибки PnP.
|
||||
- Счётчик во вкладке — часть подписи («Профессии (0)»): `count` в Tabs ui-kit скрывает ноль.
|
||||
- Кнопки «+» / «−» — `Button` ui-kit (с подсказками «Добавить» / «Удалить»), сообщение об ошибке закрывается крестиком рядом с `Alert`.
|
||||
- 🔧 **Исправлено (найдено при проверке T-RES, 2026-10-16):** перед добавлением проверяется, нет ли уже такой привязки на сервере. Раньше при двух открытых вкладках (или двух пользователях) одна профессия / компания добавлялась дважды. Если привязка уже есть — новая запись не создаётся, показывается существующая.
|
||||
|
||||
---
|
||||
|
||||
@@ -265,5 +301,6 @@
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- Схема списка **«Доступы»**: в репозиториях встречаются и поля `projectId`/`userId`, и lookup `user`/`project`. Проверить на сайте.
|
||||
- Схема списка **«Объекты»**: родитель читается то как `parentId`, то как `parent/Id`. Проверить на сайте.
|
||||
- ✅ Схема списка **«Доступы»** проверена на сайте (2026-10-16): `project` — Lookup на «Проекты», `user` — Person, права `read` / `edit0` / `aproove`. SharePoint отдаёт к ним `projectId` / `userId` — поэтому оба способа из старых репозиториев (`project/Id` + `$expand` и `projectId` / `userId`) обращаются к одним и тем же полям. Расхождения нет.
|
||||
- ✅ Схема списка **«Объекты»** проверена на сайте (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»**. Такие сценарии помечены ✍️.
|
||||
- Всё, что только **читает**, проверяем на настоящих проектах — **сравнением со старой страницей** (см. «Главный приём»).
|
||||
- Нашёл расхождение — не исправляй ничего сам, запиши по шаблону «Как сообщить о баге» и иди дальше.
|
||||
- **Оформление — в стиле ui-kit** (решение 2026-10-16). Отличия во внешнем виде от старых страниц (раскладка, цвета, регистр подписей) **багом не считаются**. Сравниваем функции, тексты, данные, права. Баг по виду — только если что-то не видно, обрезано, наезжает или не нажимается.
|
||||
- Отметки: `[x]` — совпало, `[!]` — баг (номер бага рядом), `[-]` — не применимо.
|
||||
|
||||
---
|
||||
@@ -60,8 +61,8 @@
|
||||
- [ ] **T-APP-03** Нажать «назад» в браузере → вернулись на главную. «Вперёд» → снова объекты.
|
||||
- [ ] **T-APP-04** Скопировать адрес раздела, открыть в новой вкладке → открылся тот же раздел того же проекта.
|
||||
- [ ] **T-APP-05** F5 на любом разделе → остались в нём.
|
||||
- [ ] **T-APP-06** Закрыть вкладку, открыть web part заново (без `projectId` в адресе) → проект восстановился из сессии.
|
||||
- [ ] **T-APP-07** Шрифты: заголовки — с засечками (Lora), текст — Inter. Сравнить «на глаз» со старым main.
|
||||
- [ ] **T-APP-06** В **той же вкладке** удалить из адреса `&page=…&projectId=…`, Enter, заново добавить web part → на главной уже выбран прежний проект, меню — со всеми разделами. (Выбор хранится в `sessionStorage` — он живёт только в пределах вкладки; после закрытия вкладки не сохраняется — так было и в старом main.)
|
||||
- [ ] **T-APP-07** Шрифты подгрузились: заголовки разделов — с засечками (Lora), остальной текст — Inter.
|
||||
- [ ] **T-APP-08** Узкое окно (< 768px) → меню прячется, появляется кнопка-бургер, меню открывается поверх.
|
||||
|
||||
## 2. Главная — `T-HUB` (сравнение со старым stroy-bot-main)
|
||||
@@ -72,13 +73,25 @@
|
||||
- [ ] **T-HUB-05** Без выбранного проекта все карточки неактивны, видна подсказка.
|
||||
- [ ] **T-HUB-06** «ТЕСТ StroyBot», роль Е, статус **текущий** → все 6 карточек активны; меню — все разделы.
|
||||
- [ ] **T-HUB-07** Роль **Р** → неактивна только «Отправить отчёт»; в меню нет «Отправить отчёт» и «Привязка к справочнику».
|
||||
- [ ] **T-HUB-08** Статус **завершен** (роль Е) → неактивна только «Отправить отчёт».
|
||||
- [ ] **T-HUB-09** Статус **архивный** → активен только «Архив», выгрузка неактивна, в меню только «Проект». Вернуть статус **текущий**.
|
||||
- [-] **T-HUB-08** Статус «завершен» — **не применимо**: в SharePoint у поля «Статус» только «Текущий» и «Архивный» (проверено 2026-10-16). Правило в коде остаётся на случай, если вариант появится.
|
||||
- [ ] **T-HUB-09** Статус **«Архивный»** (менять в SharePoint, список «Проекты») → бейдж золотой, активен только «Архив», выгрузка неактивна, в меню только «Проект». Вернуть **«Текущий»**.
|
||||
- ⚠️ Статус меняем **в списке SharePoint**, а не на портале проектов: портал пишет статус («Текущий» / «Архив») в справочник API, StroyBot его не читает.
|
||||
- [ ] **T-HUB-10** «Архив проекта» → в новой вкладке папка `Документы/Архив/{проект}` (та же, что открывает старый main).
|
||||
- [ ] **T-HUB-11** Ctrl+клик по карточке «Объекты» → раздел открылся в новой вкладке.
|
||||
- [ ] **T-HUB-12** Выгрузка: «?» показывает 3 строки и закрывается кликом мимо. Даты 13.10–19.10 → «Отчёт за период»; 12.10–18.10 (Пн–Вс) → «Еженедельный»; один день → «Ежедневный»; начало позже конца → ошибка, кнопка неактивна.
|
||||
- [ ] **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`
|
||||
- [ ] **T-RES-01** Большой проект: вкладки «Профессии (N)», «Компании (N)» — числа и списки совпадают со старой страницей.
|
||||
- [ ] **T-RES-02** Поиск слева, «✕» очищает; «уже добавлено» у привязанных.
|
||||
@@ -101,7 +114,7 @@
|
||||
- [ ] ✍️ **T-OBJ-05** Меню ⋮ у «Корпус 1» → «Добавить объект» → «Раздел» «Фундамент». У «Фундамент» → «Вид работ» «Бетонирование». У вида работ нет пункта «Добавить объект».
|
||||
- [ ] ✍️ **T-OBJ-06** Пустое название → сообщение «Поле обязательно…»; Enter сохраняет.
|
||||
- [ ] ✍️ **T-OBJ-07** Редактировать «Фундамент» → сменить родителя (нужен второй объект-пятно «Корпус 2»).
|
||||
- [ ] ✍️ **T-OBJ-08** Перетащить объект на другой → родитель сменился; на своего потомка — запрещено.
|
||||
- [-] **T-OBJ-08** Перетаскивание — **убрано** (решение 2026-10-16). Проверить, что строку дерева мышкой больше не перетащить.
|
||||
- [ ] ✍️ **T-OBJ-09** Удалить объект с детьми → подтверждение с числом детей; дети остаются (🐞 так было).
|
||||
- [ ] **T-OBJ-10** «Экспорт» → скачался `objects-{id}.xlsx`, содержимое как у старой страницы.
|
||||
- [ ] **T-OBJ-11** Меню ⋮ → «Работы» → открылся раздел «Работы» этого объекта.
|
||||
@@ -118,6 +131,9 @@
|
||||
- [ ] **T-WRK-09** Роль **Р** → нет кнопок добавления и меню ⋮.
|
||||
|
||||
## 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-02** Большой привязанный проект: «Загрузка работ… X из Y», затем число работ совпадает со старой страницей.
|
||||
- [ ] **T-IMP-03** Поиск (код СНБ, объём, название), страницы по 100.
|
||||
@@ -156,10 +172,57 @@
|
||||
- [ ] **T-REP-18** Роль **Р** → отчёт только для чтения, кнопок нет.
|
||||
- [ ] **T-REP-19** Роль **Н** → «Нет доступа» при открытии существующего отчёта.
|
||||
- [ ] **T-REP-20** Большой проект: открыть 3 разных старых отчёта → всё совпадает со старой формой (план-факт, персонал, техника, фото-папка).
|
||||
- Проверено на проекте 17: №2030 и ещё 2 отчёта совпали, режим правки тот же. Отчёт с персоналом/техникой **внутри работы** найти не удалось (прямого доступа к Dataverse нет) — вложенные строки проверены только на тестовом проекте (T-REP-10/11). Дополнительно: встретится такой отчёт — сверить со старой формой.
|
||||
- [ ] **T-REP-21** «← Вернуться назад» → вернулись туда, откуда пришли.
|
||||
- [ ] **T-REP-22** Enter в любом поле не перезагружает страницу.
|
||||
- [ ] ✍️ **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-property-pane": "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-dom": "17.0.1",
|
||||
"tslib": "2.3.1"
|
||||
@@ -6659,9 +6659,9 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@tnsu/ui-kit": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.12.0/ui-kit-0.12.0.tgz",
|
||||
"integrity": "sha512-lJYf2DoYaydrktbSotRNY0umeJlQw8Ekhh6fu1FGB0p+/TXTozpBfAs+TxK/mvOXlS9yYenEuGBi85fd/dRF2A==",
|
||||
"version": "0.14.0",
|
||||
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.14.0/ui-kit-0.14.0.tgz",
|
||||
"integrity": "sha512-Vun9AUM9jL8rttbsbKo5Yh6TUz1srkNqxiO6mmzebrSo1JOASNJqNjJPMTS+8Jt3MTrJPklvh0TbeqGQzw7qxQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@fluentui/react": "^8.106.4",
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
"@microsoft/sp-office-ui-fabric-core": "1.22.1",
|
||||
"@microsoft/sp-property-pane": "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-dom": "17.0.1",
|
||||
"tslib": "2.3.1"
|
||||
|
||||
@@ -37,6 +37,7 @@ export const AppShell: React.FC = () => {
|
||||
onToggleCollapsed={() => setIsCollapsed((prev) => !prev)}
|
||||
mobileOpen={isMobileMenuOpen}
|
||||
onMobileClose={() => setIsMobileMenuOpen(false)}
|
||||
dividers
|
||||
/>
|
||||
}
|
||||
mobileToggle={<SidebarToggle onClick={() => setIsMobileMenuOpen(true)} />}
|
||||
|
||||
@@ -8,6 +8,9 @@ import { ReportPage } from '../pages/report/ReportPage';
|
||||
import { ImportWorksPage } from '../pages/import/ImportWorksPage';
|
||||
import { ResourcesPage } from '../pages/resources/ResourcesPage';
|
||||
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=). */
|
||||
export const PageOutlet: React.FC = () => {
|
||||
@@ -28,6 +31,12 @@ export const PageOutlet: React.FC = () => {
|
||||
return <ResourcesPage />;
|
||||
case 'binder':
|
||||
return <BinderPage />;
|
||||
case 'weather':
|
||||
return <WeatherPage />;
|
||||
case 'reportImport':
|
||||
return <ReportImportPage />;
|
||||
case 'worksImport':
|
||||
return <WorksImportPage />;
|
||||
case 'hub':
|
||||
default:
|
||||
return <HubPage />;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { buildSidebarItems, navPageFor } from './navigation';
|
||||
import { buildSidebarItems, CATALOGS_GROUP, navPageFor } from './navigation';
|
||||
|
||||
describe('buildSidebarItems', () => {
|
||||
it('без проекта в меню только главная', () => {
|
||||
expect(buildSidebarItems(undefined).map((item) => item.id)).toEqual(['hub']);
|
||||
it('без проекта в меню только главная и справочники', () => {
|
||||
expect(buildSidebarItems(undefined).map((item) => item.id)).toEqual(['hub', 'catalogs']);
|
||||
});
|
||||
|
||||
it('с проектом — все разделы', () => {
|
||||
@@ -11,15 +11,34 @@ describe('buildSidebarItems', () => {
|
||||
'objects',
|
||||
'reports',
|
||||
'report',
|
||||
'reportImport',
|
||||
'import',
|
||||
'worksImport',
|
||||
'resources',
|
||||
'binder'
|
||||
'binder',
|
||||
'catalogs'
|
||||
]);
|
||||
});
|
||||
|
||||
it('недоступные разделы скрываются, главная остаётся всегда', () => {
|
||||
it('недоступные разделы скрываются, главная и справочники остаются всегда', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
it('сверка погоды подсвечивает главную', () => {
|
||||
expect(navPageFor('weather')).toEqual('hub');
|
||||
});
|
||||
|
||||
it('остальные разделы подсвечивают сами себя', () => {
|
||||
expect(navPageFor('reports')).toEqual('reports');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IconName, ISidebarItem } from '@tnsu/ui-kit';
|
||||
import { PageId } from './routing/routes';
|
||||
import { CATALOG_LINKS } from '../config/catalogLinks';
|
||||
|
||||
export interface INavItem {
|
||||
page: PageId;
|
||||
@@ -15,25 +16,44 @@ export const NAV_ITEMS: INavItem[] = [
|
||||
{ page: 'objects', title: 'Объекты', icon: 'folder', needsProject: true },
|
||||
{ page: 'reports', title: 'Отчёты по проекту', icon: 'clipboard', 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: 'worksImport', title: 'Импорт работ из Excel', icon: 'document', needsProject: true },
|
||||
{ page: 'resources', title: 'Ресурсы проекта', icon: 'people', needsProject: true },
|
||||
{ page: 'binder', title: 'Привязка к справочнику', icon: 'swap', needsProject: true }
|
||||
];
|
||||
|
||||
/** Пункт меню, который подсвечивается для раздела. Работы — подраздел объектов, форма отчёта — свой пункт. */
|
||||
/**
|
||||
* Пункт меню, который подсвечивается для раздела. Работы — подраздел объектов,
|
||||
* сверка погоды открывается с главной, форма отчёта — свой пункт.
|
||||
*/
|
||||
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 — правила доступа к разделу (статус проекта, права); по умолчанию всё доступно.
|
||||
*/
|
||||
export function buildSidebarItems(
|
||||
projectId: string | undefined,
|
||||
isAvailable: (page: PageId) => boolean = () => true
|
||||
): ISidebarItem[] {
|
||||
return NAV_ITEMS.filter((item) => !item.needsProject || (projectId !== undefined && isAvailable(item.page))).map(
|
||||
(item) => ({ id: item.page, title: item.title, icon: item.icon })
|
||||
);
|
||||
const sections = NAV_ITEMS.filter(
|
||||
(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 { fetchAccessibleProjects } from './projectRepository';
|
||||
import { readStoredProject, storeProject } from './projectSession';
|
||||
import { resolveCurrentProject } from './projectRules';
|
||||
|
||||
export type ProjectsLoadState = 'loading' | 'ready' | 'error';
|
||||
|
||||
@@ -51,13 +52,12 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
||||
if (isCancelled) return;
|
||||
setProjects(loaded);
|
||||
setLoadState('ready');
|
||||
// В сессии могли остаться устаревшие права — обновляем по свежим данным (как в main).
|
||||
// В сессии могли остаться устаревшие статус и права — сохраняем свежие.
|
||||
setStoredProject((stored) => {
|
||||
const fresh = stored ? findProject(loaded, stored.id) : undefined;
|
||||
if (!stored || !fresh) return stored;
|
||||
const updated = { ...stored, canEdit: fresh.canEdit };
|
||||
storeProject(updated);
|
||||
return updated;
|
||||
if (!fresh) return stored;
|
||||
storeProject(fresh);
|
||||
return fresh;
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -71,13 +71,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
||||
}, [services, reloadKey]);
|
||||
|
||||
const routeProjectId = route.projectId ? Number(route.projectId) : undefined;
|
||||
let currentProject: IAccessibleProject | undefined;
|
||||
if (routeProjectId === undefined) {
|
||||
currentProject = storedProject;
|
||||
} else {
|
||||
currentProject =
|
||||
findProject(projects, routeProjectId) ?? (storedProject?.id === routeProjectId ? storedProject : undefined);
|
||||
}
|
||||
const currentProject = resolveCurrentProject(projects, routeProjectId, storedProject);
|
||||
const currentProjectId = route.projectId ?? (storedProject ? String(storedProject.id) : undefined);
|
||||
|
||||
const selectProject = React.useCallback(
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isSectionAvailable,
|
||||
mergeAccessRows,
|
||||
pickRecentProjectIds,
|
||||
resolveCurrentProject,
|
||||
sortProjects
|
||||
} from './projectRules';
|
||||
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', () => {
|
||||
it('архивный проект — закрыты все разделы, кроме главной', () => {
|
||||
const archived = project({ status: 'архивный' });
|
||||
@@ -99,4 +119,17 @@ describe('isSectionAvailable', () => {
|
||||
expect(isSectionAvailable('report', 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 — нельзя «Отправить отчёт».
|
||||
* Импорт отчёта из Excel создаёт отчёты — те же правила, что у «Отправить отчёт».
|
||||
* Привязка к справочнику — только с правом редактирования (решение при объединении).
|
||||
* Импорт работ из Excel меняет работы проекта — тоже только с правом редактирования.
|
||||
*/
|
||||
export function isSectionAvailable(page: PageId, project: IAccessibleProject): boolean {
|
||||
if (page === 'hub') return true;
|
||||
|
||||
const kind = getStatusKind(project.status);
|
||||
if (kind === 'archive') return false;
|
||||
if (page === 'report') return kind !== 'done' && project.canEdit;
|
||||
if (page === 'binder') return project.canEdit;
|
||||
if (page === 'report' || page === 'reportImport') return kind !== 'done' && project.canEdit;
|
||||
if (page === 'binder' || page === 'worksImport') return project.canEdit;
|
||||
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 {
|
||||
return getStatusKind(project.status) !== 'archive';
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { buildSearch, IRoute, parseRoute } from './routes';
|
||||
|
||||
interface INavigateOptions {
|
||||
/** Заменить текущую запись истории (replaceState), а не добавить новую. */
|
||||
replace?: boolean;
|
||||
}
|
||||
|
||||
interface IRouterValue {
|
||||
route: IRoute;
|
||||
/** Перейти в раздел: меняет адрес (history.pushState) и перерисовывает приложение. */
|
||||
navigate: (route: IRoute) => void;
|
||||
navigate: (route: IRoute, options?: INavigateOptions) => void;
|
||||
/** Полный адрес раздела — для настоящих ссылок (Ctrl+клик, «открыть в новой вкладке»). */
|
||||
buildHref: (route: IRoute) => string;
|
||||
}
|
||||
@@ -29,8 +34,12 @@ export const RouterProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
return () => window.removeEventListener('popstate', onPopState);
|
||||
}, []);
|
||||
|
||||
const navigate = React.useCallback((next: IRoute): void => {
|
||||
window.history.pushState(undefined, '', buildHref(next));
|
||||
const navigate = React.useCallback((next: IRoute, options: INavigateOptions = {}): void => {
|
||||
if (options.replace) {
|
||||
window.history.replaceState(undefined, '', buildHref(next));
|
||||
} else {
|
||||
window.history.pushState(undefined, '', buildHref(next));
|
||||
}
|
||||
setRoute(next);
|
||||
window.scrollTo(0, 0);
|
||||
}, []);
|
||||
|
||||
@@ -17,4 +17,20 @@ describe('parseRoute', () => {
|
||||
it('адрес только с 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), поэтому ссылку на любой раздел можно
|
||||
// скопировать, открыть в новой вкладке, а «назад» в браузере работает как раньше.
|
||||
|
||||
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];
|
||||
|
||||
@@ -16,6 +28,10 @@ export interface IRoute {
|
||||
reportId?: string;
|
||||
/** Форма отчёта: создать на основе существующего (GUID Dataverse). */
|
||||
copyFromId?: string;
|
||||
/** Главная и сверка погоды: начало периода (YYYY-MM-DD). */
|
||||
start?: string;
|
||||
/** Главная и сверка погоды: конец периода (YYYY-MM-DD). */
|
||||
end?: string;
|
||||
}
|
||||
|
||||
export const ROUTE_PARAMS = {
|
||||
@@ -23,10 +39,19 @@ export const ROUTE_PARAMS = {
|
||||
projectId: 'projectId',
|
||||
objectId: 'objectId',
|
||||
reportId: 'reportId',
|
||||
copyFromId: 'copyFromId'
|
||||
copyFromId: 'copyFromId',
|
||||
start: 'start',
|
||||
end: 'end'
|
||||
} 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 {
|
||||
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 { IAccessibleProject } from '../../app/project/projectTypes';
|
||||
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 { useReportExport } from './useReportExport';
|
||||
import { ExportInfoButton } from './ExportInfoButton';
|
||||
@@ -20,17 +23,22 @@ interface Props {
|
||||
project: IAccessibleProject | undefined;
|
||||
}
|
||||
|
||||
/** Карточка «Выгрузить за период». Поля дат видны только при выбранном проекте. */
|
||||
/**
|
||||
* Карточка «Выгрузить за период» и кнопка «Сверка погодных условий». Поля дат видны только при выбранном проекте.
|
||||
* Даты берутся из адреса, если вернулись со сверки погоды, — чтобы не выбирать период заново (как в main).
|
||||
*/
|
||||
export const ExportCard: React.FC<Props> = ({ project }) => {
|
||||
const [dateFrom, setDateFrom] = React.useState<string>('');
|
||||
const [dateTo, setDateTo] = React.useState<string>('');
|
||||
const { route, navigate } = useRouter();
|
||||
const [dateFrom, setDateFrom] = React.useState<string>(route.start ?? '');
|
||||
const [dateTo, setDateTo] = React.useState<string>(route.end ?? '');
|
||||
const { isExporting, errorMessage, exportReport } = useReportExport();
|
||||
|
||||
const isEnabled = !!project && isExportAvailable(project);
|
||||
const hasBothDates = !!dateFrom && !!dateTo;
|
||||
const isRangeInvalid = isInvalidRange(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 => {
|
||||
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 (
|
||||
<div className={isEnabled ? styles.exportCard : `${styles.exportCard} ${styles.cardDisabled}`}>
|
||||
<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}>
|
||||
{isExporting ? <Spinner size="xSmall" label={TEXTS.buttonLoading} /> : TEXTS.button}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onOpenWeather} disabled={!isPeriodReady}>
|
||||
<span className={styles.buttonContent}>
|
||||
<InlineIcon name="cloud" />
|
||||
{WEATHER_TEXTS.openButton}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -136,6 +136,12 @@ $card-min-width: 260px;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.buttonContent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
}
|
||||
|
||||
.dateError {
|
||||
font-size: $fs-sm;
|
||||
color: $red-500;
|
||||
|
||||
@@ -50,6 +50,25 @@ $dropdown-max-height: 340px;
|
||||
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 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,14 @@ export const ProjectPicker: React.FC = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
// Очистка не сбрасывает выбранный проект — только текст поиска, чтобы сразу искать другой.
|
||||
const onClear = (event: React.MouseEvent): void => {
|
||||
event.preventDefault();
|
||||
setQuery('');
|
||||
setIsOpen(true);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
// mousedown, а не click: иначе поле теряет фокус раньше, чем срабатывает выбор.
|
||||
const onPick = (event: React.MouseEvent, project: IAccessibleProject): void => {
|
||||
event.preventDefault();
|
||||
@@ -67,6 +75,17 @@ export const ProjectPicker: React.FC = () => {
|
||||
onFocus={onFocus}
|
||||
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 && (
|
||||
<span className={styles.selectedBadge}>
|
||||
<ProjectStatusBadge status={currentProject.status} />
|
||||
|
||||
@@ -4,6 +4,7 @@ export const HUB_TEXTS = {
|
||||
picker: {
|
||||
label: 'Проект:',
|
||||
placeholder: 'Выберите проект',
|
||||
clear: 'Очистить',
|
||||
loading: 'Загрузка проектов…',
|
||||
noProjects: 'Нет доступных проектов',
|
||||
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 { ObjectNode } from './objectsTypes';
|
||||
import { IObjectTreeView } from './useObjectTreeView';
|
||||
import { IObjectDrag } from './useObjectDrag';
|
||||
import { DropZone } from './DropZone';
|
||||
import { OBJECTS_TEXTS } from './texts';
|
||||
import styles from './ObjectsPage.module.scss';
|
||||
|
||||
@@ -12,7 +10,6 @@ interface Props {
|
||||
node: ObjectNode;
|
||||
canEdit: boolean;
|
||||
view: IObjectTreeView;
|
||||
drag: IObjectDrag;
|
||||
onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
|
||||
}
|
||||
|
||||
@@ -20,19 +17,14 @@ interface Props {
|
||||
const ROW_BASE_INDENT_PX = 12;
|
||||
const ROW_LEVEL_INDENT_PX = 22;
|
||||
|
||||
function rowClassName(hasChildren: boolean, isSelected: boolean, isDragging: boolean): string {
|
||||
return [
|
||||
styles.nodeRow,
|
||||
hasChildren ? styles.nodeRowParent : '',
|
||||
isSelected ? styles.nodeRowSelected : '',
|
||||
isDragging ? styles.nodeRowDragging : ''
|
||||
]
|
||||
function rowClassName(hasChildren: boolean, isSelected: boolean): string {
|
||||
return [styles.nodeRow, hasChildren ? styles.nodeRowParent : '', isSelected ? styles.nodeRowSelected : '']
|
||||
.filter(Boolean)
|
||||
.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 isExpanded = view.expandedIds.has(node.id);
|
||||
|
||||
@@ -58,13 +50,9 @@ export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onO
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DropZone targetId={node.id} position="before" drag={drag} />
|
||||
<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 }}
|
||||
draggable={canEdit}
|
||||
onDragStart={(event) => drag.onDragStart(node.id, event)}
|
||||
onDragEnd={drag.onDragEnd}
|
||||
onClick={() => view.clickRow(node)}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
@@ -93,22 +81,13 @@ export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onO
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<DropZone targetId={node.id} position="inside" drag={drag} />
|
||||
{hasChildren && isExpanded && (
|
||||
<div>
|
||||
{node.children.map((child) => (
|
||||
<ObjectTreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
canEdit={canEdit}
|
||||
view={view}
|
||||
drag={drag}
|
||||
onOpenMenu={onOpenMenu}
|
||||
/>
|
||||
<ObjectTreeNode key={child.id} node={child} canEdit={canEdit} view={view} onOpenMenu={onOpenMenu} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<DropZone targetId={node.id} position="after" drag={drag} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -91,10 +91,6 @@ $menu-button-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.nodeRowDragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.nodeToggle {
|
||||
display: flex;
|
||||
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 {
|
||||
display: flex;
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useObjectsData } from './useObjectsData';
|
||||
import { useObjectActions } from './useObjectActions';
|
||||
import { useObjectsExport } from './useObjectsExport';
|
||||
import { useObjectTreeView } from './useObjectTreeView';
|
||||
import { useObjectDrag } from './useObjectDrag';
|
||||
import { ObjectsHeader } from './ObjectsHeader';
|
||||
import { ObjectsTree } from './ObjectsTree';
|
||||
import { ObjectModal } from './ObjectModal';
|
||||
@@ -42,17 +41,9 @@ export const ObjectsPageContent: React.FC<Props> = ({ projectId }) => {
|
||||
);
|
||||
|
||||
const data = useObjectsData(repository, projectId);
|
||||
const actions = useObjectActions({
|
||||
repository,
|
||||
projectTitle: data.projectTitle,
|
||||
tree: data.tree,
|
||||
reload: data.reload
|
||||
});
|
||||
const actions = useObjectActions({ repository, projectTitle: data.projectTitle, reload: data.reload });
|
||||
const exporter = useObjectsExport(repository, projectId);
|
||||
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 [modalTarget, setModalTarget] = React.useState<IObjectModalTarget | undefined>(undefined);
|
||||
|
||||
@@ -105,7 +96,6 @@ export const ObjectsPageContent: React.FC<Props> = ({ projectId }) => {
|
||||
tree={data.tree}
|
||||
canEdit={data.canEdit}
|
||||
view={view}
|
||||
drag={drag}
|
||||
onOpenMenu={(node, anchor) => setMenu({ node, anchor })}
|
||||
onAddRoot={openAddRoot}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { IMenuAnchor } from '../_tree/ContextMenu';
|
||||
import { countNodes } from '../_tree/treeRules';
|
||||
import { ObjectNode, ObjectTree } from './objectsTypes';
|
||||
import { IObjectTreeView } from './useObjectTreeView';
|
||||
import { IObjectDrag } from './useObjectDrag';
|
||||
import { ObjectTreeNode } from './ObjectTreeNode';
|
||||
import { OBJECTS_TEXTS } from './texts';
|
||||
import styles from './ObjectsPage.module.scss';
|
||||
@@ -14,13 +13,12 @@ interface Props {
|
||||
tree: ObjectTree;
|
||||
canEdit: boolean;
|
||||
view: IObjectTreeView;
|
||||
drag: IObjectDrag;
|
||||
onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
|
||||
onAddRoot: () => void;
|
||||
}
|
||||
|
||||
/** Панель «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}>
|
||||
<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) => (
|
||||
<ObjectTreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
canEdit={canEdit}
|
||||
view={view}
|
||||
drag={drag}
|
||||
onOpenMenu={onOpenMenu}
|
||||
/>
|
||||
<ObjectTreeNode key={node.id} node={node} canEdit={canEdit} view={view} onOpenMenu={onOpenMenu} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -174,17 +174,6 @@ describe('запись', () => {
|
||||
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 () => {
|
||||
const verbose = setup([
|
||||
ENTITY_TYPE_ROUTE,
|
||||
|
||||
@@ -165,8 +165,6 @@ export interface IObjectsRepository {
|
||||
createObject(projectTitle: string, title: string, parentId: number | undefined): Promise<void>;
|
||||
/** newParentId — только при смене родителя Раздела; undefined — поле не трогаем. */
|
||||
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>;
|
||||
downloadObjectsExcel(projectId: string): Promise<Blob>;
|
||||
}
|
||||
@@ -211,11 +209,6 @@ export function createObjectsRepository(deps: IObjectsRepositoryDeps): IObjectsR
|
||||
if (newParentId !== undefined) body[OBJECT_FIELDS.parentWrite] = newParentId;
|
||||
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),
|
||||
downloadObjectsExcel: (projectId) => downloadObjectsExcel(deps, projectId)
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
buildObjectTree,
|
||||
canAddChild,
|
||||
canDrop,
|
||||
getChangedParentId,
|
||||
getDeleteConfirmMessage,
|
||||
getErrorText,
|
||||
@@ -9,8 +8,7 @@ import {
|
||||
getParentDisplay,
|
||||
getParentOptions,
|
||||
getSubmitLabel,
|
||||
isParentSelectable,
|
||||
resolveMove
|
||||
isParentSelectable
|
||||
} from './objectsRules';
|
||||
import { buildObjectMenuItems } from './objectMenu';
|
||||
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('удаление и ошибки', () => {
|
||||
it('подтверждение с числом дочерних', () => {
|
||||
expect(getDeleteConfirmMessage(node(1))).toEqual(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ISelectOption } from '@tnsu/ui-kit';
|
||||
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';
|
||||
|
||||
// Правила страницы «Объекты» (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;
|
||||
}
|
||||
|
||||
/** Бросать узел можно куда угодно, кроме себя и своих потомков. */
|
||||
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 {
|
||||
return OBJECTS_TEXTS.deleteConfirm(
|
||||
node.item.title || OBJECTS_TEXTS.deleteFallbackTitle(node.id),
|
||||
|
||||
@@ -22,9 +22,6 @@ export interface IObjectModalTarget {
|
||||
node: ObjectNode | undefined;
|
||||
}
|
||||
|
||||
/** Куда бросили узел относительно целевого: до, внутрь, после. */
|
||||
export type DropPosition = 'before' | 'inside' | 'after';
|
||||
|
||||
/**
|
||||
* Строка «Объекты» в ответе SharePoint ($select=Id,Title,parent/Id&$expand=parent).
|
||||
* Пустой lookup приходит как null — читается через `?.`.
|
||||
|
||||
@@ -58,7 +58,6 @@ export const OBJECTS_TEXTS = {
|
||||
deleteFallbackTitle: (id: number): string => `ID ${id}`,
|
||||
serverError: (status: number): string => `Ошибка сервера: ${status}`,
|
||||
saveError: (message: string): string => `Ошибка сохранения: ${message}`,
|
||||
moveError: (message: string): string => `Не удалось переместить: ${message}`,
|
||||
deleteError: (message: string): string => `Не удалось удалить: ${message}`,
|
||||
exportError: (message: string): string => `Не удалось скачать файл: ${message}`
|
||||
} as const;
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import { confirmModal } from '@tnsu/ui-kit';
|
||||
import { IObjectsRepository } from './objectsRepository';
|
||||
import { getChangedParentId, getDeleteConfirmMessage, getErrorText, resolveMove } from './objectsRules';
|
||||
import { DropPosition, IObjectModalTarget, ObjectNode, ObjectTree } from './objectsTypes';
|
||||
import { getChangedParentId, getDeleteConfirmMessage, getErrorText } from './objectsRules';
|
||||
import { IObjectModalTarget, ObjectNode } from './objectsTypes';
|
||||
import { OBJECTS_TEXTS } from './texts';
|
||||
|
||||
interface IActionsDeps {
|
||||
repository: IObjectsRepository;
|
||||
projectTitle: string | undefined;
|
||||
tree: ObjectTree;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
export interface IObjectActions {
|
||||
/** Ошибка переноса или удаления (раньше — alert). */
|
||||
/** Ошибка удаления (раньше — alert). */
|
||||
actionError: string | undefined;
|
||||
/** Создание / правка из модалки. Ошибку бросает дальше — модалка покажет её у себя. */
|
||||
saveObject: (target: IObjectModalTarget, title: string, parentValue: string) => Promise<void>;
|
||||
moveObject: (dragId: number, targetId: number, position: DropPosition) => 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 saveObject = async (target: IObjectModalTarget, title: string, parentValue: string): Promise<void> => {
|
||||
@@ -39,18 +37,6 @@ export function useObjectActions({ repository, projectTitle, tree, reload }: IAc
|
||||
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 isConfirmed = await confirmModal({
|
||||
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 { useRouter } from '../../app/routing/RouterContext';
|
||||
import { goBack } from './goBack';
|
||||
import { REPORT_TEXTS } from './texts';
|
||||
import styles from './ReportPage.module.scss';
|
||||
|
||||
/** «← Вернуться назад» — назад по истории браузера, как в старой форме. */
|
||||
export const BackLink: React.FC = () => (
|
||||
<a
|
||||
href="#"
|
||||
className={styles.backLink}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
goBack();
|
||||
}}
|
||||
>
|
||||
{REPORT_TEXTS.back}
|
||||
</a>
|
||||
);
|
||||
/** «← Вернуться назад» — назад по истории браузера, как в старой форме; без истории — к списку отчётов проекта. */
|
||||
export const BackLink: React.FC = () => {
|
||||
const { route, navigate } = useRouter();
|
||||
|
||||
return (
|
||||
<a
|
||||
href="#"
|
||||
className={styles.backLink}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
goBack(() => navigate({ page: 'reports', projectId: route.projectId }));
|
||||
}}
|
||||
>
|
||||
{REPORT_TEXTS.back}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -138,6 +138,7 @@ $photo-thumb-min: 80px;
|
||||
}
|
||||
|
||||
.valueInput {
|
||||
box-sizing: border-box; // иначе ширина 100% + отступы вылезают за край карточки работы
|
||||
width: 100%;
|
||||
padding: $sp-2 $sp-3;
|
||||
border: 1px solid $gray-300;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
orderRowsForDeletion
|
||||
} from './dataverseReportRepository';
|
||||
import { createFakeHttpClient, IFakeRoute } from '../../shared/testing/fakeHttpClient';
|
||||
import { IHttpRequest } from '../../services/http/IHttpClient';
|
||||
import { IReportPayload } from './reportTypes';
|
||||
|
||||
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)', () => {
|
||||
it('отчёт, счётчик, строки, факт', () => {
|
||||
expect(DV_PATHS.report('r1')).toEqual('cree9_reports(r1)');
|
||||
expect(DV_PATHS.reportCount(5)).toEqual(
|
||||
'cree9_reports?$filter=cree9_projectid eq 5 and cree9_deleted ne true&$count=true&$top=1&$select=cree9_number'
|
||||
expect(DV_PATHS.lastReportNumber(5)).toEqual(
|
||||
'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.jobTotalFact('j1')).toEqual('cree9_report_datas?$filter=_cree9_jobid_value eq j1&$select=cree9_value');
|
||||
@@ -165,11 +166,18 @@ describe('saveReportWithData', () => {
|
||||
});
|
||||
|
||||
describe('прочие операции', () => {
|
||||
it('номер нового отчёта = количество неудалённых + 1', async () => {
|
||||
const client = createFakeHttpClient([{ match: '$count=true', response: { body: { '@odata.count': 7, value: [] } } }]);
|
||||
it('номер нового отчёта = наибольший номер в проекте (включая удалённые) + 1', async () => {
|
||||
const client = createFakeHttpClient([
|
||||
{ match: 'cree9_number desc', response: { body: { value: [{ cree9_number: 7 }] } } }
|
||||
]);
|
||||
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 () => {
|
||||
const client = createFakeHttpClient([{ match: 'cree9_reports(r1)', response: { status: 204 } }]);
|
||||
await createDataverseReportRepository(client, DV).approveReport('r1');
|
||||
|
||||
@@ -50,9 +50,10 @@ export const DV_PATHS = {
|
||||
`?$select=ReferencingAttribute,ReferencingEntityNavigationPropertyName` +
|
||||
`&$filter=ReferencingAttribute eq 'cree9_parentid'`,
|
||||
report: (reportId: string): string => `${DV_TABLES.reports}(${reportId})`,
|
||||
reportCount: (projectId: number): string =>
|
||||
`${DV_TABLES.reports}?$filter=cree9_projectid eq ${projectId} and cree9_deleted ne true` +
|
||||
`&$count=true&$top=1&$select=cree9_number`,
|
||||
// Удалённые отчёты не исключаются: их номера (и папки фото {projectId}_{номер}) повторно не выдаются.
|
||||
lastReportNumber: (projectId: number): string =>
|
||||
`${DV_TABLES.reports}?$filter=cree9_projectid eq ${projectId}` +
|
||||
`&$orderby=cree9_number desc&$top=1&$select=cree9_number`,
|
||||
jobTotalFact: (jobId: string): string =>
|
||||
`${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}`,
|
||||
@@ -252,9 +253,11 @@ export function createDataverseReportRepository(client: IHttpClient, baseUrl: st
|
||||
getReportById: (reportId) => get<IReportRow>(DV_PATHS.report(reportId)),
|
||||
|
||||
getNextReportNumber: async (projectId) => {
|
||||
// 🐞 Номер = число неудалённых отчётов + 1: после мягкого удаления номер может повториться.
|
||||
const result = await get<{ '@odata.count'?: number }>(DV_PATHS.reportCount(projectId));
|
||||
return (result['@odata.count'] || 0) + 1;
|
||||
// В старом коде номер = число неудалённых отчётов + 1, и после мягкого удаления номера повторялись —
|
||||
// исправлено при проверке T-REP-23: номер = наибольший номер в проекте + 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 }),
|
||||
|
||||
@@ -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 {
|
||||
if (window.top && window.top !== window) {
|
||||
window.top.history.back();
|
||||
|
||||
@@ -42,6 +42,18 @@ $slide-duration: 0.2s;
|
||||
animation: slideUp $slide-duration ease-out;
|
||||
}
|
||||
|
||||
// Как в старом PickerModal: шторка снизу — только на телефоне, на экране шире 640px — окно по центру.
|
||||
@include above($bp-sm) {
|
||||
.overlay {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
border-radius: $r-lg;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IAppServices } from '../../services/AppServices';
|
||||
import { siteServerRelativeUrl } from '../../services/siteUser';
|
||||
import { createSharePointReportRepository, ISharePointReportRepository } from './sharePointReportRepository';
|
||||
import { createDataverseReportRepository, IDataverseReportRepository } from './dataverseReportRepository';
|
||||
|
||||
@@ -9,13 +10,15 @@ export interface IReportServices {
|
||||
siteUrl: string;
|
||||
}
|
||||
|
||||
// Всё — от сайта со списками (settings.siteUrl), а не от сайта страницы: в workbench корневого
|
||||
// сайта id пользователя и путь к библиотеке DocLib там другие.
|
||||
export function createReportServices(services: IAppServices): IReportServices {
|
||||
return {
|
||||
sp: createSharePointReportRepository({
|
||||
client: services.sharePoint,
|
||||
siteUrl: services.settings.siteUrl,
|
||||
userId: services.page.userId,
|
||||
webServerRelativeUrl: services.page.webServerRelativeUrl
|
||||
getCurrentUserId: services.getCurrentUserId,
|
||||
webServerRelativeUrl: siteServerRelativeUrl(services.settings.siteUrl)
|
||||
}),
|
||||
dv: createDataverseReportRepository(services.dataverse, services.settings.dataverseUrl),
|
||||
siteUrl: services.settings.siteUrl
|
||||
|
||||
@@ -17,7 +17,7 @@ const repositoryWith = (routes: IFakeRoute[]): {
|
||||
const repository = createSharePointReportRepository({
|
||||
client,
|
||||
siteUrl: SITE,
|
||||
userId: 42,
|
||||
getCurrentUserId: async () => 42,
|
||||
webServerRelativeUrl: '/sites/stroy/'
|
||||
});
|
||||
return { client, repository };
|
||||
|
||||
@@ -111,9 +111,13 @@ export function fileAddUrl(siteUrl: string, serverRelativeFolderUrl: string, fil
|
||||
export interface ISharePointReportDeps {
|
||||
client: IHttpClient;
|
||||
siteUrl: string;
|
||||
/** Id текущего пользователя (legacyPageContext.userId) — по нему ищется строка «Доступы». */
|
||||
userId: number | undefined;
|
||||
/** Серверный относительный URL сайта — для пути к папке фото. */
|
||||
/**
|
||||
* Id текущего пользователя на сайте со списками — по нему ищется строка «Доступы».
|
||||
* Старый код брал legacyPageContext.userId (id на сайте страницы); в workbench корневого сайта
|
||||
* это другой id, и доступ не находился. Теперь — `_api/web/currentuser` сайта со списками.
|
||||
*/
|
||||
getCurrentUserId: () => Promise<number>;
|
||||
/** Серверный относительный URL сайта со списками (`/sites/stroy`) — для пути к папке фото. */
|
||||
webServerRelativeUrl: string;
|
||||
}
|
||||
|
||||
@@ -224,8 +228,11 @@ export function createSharePointReportRepository(deps: ISharePointReportDeps): I
|
||||
createListItem,
|
||||
|
||||
checkAccess: async (projectId) => {
|
||||
const rows = await getItems<IAccessRaw>(LISTS.access, { filter: `projectId eq ${projectId}` });
|
||||
return pickAccess(rows, deps.userId);
|
||||
const [rows, userId] = await Promise.all([
|
||||
getItems<IAccessRaw>(LISTS.access, { filter: `projectId eq ${projectId}` }),
|
||||
deps.getCurrentUserId()
|
||||
]);
|
||||
return pickAccess(rows, userId);
|
||||
},
|
||||
|
||||
uploadPhotos: async (projectId, reportNumber, photos) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { LISTS } from '../../config/lists';
|
||||
import { IReportLookups, IWorkItem } from './reportTypes';
|
||||
import { IJobWithPath, IReportLookups, IWorkItem } from './reportTypes';
|
||||
import { IPickerCreateData, IPickerItem, IPickerOptions } from './picker/pickerTypes';
|
||||
import { PickerKind, PICKER_OPTIONS, toPickerItems } from './reportPicker';
|
||||
import { addResource, addWork, createLocalId, createResourceItem } from './reportFormUpdates';
|
||||
@@ -32,8 +32,14 @@ interface IPickerDeps {
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
function newWorkItem(item: IPickerItem, lookups: IReportLookups, totalFact: number): IWorkItem {
|
||||
const job = lookups.jobs.filter((candidate) => candidate.cree9_tableid === item.id)[0];
|
||||
function newWorkItem(
|
||||
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 {
|
||||
id: createLocalId(),
|
||||
jobId: String(item.id),
|
||||
@@ -75,6 +81,10 @@ export function usePickerModal(deps: IPickerDeps): IPickerModalApi {
|
||||
const { services, report } = deps;
|
||||
const [active, setActive] = React.useState<IActivePicker | undefined>(undefined);
|
||||
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 => {
|
||||
setActive({ kind, targetWorkId });
|
||||
@@ -100,10 +110,8 @@ export function usePickerModal(deps: IPickerDeps): IPickerModalApi {
|
||||
} else if (kind === 'company') {
|
||||
report.updateForm((prev) => ({ ...prev, companyId: Number(item.id), companyName: item.title }));
|
||||
} else if (kind === 'work') {
|
||||
// 🐞 Как в старом коде: только что созданной работы ещё нет в lookups.jobs этого рендера,
|
||||
// поэтому у неё название = полный путь, ед. изм. пустая и план 0.
|
||||
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));
|
||||
} else {
|
||||
const resource = createResourceItem(Number(item.id), item.title);
|
||||
@@ -134,7 +142,9 @@ export function usePickerModal(deps: IPickerDeps): IPickerModalApi {
|
||||
parentId: data.parentId
|
||||
});
|
||||
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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -115,7 +115,8 @@ export function useReportForm(services: IReportServices, params: IReportRoutePar
|
||||
try {
|
||||
await services.dv.approveReport(existingReportId);
|
||||
setSuccessMessage(MESSAGES.approved);
|
||||
// 🐞 Как в старом коде: флаг «утверждён» не обновляется, бейдж остаётся «На утверждении».
|
||||
// В старом коде флаг не обновлялся и бейдж оставался «На утверждении» до F5 — исправлено при проверке T-REP-16.
|
||||
setIsApproved(true);
|
||||
setMode('readonly');
|
||||
} catch (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 { Badge, Icon } from '@tnsu/ui-kit';
|
||||
import { useRouter } from '../../app/routing/RouterContext';
|
||||
import { IRoute } from '../../app/routing/routes';
|
||||
import { IReportAccess, IReportCard } from './reportsTypes';
|
||||
import { canCreateReport, canDeleteReport } from './reportsRules';
|
||||
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 }) => {
|
||||
const { buildHref } = useRouter();
|
||||
const reportHref = buildHref({ page: 'report', projectId, reportId: report.id });
|
||||
const copyHref = buildHref({ page: 'report', projectId, copyFromId: report.id });
|
||||
const { navigate, buildHref } = useRouter();
|
||||
const reportRoute: IRoute = { page: 'report', projectId, reportId: report.id };
|
||||
const copyRoute: IRoute = { page: 'report', projectId, copyFromId: report.id };
|
||||
|
||||
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 внутри карточки (в том числе на её кнопках) открывают отчёт.
|
||||
@@ -65,12 +76,10 @@ export const ReportCard: React.FC<Props> = ({ report, access, projectId, onDelet
|
||||
{canCreateReport(access) && (
|
||||
<a
|
||||
className={styles.cardAction}
|
||||
href={copyHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={buildHref(copyRoute)}
|
||||
title={TEXTS.copyTitle(report.number)}
|
||||
aria-label={TEXTS.copyAriaLabel}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onClick={onCopyClick}
|
||||
>
|
||||
<Icon name="copy" size="sm" />
|
||||
</a>
|
||||
|
||||
@@ -11,7 +11,8 @@ export interface IReportListDeps {
|
||||
dataverse: IHttpClient;
|
||||
siteUrl: string;
|
||||
dataverseUrl: string;
|
||||
userId: number | undefined;
|
||||
/** Id пользователя на сайте со списками (не на сайте страницы) — см. IAppServices.getCurrentUserId. */
|
||||
getCurrentUserId: () => Promise<number>;
|
||||
}
|
||||
|
||||
/** Справочники для подписей карточек: компания по Id, путь объекта. */
|
||||
@@ -49,7 +50,8 @@ export async function loadReportList(
|
||||
projectId: number,
|
||||
range: IDateRange
|
||||
): 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' };
|
||||
|
||||
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);
|
||||
|
||||
@@ -174,7 +174,7 @@ describe('loadReportList', () => {
|
||||
it('нет доступа — denied, справочники не грузятся', async () => {
|
||||
const sharePoint = createFakeHttpClient([{ match: 'Доступы', response: { body: { value: [] } } }]);
|
||||
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(sharePoint.requests).toHaveLength(1);
|
||||
});
|
||||
@@ -182,7 +182,7 @@ describe('loadReportList', () => {
|
||||
it('ошибка отчётов — экран с «Ошибка инициализации: …» и без карточек', async () => {
|
||||
const sharePoint = createFakeHttpClient([access, companies, objects]);
|
||||
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({
|
||||
kind: 'ready',
|
||||
reports: undefined,
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface 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 [projectName, setProjectName] = React.useState<string>('');
|
||||
const [access, setAccess] = React.useState<IReportAccess>(NO_ACCESS);
|
||||
@@ -44,9 +44,9 @@ export function useReportList(projectId: number, initialRange: IDateRange): IRep
|
||||
dataverse,
|
||||
siteUrl: settings.siteUrl,
|
||||
dataverseUrl: settings.dataverseUrl,
|
||||
userId: page.userId
|
||||
getCurrentUserId
|
||||
}),
|
||||
[sharePoint, dataverse, settings.siteUrl, settings.dataverseUrl, page.userId]
|
||||
[sharePoint, dataverse, settings.siteUrl, settings.dataverseUrl, getCurrentUserId]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
assignedItemsUrl,
|
||||
assignedItemUrl,
|
||||
deleteAssignedResource,
|
||||
existingAssignmentUrl,
|
||||
fetchAllItems,
|
||||
fetchProjectResources,
|
||||
poolItemsUrl,
|
||||
@@ -105,13 +106,29 @@ describe('fetchProjectResources', () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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: 'Бетонщик' });
|
||||
|
||||
expect(id).toEqual(101);
|
||||
expect(client.requests[0]).toEqual({
|
||||
expect(client.requests[1]).toEqual({
|
||||
url: items('Профессии по проекту'),
|
||||
request: {
|
||||
method: 'POST',
|
||||
@@ -126,16 +143,16 @@ describe('addAssignedResource', () => {
|
||||
});
|
||||
|
||||
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: 'ТОО Строй' });
|
||||
|
||||
expect(client.requests[0].url).toEqual(items('Компании по проекту'));
|
||||
expect(client.requests[0].request.body).toEqual('{"Title":"ТОО Строй","projectIdId":5,"companyIdId":3}');
|
||||
expect(client.requests[1].url).toEqual(items('Компании по проекту'));
|
||||
expect(client.requests[1].request.body).toEqual('{"Title":"ТОО Строй","projectIdId":5,"companyIdId":3}');
|
||||
});
|
||||
|
||||
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(
|
||||
'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(
|
||||
client: IHttpClient,
|
||||
siteUrl: string,
|
||||
@@ -138,6 +151,14 @@ export async function addAssignedResource(
|
||||
projectId: string,
|
||||
item: IResourceItem
|
||||
): 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 = {
|
||||
Title: item.title,
|
||||
[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