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