Compare commits

..

30 Commits

Author SHA1 Message Date
Тимур Городецкий a9af74ba02 feat: add dividers to the sidebar and wrap long titles
Update @tnsu/ui-kit to 0.14.0: thin lines between sidebar items make the
long menu easier to scan, and long titles such as "Загрузить работы из
сметы" wrap to a second line instead of being cut off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 17:56:48 +05:00
Тимур Городецкий f3e098ee84 feat: add "back to objects" link on the works page
The works page of an object had no way back except the sidebar. The link
goes straight to the objects of the same project, so it also works when
the page was opened by a direct URL.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 17:52:12 +05:00
Тимур Городецкий 10ba9a6623 feat: add catalogs group with site links to the sidebar
"Справочники" in the sidebar expands into links to the site's lists and
pages (projects, companies, professions, units, equipment, archive,
manual, recycle bin), each opening in a new tab. Uses the new Sidebar
groups and links from @tnsu/ui-kit 0.13.0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 10:20:12 +05:00
Тимур Городецкий ee282b61b7 feat: show current vs new plan before importing works
A file value replaces the planned volume of an existing work. Before the
import the page now lists such works with their current and new volume
and unit, highlighting changes and greying out rows that stay the same,
so the user sees what will be overwritten.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 09:54:12 +05:00
Тимур Городецкий a98162fce7 feat: save works imported from Excel
After a clean check the page loads the existing works of each object and
shows how many works will be created and updated. "Import" writes them
with the same requests as the work dialog: a work with the same title on
the object gets only its planned volume and unit updated, so loading the
same file again does not duplicate works.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 17:52:41 +05:00
Тимур Городецкий 18c2959820 feat: add works import from Excel (check and plan)
New "Импорт работ из Excel" section checks the planned volumes file
against /Import/StroyBot/Works as soon as it is picked. The builder
turns checked rows into works and decides per work whether to create it
or update the volume and unit of an existing work with the same title
on the object. Saving comes next.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 17:35:21 +05:00
Тимур Городецкий db8565b0d3 refactor: extract shared Excel import parts
File picking, the server check, the result table and value parsing do
not depend on what the rows contain. Move them to pages/_excelImport so
the upcoming works import reuses them instead of copying. Report import
behaves as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 17:35:21 +05:00
Тимур Городецкий 1ba67fe4c1 feat: import reports from Excel
The /Import/StroyBot/Reports endpoint only validates the file, so the
check now runs as soon as a file is picked. When there are no errors,
"Import" builds the reports from the ids in the response and saves them
with the same Dataverse code as the report form: one report per date,
company, shift and object; resources nested under their work; idle and
repair hours as separate rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 17:01:27 +05:00
Тимур Городецкий eb646193fa fix: open report cards in the same tab
Clicking a report card or "create from this report" now switches to the
report form inside the app instead of opening a new browser tab.
Ctrl+click on the copy link still opens a new tab.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 17:01:27 +05:00
Тимур Городецкий 0ae52ccd6b docs: drop open questions to the API team
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 10:12:18 +05:00
Тимур Городецкий df4998c75e docs: record weather check test results
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 10:08:53 +05:00
Тимур Городецкий d606f8371c feat: weather conditions check from stroy-bot-main
Ports the weather check added to stroy-bot-main on 2026-10-16: daily
station weather, report positions with weather per shift and the AI
assessment of working conditions. Opens as ?page=weather&start=&end=
instead of the #weathercheck hash, and hub dates survive the way back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 09:47:02 +05:00
Тимур Городецкий b18cf50efd docs: add responsive layout checks to the test plan
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:19:59 +05:00
Тимур Городецкий 76878c7617 docs: record report fixes and the import test blocker
Import tests wait for an estimate in the catalogue: the API returns
totalCount 0 for every project.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:17:57 +05:00
Тимур Городецкий f05ee3e854 fix: number a new report after the highest number in the project
Counting non-deleted reports reused numbers (and photo folders) after a
soft delete. Deleted reports are now included.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:17:57 +05:00
Тимур Городецкий f6af34cb0a fix: send the back link to the reports list when there is no history
Reports open from the list in a new tab, where history.back() did nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:17:56 +05:00
Тимур Городецкий 40a39cf828 fix: show the approved badge right after approving a report
Previously the badge stayed "На утверждении" until a page reload.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:17:56 +05:00
Тимур Городецкий ae383b5231 fix: show title, unit and plan for a work created from the picker
The new work was not yet in lookups of the current render, so the card
got the full path, an empty unit and plan 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:17:56 +05:00
Тимур Городецкий 33f31366cd fix: center the report picker on wide screens and keep the value field inside its card
The bottom sheet stays for phones only, as in the old picker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:17:56 +05:00
Тимур Городецкий f0272c2170 fix: check report access with the user id of the lists site
The user id differs per site, so the id from the page context showed
"Нет доступа" in reports when the page was not on /sites/stroy.
The id and the photo folder path now come from settings.siteUrl.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:17:56 +05:00
Тимур Городецкий cb810972c4 feat: searchable unit picker in the work form
Units dictionary has around a hundred entries; typing now filters the
list and Enter picks the first match. Only dictionary values can be
chosen, as before. Generic SearchableSelect lives in shared until the
ui-kit gets one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 10:07:07 +05:00
Тимур Городецкий 7ca71c6b6c fix: reject work finish date earlier than start date
The legacy works form saved any dates. The finish date picker now has
the start date as minimum and saving shows an error. Found in T-WRK.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 10:07:07 +05:00
Тимур Городецкий 3128190b5c fix: retry AAD client creation after a failed token request
The rejected promise was cached, so a single network hiccup while
getting the Dataverse or API token broke every request to that resource
until the page was reloaded. Seen once in T-OBJ-11.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 10:07:07 +05:00
Тимур Городецкий d2852df976 refactor: remove drag and drop from the objects tree
Decided during manual testing: the legacy drop-inside zone had zero
height, so drops only worked before/after a node and moves happened by
accident. Sections are re-parented via Edit, as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 10:07:06 +05:00
Тимур Городецкий 8ed1a02967 fix: prevent duplicate project resource assignments
With two tabs open (or two users) the same profession or company could
be assigned twice, because only the list loaded in the tab was checked.
Before creating an assignment the server is now asked whether it already
exists; if so its id is reused. Found in T-RES-03.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 12:33:38 +05:00
Тимур Городецкий c69a11d167 feat: add clear button to the project search field
Clears only the search text and reopens the list, the selected project
stays. Requested during manual testing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 12:33:38 +05:00
Тимур Городецкий 45c974d0c6 fix: use fresh project status and rights after reload
After F5 without projectId in the address the hub and sidebar showed the
status and edit right saved in sessionStorage, so a project switched to
Архивный or a revoked edit0 right was ignored until the project was
picked again. The fresh list from SharePoint now always wins; the saved
copy is used only while the list is loading. Found in T-HUB-07/09.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 12:33:38 +05:00
Тимур Городецкий 650bce809a docs: align hub status scenarios with real SharePoint choices
Status choices in the Проекты list are Текущий / Архивный, so the legacy
archive rule matches real data; the projects portal writes its own
status to the API catalogue, which StroyBot does not read.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 11:45:04 +05:00
Тимур Городецкий bb7093de20 docs: fix session restore scenario, record ui-kit look decision
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 10:59:36 +05:00
Тимур Городецкий 355a1300ce docs: record verified SharePoint schema for access and objects lists
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 10:37:03 +05:00
135 changed files with 4442 additions and 407 deletions
+1 -1
View File
@@ -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"
} }
+45 -8
View File
@@ -14,6 +14,8 @@
## 0. Общее (оболочка) ## 0. Общее (оболочка)
> 🔧 **Исправлено при проверке T-RPL (2026-10-19):** «Отчёты по проекту» и форма отчёта брали id пользователя из контекста страницы (`legacyPageContext.userId`) и путь к DocLib — от сайта страницы. У каждого сайта свой id пользователя, поэтому в workbench корневого сайта «Отчёты» показывали «Нет доступа». Теперь всё — от сайта со списками (`settings.siteUrl`): id через `_api/web/currentuser`, путь папки фото — от `siteUrl`. Workbench по умолчанию открывается на `/sites/stroy`.
- [ ] Одна web part, разделы переключаются через `?page=…`, проект — через `?projectId=…` - [ ] Одна web part, разделы переключаются через `?page=…`, проект — через `?projectId=…`
- [ ] Кнопки «назад/вперёд» браузера переключают разделы - [ ] Кнопки «назад/вперёд» браузера переключают разделы
- [ ] Ссылку на любой раздел можно скопировать и открыть в новой вкладке - [ ] Ссылку на любой раздел можно скопировать и открыть в новой вкладке
@@ -73,6 +75,28 @@
- Адрес выгрузки берётся из настройки «Базовый URL API» (по умолчанию тот же `https://api.tnsu.kz`). - Адрес выгрузки берётся из настройки «Базовый URL API» (по умолчанию тот же `https://api.tnsu.kz`).
- Меню слева скрывает разделы по правилам карточек: архивный — только «Проект»; завершён или нет `edit0` — без «Отправить отчёт»; «Привязка к справочнику» — только с `edit0`. - Меню слева скрывает разделы по правилам карточек: архивный — только «Проект»; завершён или нет `edit0` — без «Отправить отчёт»; «Привязка к справочнику» — только с `edit0`.
### Сверка погодных условий — `?page=weather&projectId=&start=&end=`
Добавлена в stroy-bot-main 16.10.2026 (коммиты `d54a581`…`594fc7b`, Mariya Trishina), перенесена 20.10.2026.
- [ ] Кнопка «Сверка погодных условий» рядом с «Выгрузить», активна при обоих датах и верном периоде
- [ ] Ссылка «‹ Разделы проекта» — на главную с теми же датами
- [ ] Шапка: «Сверка погодных условий», название проекта, «Период: ДД.ММ.ГГГГ — ДД.ММ.ГГГГ» (один день — одна дата)
- [ ] Без проекта — «Проект не выбран…», без периода — «Период не указан…», загрузка — «Загрузка данных…»
- [ ] Запрос `GET https://api.tnsu.kz/Report/StroyBot/WeatherCheck?start=…&end=…&projectId=…` (unix как у выгрузки); ошибка — «Не удалось загрузить данные: {status} {statusText}»
- [ ] Метеостанция: «Метеостанция {name}», «WIGOS {code} · {N} км от объекта»
- [ ] «Погода по дням»: Дата, Наблюдений, T мин/макс, Ветер, Порывы, Осадки, Явления; пусто — «Нет данных наблюдений за период»
- [ ] «Позиции и оценка условий труда»: Дата, Профессия / вид работ, Смена (+время), Часы, Тип работ (`hours_type`), Чел., Погода в смену, Оценка, Комментарий, Акт простоя; пусто — «Нет позиций за период»
- [ ] Погода в смену: T мин…макс, ветер (+порывы), осадки, точка росы, видимость (от 1000 м — км), наблюдений, явления; нет — «нет данных»
- [ ] «Запросить оценку ИИ…» → `POST …/WeatherCheck/Assess` с `{ positions }` → «Идёт оценка, это может занять до минуты…» → позиции заменяются ответом, «Оценено позиций: N[, пропущено: M]»; ошибка — «Не удалось получить оценку: …»
- [ ] Оценка «В пределах нормы» — зелёная, другая — жёлтая; акт простоя: «Рекомендован» (+причина) / «Не требуется» / «—»
- [ ] «Примечания» — список, если API их прислал
- [ ] Числа с запятой, пустые — «—»
**Сознательные отличия от main:**
- Вид открывается обычным разделом `?page=weather&start=&end=` вместо хэша `#weathercheck?start=&end=`: «назад/вперёд» и ссылки работают, как у остальных разделов.
- Даты на главной сохраняются в адресе (`?start=&end=`), поэтому возвращаются и по ссылке «Разделы проекта», и кнопкой «назад» браузера (в main — только пока страницу не перезагрузили).
- Ошибки загрузки и оценки — плашкой ui-kit `Alert`, бейджи — `Badge` ui-kit; иконки облака и «искр» — локальный `InlineIcon` (в ките их пока нет).
- Пустое название проекта в списке (правка `?? ''`, которая была незакоммичена в main) у нас уже обработано в `projectRules.ts`.
--- ---
## 2. Объекты (stroy-bot-objects) — `?page=objects&projectId=` ## 2. Объекты (stroy-bot-objects) — `?page=objects&projectId=`
@@ -89,13 +113,15 @@
- [ ] Модалка: «Название *» (обязательно, Enter — сохранить), «Родительский объект» (select только при правке Раздела: узлы уровня 0, сортировка ru, без себя и потомков), «Проект» (read-only) - [ ] Модалка: «Название *» (обязательно, Enter — сохранить), «Родительский объект» (select только при правке Раздела: узлы уровня 0, сортировка ru, без себя и потомков), «Проект» (read-only)
- [ ] Заголовки модалки: «Добавить Объект-пятно / Раздел / Вид работ», «Редактировать …», «— (корневой)» - [ ] Заголовки модалки: «Добавить Объект-пятно / Раздел / Вид работ», «Редактировать …», «— (корневой)»
- [ ] Удаление: подтверждение с числом дочерних, «Это действие нельзя отменить.» - [ ] Удаление: подтверждение с числом дочерних, «Это действие нельзя отменить.»
- [ ] Drag & drop (только `canEdit`): до / внутрь / после, запрет на себя и потомков, ошибка «Не удалось переместить: …» - [-] ~~Drag & drop~~ — **убрано по решению 2026-10-16** (неудобно, зона «внутрь» не работала, лишние случайные переносы). Родителя Раздела меняют через «Редактировать».
- [ ] «Работы» → раздел Работы с `objectId` - [ ] «Работы» → раздел Работы с `objectId`
- [ ] После любой операции дерево перезагружается - [ ] После любой операции дерево перезагружается
- [ ] Список «Объекты»: `Title`, `project` (текст = название проекта), `parent` (self-lookup) - [ ] Список «Объекты»: `Title`, `project` (текст = название проекта), `parent` (self-lookup)
- [ ] Права: «Доступы» `projectId`/`userId` → `edit0` - [ ] Права: «Доступы» `projectId`/`userId` → `edit0`
🐞 Перетаскивание меняет только родителя, порядок не сохраняется. 🐞 Удаление узла не удаляет детей. 🐞 Зона сброса «внутрь» нулевой высоты — на практике узел можно бросить только «до» / «после» (перенесено как есть). 🐞 Удаление узла не удаляет детей.
ℹ️ Экспорт падает с 500 на стороне `api.tnsu.kz`: «Failed to acquire Graph token … SPO_TENANT_ID, SPO_CLIENT_ID, SPO_CERT_PATH, SPO_CERT_PASS» — у API не настроен доступ к Graph (проверка 2026-10-16). Наш запрос доходит до API корректно; чинится на сервере.
**Сознательные отличия от objects:** **Сознательные отличия от objects:**
- `alert()` → сообщение `Alert` на странице (ошибки переноса, удаления, экспорта) или в модалке (ошибка сохранения). Тексты те же. - `alert()` → сообщение `Alert` на странице (ошибки переноса, удаления, экспорта) или в модалке (ошибка сохранения). Тексты те же.
@@ -119,7 +145,7 @@
- [ ] Навигационное свойство родителя определяется через `EntityDefinitions` (fallback `cree9_parentid`) - [ ] Навигационное свойство родителя определяется через `EntityDefinitions` (fallback `cree9_parentid`)
- [ ] Проект находится подъёмом по родителям объекта до непустого `project` - [ ] Проект находится подъёмом по родителям объекта до непустого `project`
🐞 Путь объекта (`#headerTitle`) не выводится. 🐞 Нет проверки «начало ≤ окончание». 🐞 Отступы в списке «Родительская работа» — обычные пробелы, браузер их схлопывает (как было). 🐞 Путь объекта (`#headerTitle`) не выводится. 🐞 Отступы в списке «Родительская работа» — обычные пробелы, браузер их схлопывает (как было).
**Сознательные отличия от works:** **Сознательные отличия от works:**
- `alert()` → сообщение `Alert`: ошибка сохранения — в модалке, ошибки удаления — над таблицей. Тексты те же. - `alert()` → сообщение `Alert`: ошибка сохранения — в модалке, ошибки удаления — над таблицей. Тексты те же.
@@ -127,6 +153,8 @@
- PATCH и DELETE идут через тот же AAD-клиент Dataverse, а не через `fetch` с токеном вручную; заголовки те же. - PATCH и DELETE идут через тот же AAD-клиент Dataverse, а не через `fetch` с токеном вручную; заголовки те же.
- Адрес сайта — настройка «URL сайта SharePoint» (раньше `pageContext.web.absoluteUrl`), Dataverse — настройка «URL Dataverse»; имя таблицы — константа (раньше настройка `worksTable`). - Адрес сайта — настройка «URL сайта SharePoint» (раньше `pageContext.web.absoluteUrl`), Dataverse — настройка «URL Dataverse»; имя таблицы — константа (раньше настройка `worksTable`).
- Таблица — `DataTable` из ui-kit; меню ⋮ — общее меню из `pages/_tree`. - Таблица — `DataTable` из ui-kit; меню ⋮ — общее меню из `pages/_tree`.
- 🔧 **Исправлено при проверке T-WRK (2026-10-19):** дату окончания нельзя поставить раньше даты начала — календарь не даёт, а при сохранении ошибка «Дата окончания не может быть раньше даты начала» (в старом коде проверки не было).
- ✨ **Улучшено по просьбе (2026-10-19):** «Ед. изм.» — список с поиском: можно начать печатать, Enter выбирает первое найденное. Выбрать можно только значение из справочника, как и раньше.
--- ---
@@ -181,18 +209,25 @@
### Сохранение ### Сохранение
- [ ] Валидация (все сообщения из исходника) - [ ] Валидация (все сообщения из исходника)
- [ ] Номер = количество неудалённых отчётов проекта + 1 - [ ] Номер = наибольший номер в проекте (включая удалённые) + 1 — в старом коде «количество неудалённых + 1», см. 🔧 ниже
- [ ] При правке — удалить все старые строки (сначала дочерние), создать заново - [ ] При правке — удалить все старые строки (сначала дочерние), создать заново
- [ ] Фото → `DocLib/{projectId}_{number}` - [ ] Фото → `DocLib/{projectId}_{number}`
- [ ] «Отчет успешно сохранен!», кнопка меняется на «← Вернуться назад» - [ ] «Отчет успешно сохранен!», кнопка меняется на «← Вернуться назад»
- [ ] Утверждение: подтверждение → `cree9_aprooved = true` → «Отчет утвержден!» - [ ] Утверждение: подтверждение → `cree9_aprooved = true` → «Отчет утвержден!»
- [ ] Права: «Доступы» `projectId` + `userId*` (несколько вариантов имени поля) - [ ] Права: «Доступы» `projectId` + `userId*` (несколько вариантов имени поля)
🐞 Create без проверки NoAccess. 🐞 Номер может совпасть после мягкого удаления. 🐞 Дата «сегодня» — UTC. 🐞 Create без проверки NoAccess. 🐞 Дата «сегодня» — UTC.
🔧 **Исправлено при проверке T-REP (2026-10-19):**
- Только что созданная из пикера работа попадала в карточку с полным путём вместо названия, без ед. изм. и с планом 0 (в старом коде так же).
- Окно выбора на экране шире 640px — по центру (было всегда прижато к низу), поле «Значение» не вылезает за карточку.
- После «Утвердить отчет» бейдж оставался «На утверждении» до F5 — теперь сразу «Утверждён».
- «← Вернуться назад» во вкладке, открытой из списка отчётов (истории нет), ничего не делал — теперь ведёт в «Отчёты по проекту».
- Номер отчёта повторялся после мягкого удаления (номер = количество неудалённых + 1), а вместе с ним и папка фото `{projectId}_{номер}`. Теперь номер = наибольший номер в проекте, включая удалённые, + 1.
**Сознательные отличия от report:** **Сознательные отличия от report:**
- `confirm('Утвердить отчет? …')` → `confirmModal` из ui-kit, текст тот же. - `confirm('Утвердить отчет? …')` → `confirmModal` из ui-kit, текст тот же.
- Адрес сайта для списков и ссылки на папку фото — настройка «URL сайта SharePoint» (раньше `pageContext.web.absoluteUrl`); путь папки при создании — по-прежнему от сайта, где стоит страница (`web.serverRelativeUrl`). По умолчанию это один и тот же сайт `/sites/stroy`. - Адрес сайта для списков и ссылки на папку фото — настройка «URL сайта SharePoint» (раньше `pageContext.web.absoluteUrl`); путь папки при создании и id пользователя для проверки прав — тоже от этого сайта (раньше — от сайта, где стоит страница). По умолчанию это один и тот же сайт `/sites/stroy`.
- Если Dataverse ответит на создание записи `204` без тела, старый код брал id из заголовка `OData-EntityId`, а новый клиент заголовков не видит. С `Prefer: return=representation` Dataverse отвечает `201` с телом, поэтому на практике не встречается; при пустом id сохранение останавливается той же ошибкой `reportId is empty`. - Если Dataverse ответит на создание записи `204` без тела, старый код брал id из заголовка `OData-EntityId`, а новый клиент заголовков не видит. С `Prefer: return=representation` Dataverse отвечает `201` с телом, поэтому на практике не встречается; при пустом id сохранение останавливается той же ошибкой `reportId is empty`.
- Логика режимов, валидации, сборки строк и порядка записи / удаления сверена со старым кодом построчно и покрыта тестами (45 тестов). - Логика режимов, валидации, сборки строк и порядка записи / удаления сверена со старым кодом построчно и покрыта тестами (45 тестов).
@@ -241,6 +276,7 @@
- Текст после префикса ошибки («Ошибка загрузки данных: …» и т.п.) — сообщение `HttpError` (`{status} {statusText}: {ответ сервера}`), а не формат ошибки PnP. - Текст после префикса ошибки («Ошибка загрузки данных: …» и т.п.) — сообщение `HttpError` (`{status} {statusText}: {ответ сервера}`), а не формат ошибки PnP.
- Счётчик во вкладке — часть подписи («Профессии (0)»): `count` в Tabs ui-kit скрывает ноль. - Счётчик во вкладке — часть подписи («Профессии (0)»): `count` в Tabs ui-kit скрывает ноль.
- Кнопки «+» / «−» — `Button` ui-kit (с подсказками «Добавить» / «Удалить»), сообщение об ошибке закрывается крестиком рядом с `Alert`. - Кнопки «+» / «−» — `Button` ui-kit (с подсказками «Добавить» / «Удалить»), сообщение об ошибке закрывается крестиком рядом с `Alert`.
- 🔧 **Исправлено (найдено при проверке T-RES, 2026-10-16):** перед добавлением проверяется, нет ли уже такой привязки на сервере. Раньше при двух открытых вкладках (или двух пользователях) одна профессия / компания добавлялась дважды. Если привязка уже есть — новая запись не создаётся, показывается существующая.
--- ---
@@ -265,5 +301,6 @@
## Открытые вопросы ## Открытые вопросы
- Схема списка **«Доступы»**: в репозиториях встречаются и поля `projectId`/`userId`, и lookup `user`/`project`. Проверить на сайте. - ✅ Схема списка **«Доступы»** проверена на сайте (2026-10-16): `project` — Lookup на «Проекты», `user` — Person, права `read` / `edit0` / `aproove`. SharePoint отдаёт к ним `projectId` / `userId` — поэтому оба способа из старых репозиториев (`project/Id` + `$expand` и `projectId` / `userId`) обращаются к одним и тем же полям. Расхождения нет.
- Схема списка **«Объекты»**: родитель читается то как `parentId`, то как `parent/Id`. Проверить на сайте. - ✅ Схема списка **«Объекты»** проверена на сайте (2026-10-16): `parent` — Lookup («Объект»), SharePoint отдаёт к нему `parentId` — чтение через `parent/Id` и запись через `parentId` обращаются к одному полю. `project` — Text (название проекта), как и ожидает код.
- ✅ В «Объектах» есть поля `level` (Number) и `skipLevelUpdate` (Boolean), которые не писал ни один старый репозиторий. Проверено 2026-10-16: `level` пустой и у старых настоящих объектов — поле никто не заполняет и ни на что не влияет. Новое приложение его тоже не трогает.
+68 -5
View File
@@ -6,6 +6,7 @@
- Всё, что **записывает** данные (создать / изменить / удалить / импорт / утвердить), — **только на проекте «ТЕСТ StroyBot»**. Такие сценарии помечены ✍️. - Всё, что **записывает** данные (создать / изменить / удалить / импорт / утвердить), — **только на проекте «ТЕСТ StroyBot»**. Такие сценарии помечены ✍️.
- Всё, что только **читает**, проверяем на настоящих проектах — **сравнением со старой страницей** (см. «Главный приём»). - Всё, что только **читает**, проверяем на настоящих проектах — **сравнением со старой страницей** (см. «Главный приём»).
- Нашёл расхождение — не исправляй ничего сам, запиши по шаблону «Как сообщить о баге» и иди дальше. - Нашёл расхождение — не исправляй ничего сам, запиши по шаблону «Как сообщить о баге» и иди дальше.
- **Оформление — в стиле ui-kit** (решение 2026-10-16). Отличия во внешнем виде от старых страниц (раскладка, цвета, регистр подписей) **багом не считаются**. Сравниваем функции, тексты, данные, права. Баг по виду — только если что-то не видно, обрезано, наезжает или не нажимается.
- Отметки: `[x]` — совпало, `[!]` — баг (номер бага рядом), `[-]` — не применимо. - Отметки: `[x]` — совпало, `[!]` — баг (номер бага рядом), `[-]` — не применимо.
--- ---
@@ -60,8 +61,8 @@
- [ ] **T-APP-03** Нажать «назад» в браузере → вернулись на главную. «Вперёд» → снова объекты. - [ ] **T-APP-03** Нажать «назад» в браузере → вернулись на главную. «Вперёд» → снова объекты.
- [ ] **T-APP-04** Скопировать адрес раздела, открыть в новой вкладке → открылся тот же раздел того же проекта. - [ ] **T-APP-04** Скопировать адрес раздела, открыть в новой вкладке → открылся тот же раздел того же проекта.
- [ ] **T-APP-05** F5 на любом разделе → остались в нём. - [ ] **T-APP-05** F5 на любом разделе → остались в нём.
- [ ] **T-APP-06** Закрыть вкладку, открыть web part заново (без `projectId` в адресе) → проект восстановился из сессии. - [ ] **T-APP-06** В **той же вкладке** удалить из адреса `&page=…&projectId=…`, Enter, заново добавить web part → на главной уже выбран прежний проект, меню — со всеми разделами. (Выбор хранится в `sessionStorage` — он живёт только в пределах вкладки; после закрытия вкладки не сохраняется — так было и в старом main.)
- [ ] **T-APP-07** Шрифты: заголовки — с засечками (Lora), текст — Inter. Сравнить «на глаз» со старым main. - [ ] **T-APP-07** Шрифты подгрузились: заголовки разделов — с засечками (Lora), остальной текст — Inter.
- [ ] **T-APP-08** Узкое окно (< 768px) → меню прячется, появляется кнопка-бургер, меню открывается поверх. - [ ] **T-APP-08** Узкое окно (< 768px) → меню прячется, появляется кнопка-бургер, меню открывается поверх.
## 2. Главная — `T-HUB` (сравнение со старым stroy-bot-main) ## 2. Главная — `T-HUB` (сравнение со старым stroy-bot-main)
@@ -72,13 +73,25 @@
- [ ] **T-HUB-05** Без выбранного проекта все карточки неактивны, видна подсказка. - [ ] **T-HUB-05** Без выбранного проекта все карточки неактивны, видна подсказка.
- [ ] **T-HUB-06** «ТЕСТ StroyBot», роль Е, статус **текущий** → все 6 карточек активны; меню — все разделы. - [ ] **T-HUB-06** «ТЕСТ StroyBot», роль Е, статус **текущий** → все 6 карточек активны; меню — все разделы.
- [ ] **T-HUB-07** Роль **Р** → неактивна только «Отправить отчёт»; в меню нет «Отправить отчёт» и «Привязка к справочнику». - [ ] **T-HUB-07** Роль **Р** → неактивна только «Отправить отчёт»; в меню нет «Отправить отчёт» и «Привязка к справочнику».
- [ ] **T-HUB-08** Статус **завершен** (роль Е) → неактивна только «Отправить отчёт». - [-] **T-HUB-08** Статус «завершен» — **не применимо**: в SharePoint у поля «Статус» только «Текущий» и «Архивный» (проверено 2026-10-16). Правило в коде остаётся на случай, если вариант появится.
- [ ] **T-HUB-09** Статус **архивный** → активен только «Архив», выгрузка неактивна, в меню только «Проект». Вернуть статус **текущий**. - [ ] **T-HUB-09** Статус **«Архивный»** (менять в SharePoint, список «Проекты») → бейдж золотой, активен только «Архив», выгрузка неактивна, в меню только «Проект». Вернуть **«Текущий»**.
- ⚠️ Статус меняем **в списке SharePoint**, а не на портале проектов: портал пишет статус («Текущий» / «Архив») в справочник API, StroyBot его не читает.
- [ ] **T-HUB-10** «Архив проекта» → в новой вкладке папка `Документы/Архив/{проект}` (та же, что открывает старый main). - [ ] **T-HUB-10** «Архив проекта» → в новой вкладке папка `Документы/Архив/{проект}` (та же, что открывает старый main).
- [ ] **T-HUB-11** Ctrl+клик по карточке «Объекты» → раздел открылся в новой вкладке. - [ ] **T-HUB-11** Ctrl+клик по карточке «Объекты» → раздел открылся в новой вкладке.
- [ ] **T-HUB-12** Выгрузка: «?» показывает 3 строки и закрывается кликом мимо. Даты 13.10–19.10 → «Отчёт за период»; 12.10–18.10 (Пн–Вс) → «Еженедельный»; один день → «Ежедневный»; начало позже конца → ошибка, кнопка неактивна. - [ ] **T-HUB-12** Выгрузка: «?» показывает 3 строки и закрывается кликом мимо. Даты 13.10–19.10 → «Отчёт за период»; 12.10–18.10 (Пн–Вс) → «Еженедельный»; один день → «Ежедневный»; начало позже конца → ошибка, кнопка неактивна.
- [ ] **T-HUB-13** «Выгрузить» на большом проекте за неделю → скачался `weekly_…xlsx`. **Сравнить** с файлом из старого main за тот же период — содержимое одинаковое. - [ ] **T-HUB-13** «Выгрузить» на большом проекте за неделю → скачался `weekly_…xlsx`. **Сравнить** с файлом из старого main за тот же период — содержимое одинаковое.
### Сверка погодных условий — `T-WX` (фича из main от 16.10)
- [ ] **T-WX-01** Без дат или начало позже конца — «Сверка погодных условий» неактивна; обе даты верные — активна. Архивный проект — неактивна вместе с карточкой.
- [ ] **T-WX-02** Проект 17, неделя с отчётами → открылась сверка: шапка с проектом и периодом, метеостанция, «Погода по дням», «Позиции…». **Сравнить** со старым main за тот же период: те же строки и числа.
- [ ] **T-WX-03** «‹ Разделы проекта» → главная, даты остались. Ещё раз открыть сверку → «назад» браузера → главная, даты остались.
- [ ] **T-WX-04** Период без отчётов → «Нет позиций за период», кнопка оценки неактивна.
- [ ] **T-WX-05** Адрес `?page=weather&projectId=51` без дат → «Период не указан…». Меню слева подсвечивает «Проект».
- [ ] ✍️ **T-WX-06** Тестовый проект 51, 19.10.2026 (там есть позиции) → «Запросить оценку ИИ…» → спиннер до минуты → в «Оценке» и «Акте простоя» появились бейджи, сверху «Оценено позиций: N». Уйти назад во время оценки → ошибок в консоли нет.
- [ ] **T-WX-07** 360px: таблицы прокручиваются внутри своей рамки, страница целиком вбок не едет.
> ✅ **2026-10-20:** T-WX-01…05 пройдены. T-WX-02 — проект 35, 13.10–20.10: станция, все 8 дней и позиции совпали со старым main. T-WX-06 проверена на проекте 35 (оценено 138 позиций). T-WX-07 — на телефоне после деплоя (workbench не сужается до 360px), вместе с T-RSP-09.
## 3. Ресурсы проекта — `T-RES` ## 3. Ресурсы проекта — `T-RES`
- [ ] **T-RES-01** Большой проект: вкладки «Профессии (N)», «Компании (N)» — числа и списки совпадают со старой страницей. - [ ] **T-RES-01** Большой проект: вкладки «Профессии (N)», «Компании (N)» — числа и списки совпадают со старой страницей.
- [ ] **T-RES-02** Поиск слева, «✕» очищает; «уже добавлено» у привязанных. - [ ] **T-RES-02** Поиск слева, «✕» очищает; «уже добавлено» у привязанных.
@@ -101,7 +114,7 @@
- [ ] ✍️ **T-OBJ-05** Меню ⋮ у «Корпус 1» → «Добавить объект» → «Раздел» «Фундамент». У «Фундамент» → «Вид работ» «Бетонирование». У вида работ нет пункта «Добавить объект». - [ ] ✍️ **T-OBJ-05** Меню ⋮ у «Корпус 1» → «Добавить объект» → «Раздел» «Фундамент». У «Фундамент» → «Вид работ» «Бетонирование». У вида работ нет пункта «Добавить объект».
- [ ] ✍️ **T-OBJ-06** Пустое название → сообщение «Поле обязательно…»; Enter сохраняет. - [ ] ✍️ **T-OBJ-06** Пустое название → сообщение «Поле обязательно…»; Enter сохраняет.
- [ ] ✍️ **T-OBJ-07** Редактировать «Фундамент» → сменить родителя (нужен второй объект-пятно «Корпус 2»). - [ ] ✍️ **T-OBJ-07** Редактировать «Фундамент» → сменить родителя (нужен второй объект-пятно «Корпус 2»).
- [ ] ✍️ **T-OBJ-08** Перетащить объект на другой → родитель сменился; на своего потомка — запрещено. - [-] **T-OBJ-08** Перетаскивание — **убрано** (решение 2026-10-16). Проверить, что строку дерева мышкой больше не перетащить.
- [ ] ✍️ **T-OBJ-09** Удалить объект с детьми → подтверждение с числом детей; дети остаются (🐞 так было). - [ ] ✍️ **T-OBJ-09** Удалить объект с детьми → подтверждение с числом детей; дети остаются (🐞 так было).
- [ ] **T-OBJ-10** «Экспорт» → скачался `objects-{id}.xlsx`, содержимое как у старой страницы. - [ ] **T-OBJ-10** «Экспорт» → скачался `objects-{id}.xlsx`, содержимое как у старой страницы.
- [ ] **T-OBJ-11** Меню ⋮ → «Работы» → открылся раздел «Работы» этого объекта. - [ ] **T-OBJ-11** Меню ⋮ → «Работы» → открылся раздел «Работы» этого объекта.
@@ -118,6 +131,9 @@
- [ ] **T-WRK-09** Роль **Р** → нет кнопок добавления и меню ⋮. - [ ] **T-WRK-09** Роль **Р** → нет кнопок добавления и меню ⋮.
## 7. Импорт работ — `T-IMP` ## 7. Импорт работ — `T-IMP`
> ⛔ **T-IMP-02…07 заблокированы (2026-10-19):** сначала `GET https://api.tnsu.kz/Project/{oid}/works` отвечал 500 `42703: column l.name does not exist` — команда API исправила. Теперь он отвечает 200, но `totalCount: 0` у всех проектов: сметы в справочник ещё не загружены. Старый web part показывает то же «Работы не найдены». Повторить, когда появится хотя бы один проект со сметой (тестовый проект 51 привязать к нему; настоящие проекты не привязывать). T-IMP-01 ✅.
> Проверено по коду: API урезает `pageSize=200` до 100, но число страниц берётся из `totalPages` ответа — работы не теряются.
- [ ] **T-IMP-01** Проект без привязки к справочнику → «Проект не привязан к справочнику…». - [ ] **T-IMP-01** Проект без привязки к справочнику → «Проект не привязан к справочнику…».
- [ ] **T-IMP-02** Большой привязанный проект: «Загрузка работ… X из Y», затем число работ совпадает со старой страницей. - [ ] **T-IMP-02** Большой привязанный проект: «Загрузка работ… X из Y», затем число работ совпадает со старой страницей.
- [ ] **T-IMP-03** Поиск (код СНБ, объём, название), страницы по 100. - [ ] **T-IMP-03** Поиск (код СНБ, объём, название), страницы по 100.
@@ -156,10 +172,57 @@
- [ ] **T-REP-18** Роль **Р** → отчёт только для чтения, кнопок нет. - [ ] **T-REP-18** Роль **Р** → отчёт только для чтения, кнопок нет.
- [ ] **T-REP-19** Роль **Н** → «Нет доступа» при открытии существующего отчёта. - [ ] **T-REP-19** Роль **Н** → «Нет доступа» при открытии существующего отчёта.
- [ ] **T-REP-20** Большой проект: открыть 3 разных старых отчёта → всё совпадает со старой формой (план-факт, персонал, техника, фото-папка). - [ ] **T-REP-20** Большой проект: открыть 3 разных старых отчёта → всё совпадает со старой формой (план-факт, персонал, техника, фото-папка).
- Проверено на проекте 17: №2030 и ещё 2 отчёта совпали, режим правки тот же. Отчёт с персоналом/техникой **внутри работы** найти не удалось (прямого доступа к Dataverse нет) — вложенные строки проверены только на тестовом проекте (T-REP-10/11). Дополнительно: встретится такой отчёт — сверить со старой формой.
- [ ] **T-REP-21** «← Вернуться назад» → вернулись туда, откуда пришли. - [ ] **T-REP-21** «← Вернуться назад» → вернулись туда, откуда пришли.
- [ ] **T-REP-22** Enter в любом поле не перезагружает страницу. - [ ] **T-REP-22** Enter в любом поле не перезагружает страницу.
- [ ] ✍️ **T-REP-23** Удалить тестовый отчёт из «Отчёты по проекту» → «Удалить отчёт?» → пропал из списка (мягкое удаление). - [ ] ✍️ **T-REP-23** Удалить тестовый отчёт из «Отчёты по проекту» → «Удалить отчёт?» → пропал из списка (мягкое удаление).
## 9a. Импорт отчёта из Excel — `T-RIM` (новая фича)
> `POST /Import/StroyBot/Reports?projectId=` **только проверяет** файл и ничего не пишет (проверено 2026-10-22: после ответа без ошибок в `cree9_reports` / `cree9_report_datas` записей нет). Отчёты пишет фронт по `ids` из ответа — тем же `saveReportWithData`, что форма «Отправить отчёт».
> Правила сборки: один отчёт = дата + компания + смена + объект (самый глубокий); каждая строка Excel — своя строка-работа; ресурс с «Часы работы» — под работу; простой / ремонт — отдельной строкой вне работы, «Комментарий (данные)» — ей. Вид работы сервер требует в каждой строке. ⚠️ Только проект **51**.
> Тестовый файл: `Downloads\ТЕСТ импорт отчета - проект 51 (v2).xlsx` (4 строки без ошибок → 2 отчёта, за 01.10 и 02.10; строка 4 — простой).
- [ ] **T-RIM-01** Меню: «Импорт отчёта из Excel» есть у роли **Е**; у **Р** (без edit0) — нет; по прямому адресу без прав — «Импорт отчётов недоступен…».
- [ ] **T-RIM-02** Выбор файла **сразу запускает проверку**: «Проверяем файл…», в F12 → Network один `POST Reports` (+ preflight). Кнопка выбора файла на время проверки неактивна.
- [ ] **T-RIM-03** Файл с ошибками → «С ошибками: N», «Исправьте ошибки в Excel и загрузите файл снова.», кнопки «Импортировать» нет.
- [ ] **T-RIM-04** Таблица: все 18 колонок файла; красные ячейки с ошибками; наведение — текст ошибки; справа «Ошибки»; фильтр «Только строки с ошибками».
- [ ] **T-RIM-05** Файл не .xlsx → «Нужен файл Excel в формате .xlsx», проверка не запускается.
- [ ] **T-RIM-06** v2 → «Ошибок нет — файл готов к импорту.», кнопка «Импортировать (отчётов: 2)» → окно «Будет создано отчётов: 2 в проекте «…»» → «Отмена» — ничего не записано.
- [ ] ✍️ **T-RIM-07** То же → «Импортировать» → «Сохраняем отчёты… 1 из 2» → «Импорт завершён. Созданы отчёты: №… за 01.10.2026, №… за 02.10.2026» и ссылка на «Отчёты по проекту»; кнопки больше нет. (Проверено 2026-10-22: №5, №6.)
- [ ] **T-RIM-08** Отчёт за 01.10 в форме: три работы «Арматура» (5, 3, 2); под первой техника «Бетонный насос» 1×11, под второй персонал «Бетонщик» 10×11; в «Технике» отдельно насос 1×3 «Часы простоя». За 02.10: «Арматура» 4, под ней «Бетонщик» 5×11.
## 9b. Импорт работ из Excel — `T-WIM` (новая фича)
> `POST /Import/StroyBot/Works?projectId=` **только проверяет** файл (как и импорт отчётов). Объекты должны уже быть в проекте — иначе ошибка «… не найден внутри …». В `ids` — id самого глубокого объекта и единицы; id работы сервер не возвращает.
> Пишет фронт теми же `createWork` / `updateWork`, что окно работы. Работа с тем же названием на объекте (без учёта регистра и пробелов, на любом уровне иерархии) — **обновляются только объём и единица**; иначе — создаётся работа верхнего уровня. Две одноимённые на объекте или одна работа дважды в файле — ошибка, ничего не пишется. ⚠️ Только проект **51**.
> Тестовый файл: `Downloads\ТЕСТ импорт работ - проект 51.xlsx` (Тестовый объект / Бетонирование: «Арматура» 120 м — есть, «Тестовая работа импорта» 50 м3 — новая).
- [ ] **T-WIM-01** Меню: «Импорт работ из Excel» после «Загрузить работы из сметы»; есть у роли **Е**, у **Р** (без edit0) — нет; в архивном проекте — нет; в завершённом — есть.
- [ ] **T-WIM-02** Файл с несуществующим объектом → «С ошибками: 1», «Объект 2: «…» не найден внутри «…»», кнопки «Импортировать» нет.
- [ ] **T-WIM-03** Тестовый файл → «Сверяем с работами объектов…» → «Будет создано работ: 1, обновлён … у существующих: 1», кнопка «Импортировать (работ: 2)».
- [ ] **T-WIM-03a** У работ, где план уже есть, — таблица «Сейчас → Станет»: изменившийся объём подсвечен со стрелкой ▲/▼ (например, «Арматура» 120 м → 150 м ▲), совпадающий — серым «без изменений». План **заменяется** значением из файла, не складывается. В окне подтверждения — «…плановый объём будет заменён значением из файла». (Проверено 2026-10-23.)
- [ ] ✍️ **T-WIM-04** «Импортировать» → окно «… будет создано работ: 1, обновлено: 1» → «Сохраняем работы…» → «Импорт завершён: создано работ — 1, обновлено — 1» и ссылка «Открыть «Объекты»». (Проверено 2026-10-22.)
- [ ] **T-WIM-05** «Объекты» → «Бетонирование» → «Работы»: «Арматура» — 120 м, родитель «Бетон B25» и даты не изменились; «Тестовая работа импорта» — 50 м3, верхний уровень.
- [ ] ✍️ **T-WIM-06** Тот же файл ещё раз → «создано: 0, обновлено: 2» — работы не задваиваются. (Проверено 2026-10-22.)
- [ ] **T-WIM-07** Одна работа объекта дважды в файле → «Строки … и …: работа «…» на одном объекте указана дважды», кнопки нет.
## 10. Адаптивность — `T-RSP`
> Как: F12 → кнопка «телефон/планшет» (Ctrl+Shift+M) → сверху выбрать ширину → F5. Только просмотр, ничего не сохранять.
> Ширины: **360** (обычный телефон), **768** (планшет), **1280** (ноутбук), плюс твой монитор как есть. На каждой ширине — признаки бага:
> горизонтальная прокрутка всей страницы, текст/кнопка вылезает за край, элементы налезают друг на друга, что-то нельзя нажать.
- [ ] **T-RSP-01** 360: меню скрыто, кнопка ☰ открывает его поверх, выбор раздела закрывает меню.
- [ ] **T-RSP-02** 360: главная — выбор проекта, даты в столбик, карточки в одну колонку.
- [ ] **T-RSP-03** 360: «Отчёты по проекту» — фильтр дат над поиском, карточки в одну колонку, кнопки копирования/удаления нажимаются.
- [ ] **T-RSP-04** 360: форма отчёта — поля во всю ширину, «Кол-во/Часы» друг под другом, пикер — шторка снизу, список прокручивается, «Сохранить» видна.
- [ ] **T-RSP-05** 360: «Объекты» и «Работы» — дерево не уезжает вправо, длинные названия переносятся/обрезаются, меню ⋮ не уходит за экран.
- [ ] **T-RSP-06** 360: «Ресурсы», «Привязка», «Импорт» — колонки складываются, модалки помещаются в экран.
- [ ] **T-RSP-07** 768: все разделы — то же беглым просмотром; пикер отчёта — окно по центру.
- [ ] **T-RSP-08** 1280 и свой монитор: меню слева, ничего не растянуто на всю ширину неудобно, форма отчёта — узкая колонка.
- [ ] **T-RSP-09** После деплоя: открыть страницу на **настоящем телефоне** (браузер и приложение SharePoint) — меню, форма отчёта, пикер (сохранять только на тестовом проекте 51).
--- ---
## Как сообщить о баге ## Как сообщить о баге
+4 -4
View File
@@ -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
View File
@@ -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"
+1
View File
@@ -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)} />}
+9
View File
@@ -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 />;
+29 -6
View File
@@ -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');
}); });
+26 -6
View File
@@ -1,5 +1,6 @@
import { IconName, ISidebarItem } from '@tnsu/ui-kit'; import { IconName, ISidebarItem } from '@tnsu/ui-kit';
import { PageId } from './routing/routes'; import { PageId } from './routing/routes';
import { CATALOG_LINKS } from '../config/catalogLinks';
export interface INavItem { export interface INavItem {
page: PageId; page: PageId;
@@ -15,25 +16,44 @@ export const NAV_ITEMS: INavItem[] = [
{ page: 'objects', title: 'Объекты', icon: 'folder', needsProject: true }, { page: 'objects', title: 'Объекты', icon: 'folder', needsProject: true },
{ page: 'reports', title: 'Отчёты по проекту', icon: 'clipboard', needsProject: true }, { page: 'reports', title: 'Отчёты по проекту', icon: 'clipboard', needsProject: true },
{ page: 'report', title: 'Отправить отчёт', icon: 'send', needsProject: true }, { page: 'report', title: 'Отправить отчёт', icon: 'send', needsProject: true },
{ page: 'reportImport', title: 'Импорт отчёта из Excel', icon: 'upload', needsProject: true },
{ page: 'import', title: 'Загрузить работы из сметы', icon: 'download', needsProject: true }, { page: 'import', title: 'Загрузить работы из сметы', icon: 'download', needsProject: true },
{ page: 'worksImport', title: 'Импорт работ из Excel', icon: 'document', needsProject: true },
{ page: 'resources', title: 'Ресурсы проекта', icon: 'people', needsProject: true }, { page: 'resources', title: 'Ресурсы проекта', icon: 'people', needsProject: true },
{ page: 'binder', title: 'Привязка к справочнику', icon: 'swap', needsProject: true } { page: 'binder', title: 'Привязка к справочнику', icon: 'swap', needsProject: true }
]; ];
/** Пункт меню, который подсвечивается для раздела. Работы — подраздел объектов, форма отчёта — свой пункт. */ /**
* Пункт меню, который подсвечивается для раздела. Работы — подраздел объектов,
* сверка погоды открывается с главной, форма отчёта — свой пункт.
*/
export function navPageFor(page: PageId): PageId { export function navPageFor(page: PageId): PageId {
return page === 'works' ? 'objects' : page; if (page === 'works') return 'objects';
if (page === 'weather') return 'hub';
return page;
} }
/** /**
* Пункты меню для текущего состояния. Без проекта — только главная. * «Справочники» — раскрывающаяся группа ссылок на списки сайта (новая вкладка).
* Разделом приложения не является: onChange меню её не получает. Права проверяет SharePoint.
*/
export const CATALOGS_GROUP: ISidebarItem = {
id: 'catalogs',
title: 'Справочники',
icon: 'info',
children: CATALOG_LINKS.map((link) => ({ id: link.id, title: link.title, href: link.url }))
};
/**
* Пункты меню для текущего состояния. Без проекта — только главная и справочники.
* isAvailable — правила доступа к разделу (статус проекта, права); по умолчанию всё доступно. * isAvailable — правила доступа к разделу (статус проекта, права); по умолчанию всё доступно.
*/ */
export function buildSidebarItems( export function buildSidebarItems(
projectId: string | undefined, projectId: string | undefined,
isAvailable: (page: PageId) => boolean = () => true isAvailable: (page: PageId) => boolean = () => true
): ISidebarItem[] { ): ISidebarItem[] {
return NAV_ITEMS.filter((item) => !item.needsProject || (projectId !== undefined && isAvailable(item.page))).map( const sections = NAV_ITEMS.filter(
(item) => ({ id: item.page, title: item.title, icon: item.icon }) (item) => !item.needsProject || (projectId !== undefined && isAvailable(item.page))
); ).map((item) => ({ id: item.page, title: item.title, icon: item.icon }));
return [...sections, CATALOGS_GROUP];
} }
@@ -4,6 +4,7 @@ import { useRouter } from '../routing/RouterContext';
import { IAccessibleProject } from './projectTypes'; import { IAccessibleProject } from './projectTypes';
import { fetchAccessibleProjects } from './projectRepository'; import { fetchAccessibleProjects } from './projectRepository';
import { readStoredProject, storeProject } from './projectSession'; import { readStoredProject, storeProject } from './projectSession';
import { resolveCurrentProject } from './projectRules';
export type ProjectsLoadState = 'loading' | 'ready' | 'error'; export type ProjectsLoadState = 'loading' | 'ready' | 'error';
@@ -51,13 +52,12 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ child
if (isCancelled) return; if (isCancelled) return;
setProjects(loaded); setProjects(loaded);
setLoadState('ready'); setLoadState('ready');
// В сессии могли остаться устаревшие права — обновляем по свежим данным (как в main). // В сессии могли остаться устаревшие статус и права — сохраняем свежие.
setStoredProject((stored) => { setStoredProject((stored) => {
const fresh = stored ? findProject(loaded, stored.id) : undefined; const fresh = stored ? findProject(loaded, stored.id) : undefined;
if (!stored || !fresh) return stored; if (!fresh) return stored;
const updated = { ...stored, canEdit: fresh.canEdit }; storeProject(fresh);
storeProject(updated); return fresh;
return updated;
}); });
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
@@ -71,13 +71,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ child
}, [services, reloadKey]); }, [services, reloadKey]);
const routeProjectId = route.projectId ? Number(route.projectId) : undefined; const routeProjectId = route.projectId ? Number(route.projectId) : undefined;
let currentProject: IAccessibleProject | undefined; const currentProject = resolveCurrentProject(projects, routeProjectId, storedProject);
if (routeProjectId === undefined) {
currentProject = storedProject;
} else {
currentProject =
findProject(projects, routeProjectId) ?? (storedProject?.id === routeProjectId ? storedProject : undefined);
}
const currentProjectId = route.projectId ?? (storedProject ? String(storedProject.id) : undefined); const currentProjectId = route.projectId ?? (storedProject ? String(storedProject.id) : undefined);
const selectProject = React.useCallback( const selectProject = React.useCallback(
@@ -5,6 +5,7 @@ import {
isSectionAvailable, isSectionAvailable,
mergeAccessRows, mergeAccessRows,
pickRecentProjectIds, pickRecentProjectIds,
resolveCurrentProject,
sortProjects sortProjects
} from './projectRules'; } from './projectRules';
import { IAccessibleProject } from './projectTypes'; import { IAccessibleProject } from './projectTypes';
@@ -71,6 +72,25 @@ describe('getStatusKind', () => {
}); });
}); });
describe('resolveCurrentProject', () => {
const stored = project({ id: 51, status: 'Текущий', canEdit: true });
const fresh = project({ id: 51, status: 'Архив', canEdit: false });
it('свежие статус и права из списка главнее сохранённых в сессии (баг T-HUB-07/09)', () => {
expect(resolveCurrentProject([fresh], undefined, stored)).toBe(fresh);
expect(resolveCurrentProject([fresh], 51, stored)).toBe(fresh);
});
it('пока список не загрузился — сохранённый выбор', () => {
expect(resolveCurrentProject([], undefined, stored)).toBe(stored);
});
it('проект из адреса, которого нет ни в списке, ни в сессии, — не выбран', () => {
expect(resolveCurrentProject([fresh], 7, stored)).toBeUndefined();
expect(resolveCurrentProject([], undefined, undefined)).toBeUndefined();
});
});
describe('isSectionAvailable', () => { describe('isSectionAvailable', () => {
it('архивный проект — закрыты все разделы, кроме главной', () => { it('архивный проект — закрыты все разделы, кроме главной', () => {
const archived = project({ status: 'архивный' }); const archived = project({ status: 'архивный' });
@@ -99,4 +119,17 @@ describe('isSectionAvailable', () => {
expect(isSectionAvailable('report', current)).toBe(true); expect(isSectionAvailable('report', current)).toBe(true);
expect(isSectionAvailable('binder', current)).toBe(true); expect(isSectionAvailable('binder', current)).toBe(true);
}); });
it('импорт отчёта из Excel — по тем же правилам, что «Отправить отчёт»', () => {
expect(isSectionAvailable('reportImport', project({ status: 'текущий', canEdit: true }))).toBe(true);
expect(isSectionAvailable('reportImport', project({ status: 'текущий', canEdit: false }))).toBe(false);
expect(isSectionAvailable('reportImport', project({ status: 'завершён', canEdit: true }))).toBe(false);
});
it('импорт работ из Excel — только с правом edit0, архив закрыт', () => {
expect(isSectionAvailable('worksImport', project({ status: 'текущий', canEdit: true }))).toBe(true);
expect(isSectionAvailable('worksImport', project({ status: 'завершён', canEdit: true }))).toBe(true);
expect(isSectionAvailable('worksImport', project({ status: 'текущий', canEdit: false }))).toBe(false);
expect(isSectionAvailable('worksImport', project({ status: 'архивный', canEdit: true }))).toBe(false);
});
}); });
@@ -73,18 +73,37 @@ export function getStatusKind(status: string | undefined): ProjectStatusKind {
* - архивный проект — закрыто всё (кроме архива, он не раздел приложения); * - архивный проект — закрыто всё (кроме архива, он не раздел приложения);
* - завершён — нельзя «Отправить отчёт»; * - завершён — нельзя «Отправить отчёт»;
* - нет права edit0 — нельзя «Отправить отчёт». * - нет права edit0 — нельзя «Отправить отчёт».
* Импорт отчёта из Excel создаёт отчёты — те же правила, что у «Отправить отчёт».
* Привязка к справочнику — только с правом редактирования (решение при объединении). * Привязка к справочнику — только с правом редактирования (решение при объединении).
* Импорт работ из Excel меняет работы проекта — тоже только с правом редактирования.
*/ */
export function isSectionAvailable(page: PageId, project: IAccessibleProject): boolean { export function isSectionAvailable(page: PageId, project: IAccessibleProject): boolean {
if (page === 'hub') return true; if (page === 'hub') return true;
const kind = getStatusKind(project.status); const kind = getStatusKind(project.status);
if (kind === 'archive') return false; if (kind === 'archive') return false;
if (page === 'report') return kind !== 'done' && project.canEdit; if (page === 'report' || page === 'reportImport') return kind !== 'done' && project.canEdit;
if (page === 'binder') return project.canEdit; if (page === 'binder' || page === 'worksImport') return project.canEdit;
return true; return true;
} }
/**
* Текущий проект: id из адреса, а без него — сохранённый в сессии выбор.
* Данные (статус, права) берутся из свежего списка с сервера; сохранённая копия — только пока список
* не загрузился или если проекта в нём нет. Иначе после смены статуса / прав F5 показывал бы старые.
*/
export function resolveCurrentProject(
projects: IAccessibleProject[],
routeProjectId: number | undefined,
storedProject: IAccessibleProject | undefined
): IAccessibleProject | undefined {
const targetId = routeProjectId ?? storedProject?.id;
if (targetId === undefined) return undefined;
const fresh = projects.filter((project) => project.id === targetId)[0];
if (fresh) return fresh;
return storedProject?.id === targetId ? storedProject : undefined;
}
/** Выгрузка за период недоступна только для архивного проекта. */ /** Выгрузка за период недоступна только для архивного проекта. */
export function isExportAvailable(project: IAccessibleProject): boolean { export function isExportAvailable(project: IAccessibleProject): boolean {
return getStatusKind(project.status) !== 'archive'; return getStatusKind(project.status) !== 'archive';
@@ -1,10 +1,15 @@
import * as React from 'react'; import * as React from 'react';
import { buildSearch, IRoute, parseRoute } from './routes'; import { buildSearch, IRoute, parseRoute } from './routes';
interface INavigateOptions {
/** Заменить текущую запись истории (replaceState), а не добавить новую. */
replace?: boolean;
}
interface IRouterValue { interface IRouterValue {
route: IRoute; route: IRoute;
/** Перейти в раздел: меняет адрес (history.pushState) и перерисовывает приложение. */ /** Перейти в раздел: меняет адрес (history.pushState) и перерисовывает приложение. */
navigate: (route: IRoute) => void; navigate: (route: IRoute, options?: INavigateOptions) => void;
/** Полный адрес раздела — для настоящих ссылок (Ctrl+клик, «открыть в новой вкладке»). */ /** Полный адрес раздела — для настоящих ссылок (Ctrl+клик, «открыть в новой вкладке»). */
buildHref: (route: IRoute) => string; buildHref: (route: IRoute) => string;
} }
@@ -29,8 +34,12 @@ export const RouterProvider: React.FC<{ children: React.ReactNode }> = ({ childr
return () => window.removeEventListener('popstate', onPopState); return () => window.removeEventListener('popstate', onPopState);
}, []); }, []);
const navigate = React.useCallback((next: IRoute): void => { const navigate = React.useCallback((next: IRoute, options: INavigateOptions = {}): void => {
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'
);
});
}); });
+28 -3
View File
@@ -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 };
}
+26 -4
View File
@@ -2,6 +2,9 @@ import * as React from 'react';
import { Alert, Badge, BadgeVariant, Button, Icon, Spinner, TextInput } from '@tnsu/ui-kit'; import { Alert, Badge, BadgeVariant, Button, Icon, Spinner, TextInput } from '@tnsu/ui-kit';
import { IAccessibleProject } from '../../app/project/projectTypes'; import { IAccessibleProject } from '../../app/project/projectTypes';
import { isExportAvailable } from '../../app/project/projectRules'; import { isExportAvailable } from '../../app/project/projectRules';
import { useRouter } from '../../app/routing/RouterContext';
import { InlineIcon } from '../../shared/components/InlineIcon';
import { WEATHER_TEXTS } from '../weather/texts';
import { getReportType, isInvalidRange, ReportKind } from './exportReport'; import { getReportType, isInvalidRange, ReportKind } from './exportReport';
import { useReportExport } from './useReportExport'; import { useReportExport } from './useReportExport';
import { ExportInfoButton } from './ExportInfoButton'; import { ExportInfoButton } from './ExportInfoButton';
@@ -20,17 +23,22 @@ interface Props {
project: IAccessibleProject | undefined; project: IAccessibleProject | undefined;
} }
/** Карточка «Выгрузить за период». Поля дат видны только при выбранном проекте. */ /**
* Карточка «Выгрузить за период» и кнопка «Сверка погодных условий». Поля дат видны только при выбранном проекте.
* Даты берутся из адреса, если вернулись со сверки погоды, — чтобы не выбирать период заново (как в main).
*/
export const ExportCard: React.FC<Props> = ({ project }) => { export const ExportCard: React.FC<Props> = ({ project }) => {
const [dateFrom, setDateFrom] = React.useState<string>(''); const { route, navigate } = useRouter();
const [dateTo, setDateTo] = React.useState<string>(''); const [dateFrom, setDateFrom] = React.useState<string>(route.start ?? '');
const [dateTo, setDateTo] = React.useState<string>(route.end ?? '');
const { isExporting, errorMessage, exportReport } = useReportExport(); const { isExporting, errorMessage, exportReport } = useReportExport();
const isEnabled = !!project && isExportAvailable(project); const isEnabled = !!project && isExportAvailable(project);
const hasBothDates = !!dateFrom && !!dateTo; const hasBothDates = !!dateFrom && !!dateTo;
const isRangeInvalid = isInvalidRange(dateFrom, dateTo); const isRangeInvalid = isInvalidRange(dateFrom, dateTo);
const reportType = getReportType(dateFrom, dateTo); const reportType = getReportType(dateFrom, dateTo);
const canExport = isEnabled && hasBothDates && !isRangeInvalid && !isExporting; const isPeriodReady = isEnabled && hasBothDates && !isRangeInvalid;
const canExport = isPeriodReady && !isExporting;
const onExport = (): void => { const onExport = (): void => {
if (project && canExport) { if (project && canExport) {
@@ -38,6 +46,14 @@ export const ExportCard: React.FC<Props> = ({ project }) => {
} }
}; };
const onOpenWeather = (): void => {
if (!project || !isPeriodReady) return;
const projectId = String(project.id);
// Сначала запоминаем даты в адресе главной: «назад» в браузере вернёт их в поля.
navigate({ page: 'hub', projectId, start: dateFrom, end: dateTo }, { replace: true });
navigate({ page: 'weather', projectId, start: dateFrom, end: dateTo });
};
return ( return (
<div className={isEnabled ? styles.exportCard : `${styles.exportCard} ${styles.cardDisabled}`}> <div className={isEnabled ? styles.exportCard : `${styles.exportCard} ${styles.cardDisabled}`}>
<span className={styles.cardIcon} aria-hidden="true"> <span className={styles.cardIcon} aria-hidden="true">
@@ -64,6 +80,12 @@ export const ExportCard: React.FC<Props> = ({ project }) => {
<Button variant="accent" onClick={onExport} disabled={!canExport}> <Button variant="accent" onClick={onExport} disabled={!canExport}>
{isExporting ? <Spinner size="xSmall" label={TEXTS.buttonLoading} /> : TEXTS.button} {isExporting ? <Spinner size="xSmall" label={TEXTS.buttonLoading} /> : TEXTS.button}
</Button> </Button>
<Button variant="secondary" onClick={onOpenWeather} disabled={!isPeriodReady}>
<span className={styles.buttonContent}>
<InlineIcon name="cloud" />
{WEATHER_TEXTS.openButton}
</span>
</Button>
</div> </div>
)} )}
@@ -136,6 +136,12 @@ $card-min-width: 260px;
color: $gray-700; color: $gray-700;
} }
.buttonContent {
display: inline-flex;
align-items: center;
gap: $sp-2;
}
.dateError { .dateError {
font-size: $fs-sm; font-size: $fs-sm;
color: $red-500; color: $red-500;
@@ -50,6 +50,25 @@ $dropdown-max-height: 340px;
color: $gray-900; color: $gray-900;
} }
.clearButton {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: $sp-1;
margin-right: $sp-2;
border: 0;
border-radius: $r-full;
background: transparent;
color: $gray-400;
cursor: pointer;
&:hover {
background: $gray-100;
color: $gray-700;
}
}
.selectedBadge { .selectedBadge {
flex-shrink: 0; flex-shrink: 0;
} }
@@ -40,6 +40,14 @@ export const ProjectPicker: React.FC = () => {
setIsOpen(true); setIsOpen(true);
}; };
// Очистка не сбрасывает выбранный проект — только текст поиска, чтобы сразу искать другой.
const onClear = (event: React.MouseEvent): void => {
event.preventDefault();
setQuery('');
setIsOpen(true);
inputRef.current?.focus();
};
// mousedown, а не click: иначе поле теряет фокус раньше, чем срабатывает выбор. // mousedown, а не click: иначе поле теряет фокус раньше, чем срабатывает выбор.
const onPick = (event: React.MouseEvent, project: IAccessibleProject): void => { const onPick = (event: React.MouseEvent, project: IAccessibleProject): void => {
event.preventDefault(); event.preventDefault();
@@ -67,6 +75,17 @@ export const ProjectPicker: React.FC = () => {
onFocus={onFocus} onFocus={onFocus}
onChange={onChange} onChange={onChange}
/> />
{query && (
<button
type="button"
className={styles.clearButton}
title={TEXTS.clear}
aria-label={TEXTS.clear}
onMouseDown={onClear}
>
<Icon name="close" size="sm" />
</button>
)}
{currentProject?.status && ( {currentProject?.status && (
<span className={styles.selectedBadge}> <span className={styles.selectedBadge}>
<ProjectStatusBadge status={currentProject.status} /> <ProjectStatusBadge status={currentProject.status} />
+1
View File
@@ -4,6 +4,7 @@ export const HUB_TEXTS = {
picker: { picker: {
label: 'Проект:', label: 'Проект:',
placeholder: 'Выберите проект', placeholder: 'Выберите проект',
clear: 'Очистить',
loading: 'Загрузка проектов…', loading: 'Загрузка проектов…',
noProjects: 'Нет доступных проектов', noProjects: 'Нет доступных проектов',
notFound: 'Проекты не найдены', notFound: 'Проекты не найдены',
@@ -1,32 +0,0 @@
import * as React from 'react';
import { DropPosition } from './objectsTypes';
import { IObjectDrag } from './useObjectDrag';
import styles from './ObjectsPage.module.scss';
interface Props {
targetId: number;
position: DropPosition;
drag: IObjectDrag;
}
const POSITION_CLASS: Record<DropPosition, string> = {
before: styles.dropZoneEdge,
inside: styles.dropZoneInside,
after: styles.dropZoneEdge
};
/** Тонкая полоса до / внутри / после узла, куда можно бросить перетаскиваемый объект. */
export const DropZone: React.FC<Props> = ({ targetId, position, drag }) => {
const isActive = drag.activeZone?.targetId === targetId && drag.activeZone.position === position;
const className = [styles.dropZone, POSITION_CLASS[position], isActive ? styles.dropZoneActive : '']
.filter(Boolean)
.join(' ');
return (
<div
className={className}
onDragOver={(event) => drag.onZoneDragOver({ targetId, position }, event)}
onDragLeave={drag.onZoneDragLeave}
/>
);
};
@@ -3,8 +3,6 @@ import { Icon } from '@tnsu/ui-kit';
import { IMenuAnchor } from '../_tree/ContextMenu'; import { IMenuAnchor } from '../_tree/ContextMenu';
import { ObjectNode } from './objectsTypes'; import { ObjectNode } from './objectsTypes';
import { IObjectTreeView } from './useObjectTreeView'; import { IObjectTreeView } from './useObjectTreeView';
import { IObjectDrag } from './useObjectDrag';
import { DropZone } from './DropZone';
import { OBJECTS_TEXTS } from './texts'; import { OBJECTS_TEXTS } from './texts';
import styles from './ObjectsPage.module.scss'; import styles from './ObjectsPage.module.scss';
@@ -12,7 +10,6 @@ interface Props {
node: ObjectNode; node: ObjectNode;
canEdit: boolean; canEdit: boolean;
view: IObjectTreeView; view: IObjectTreeView;
drag: IObjectDrag;
onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void; onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
} }
@@ -20,19 +17,14 @@ interface Props {
const ROW_BASE_INDENT_PX = 12; const ROW_BASE_INDENT_PX = 12;
const ROW_LEVEL_INDENT_PX = 22; const ROW_LEVEL_INDENT_PX = 22;
function rowClassName(hasChildren: boolean, isSelected: boolean, isDragging: boolean): string { function rowClassName(hasChildren: boolean, isSelected: boolean): string {
return [ return [styles.nodeRow, hasChildren ? styles.nodeRowParent : '', isSelected ? styles.nodeRowSelected : '']
styles.nodeRow,
hasChildren ? styles.nodeRowParent : '',
isSelected ? styles.nodeRowSelected : '',
isDragging ? styles.nodeRowDragging : ''
]
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
} }
/** Узел дерева объектов с зонами сброса и (если раскрыт) дочерними узлами. */ /** Узел дерева объектов и (если раскрыт) его дочерние узлы. */
export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onOpenMenu }) => { export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, onOpenMenu }) => {
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
const isExpanded = view.expandedIds.has(node.id); const isExpanded = view.expandedIds.has(node.id);
@@ -58,13 +50,9 @@ export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onO
return ( return (
<div> <div>
<DropZone targetId={node.id} position="before" drag={drag} />
<div <div
className={rowClassName(hasChildren, view.selectedId === node.id, drag.draggingId === node.id)} className={rowClassName(hasChildren, view.selectedId === node.id)}
style={{ paddingLeft: ROW_BASE_INDENT_PX + node.level * ROW_LEVEL_INDENT_PX }} style={{ paddingLeft: ROW_BASE_INDENT_PX + node.level * ROW_LEVEL_INDENT_PX }}
draggable={canEdit}
onDragStart={(event) => drag.onDragStart(node.id, event)}
onDragEnd={drag.onDragEnd}
onClick={() => view.clickRow(node)} onClick={() => view.clickRow(node)}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
> >
@@ -93,22 +81,13 @@ export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onO
</button> </button>
)} )}
</div> </div>
<DropZone targetId={node.id} position="inside" drag={drag} />
{hasChildren && isExpanded && ( {hasChildren && isExpanded && (
<div> <div>
{node.children.map((child) => ( {node.children.map((child) => (
<ObjectTreeNode <ObjectTreeNode key={child.id} node={child} canEdit={canEdit} view={view} onOpenMenu={onOpenMenu} />
key={child.id}
node={child}
canEdit={canEdit}
view={view}
drag={drag}
onOpenMenu={onOpenMenu}
/>
))} ))}
</div> </div>
)} )}
<DropZone targetId={node.id} position="after" drag={drag} />
</div> </div>
); );
}; };
@@ -91,10 +91,6 @@ $menu-button-size: 28px;
} }
} }
.nodeRowDragging {
opacity: 0.45;
}
.nodeToggle { .nodeToggle {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -182,32 +178,6 @@ $menu-button-size: 28px;
} }
} }
// ── Зоны сброса при перетаскивании ──────────────────────────────────────────
// Высоты 1:1 со старым web part: зона «внутрь» нулевой высоты (см. открытые вопросы в чек-листе).
.dropZone {
margin: 0 $sp-2;
border-radius: 1px;
transition: background 0.12s, height 0.12s;
}
.dropZoneEdge {
height: 2px;
&.dropZoneActive {
height: 3px;
background: $gold-400;
}
}
.dropZoneInside {
height: 0;
&.dropZoneActive {
height: 2px;
background: rgba($gold-400, 0.3);
}
}
// ── Модалка ───────────────────────────────────────────────────────────────── // ── Модалка ─────────────────────────────────────────────────────────────────
.modalBody { .modalBody {
display: flex; display: flex;
@@ -10,7 +10,6 @@ import { useObjectsData } from './useObjectsData';
import { useObjectActions } from './useObjectActions'; import { useObjectActions } from './useObjectActions';
import { useObjectsExport } from './useObjectsExport'; import { useObjectsExport } from './useObjectsExport';
import { useObjectTreeView } from './useObjectTreeView'; import { useObjectTreeView } from './useObjectTreeView';
import { useObjectDrag } from './useObjectDrag';
import { ObjectsHeader } from './ObjectsHeader'; import { ObjectsHeader } from './ObjectsHeader';
import { ObjectsTree } from './ObjectsTree'; import { ObjectsTree } from './ObjectsTree';
import { ObjectModal } from './ObjectModal'; import { ObjectModal } from './ObjectModal';
@@ -42,17 +41,9 @@ export const ObjectsPageContent: React.FC<Props> = ({ projectId }) => {
); );
const data = useObjectsData(repository, projectId); const data = useObjectsData(repository, projectId);
const actions = useObjectActions({ const actions = useObjectActions({ repository, projectTitle: data.projectTitle, reload: data.reload });
repository,
projectTitle: data.projectTitle,
tree: data.tree,
reload: data.reload
});
const exporter = useObjectsExport(repository, projectId); const exporter = useObjectsExport(repository, projectId);
const view = useObjectTreeView(data.tree); const view = useObjectTreeView(data.tree);
const drag = useObjectDrag(data.tree, (dragId, targetId, position) => {
actions.moveObject(dragId, targetId, position).catch(() => undefined);
});
const [menu, setMenu] = React.useState<IOpenMenu | undefined>(undefined); const [menu, setMenu] = React.useState<IOpenMenu | undefined>(undefined);
const [modalTarget, setModalTarget] = React.useState<IObjectModalTarget | undefined>(undefined); const [modalTarget, setModalTarget] = React.useState<IObjectModalTarget | undefined>(undefined);
@@ -105,7 +96,6 @@ export const ObjectsPageContent: React.FC<Props> = ({ projectId }) => {
tree={data.tree} tree={data.tree}
canEdit={data.canEdit} canEdit={data.canEdit}
view={view} view={view}
drag={drag}
onOpenMenu={(node, anchor) => setMenu({ node, anchor })} onOpenMenu={(node, anchor) => setMenu({ node, anchor })}
onAddRoot={openAddRoot} onAddRoot={openAddRoot}
/> />
@@ -5,7 +5,6 @@ import { IMenuAnchor } from '../_tree/ContextMenu';
import { countNodes } from '../_tree/treeRules'; import { countNodes } from '../_tree/treeRules';
import { ObjectNode, ObjectTree } from './objectsTypes'; import { ObjectNode, ObjectTree } from './objectsTypes';
import { IObjectTreeView } from './useObjectTreeView'; import { IObjectTreeView } from './useObjectTreeView';
import { IObjectDrag } from './useObjectDrag';
import { ObjectTreeNode } from './ObjectTreeNode'; import { ObjectTreeNode } from './ObjectTreeNode';
import { OBJECTS_TEXTS } from './texts'; import { OBJECTS_TEXTS } from './texts';
import styles from './ObjectsPage.module.scss'; import styles from './ObjectsPage.module.scss';
@@ -14,13 +13,12 @@ interface Props {
tree: ObjectTree; tree: ObjectTree;
canEdit: boolean; canEdit: boolean;
view: IObjectTreeView; view: IObjectTreeView;
drag: IObjectDrag;
onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void; onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
onAddRoot: () => void; onAddRoot: () => void;
} }
/** Панель «N объектов / Развернуть / Свернуть» и дерево объектов (или «Объекты не найдены»). */ /** Панель «N объектов / Развернуть / Свернуть» и дерево объектов (или «Объекты не найдены»). */
export const ObjectsTree: React.FC<Props> = ({ tree, canEdit, view, drag, onOpenMenu, onAddRoot }) => ( export const ObjectsTree: React.FC<Props> = ({ tree, canEdit, view, onOpenMenu, onAddRoot }) => (
<> <>
<div className={styles.toolbar}> <div className={styles.toolbar}>
<span className={styles.totalCount}>{OBJECTS_TEXTS.toolbar.total(countNodes(tree.roots))}</span> <span className={styles.totalCount}>{OBJECTS_TEXTS.toolbar.total(countNodes(tree.roots))}</span>
@@ -48,16 +46,9 @@ export const ObjectsTree: React.FC<Props> = ({ tree, canEdit, view, drag, onOpen
/> />
)} )}
<div className={styles.tree} onDrop={drag.onDrop}> <div className={styles.tree}>
{tree.roots.map((node) => ( {tree.roots.map((node) => (
<ObjectTreeNode <ObjectTreeNode key={node.id} node={node} canEdit={canEdit} view={view} onOpenMenu={onOpenMenu} />
key={node.id}
node={node}
canEdit={canEdit}
view={view}
drag={drag}
onOpenMenu={onOpenMenu}
/>
))} ))}
</div> </div>
</> </>
@@ -174,17 +174,6 @@ describe('запись', () => {
expect(bodyOf(sharePoint, 2)).toEqual({ __metadata: { type: ENTITY_TYPE }, Title: 'Новое', parentId: 3 }); expect(bodyOf(sharePoint, 2)).toEqual({ __metadata: { type: ENTITY_TYPE }, Title: 'Новое', parentId: 3 });
}); });
it('перенос: меняется только parentId, в корень — null', async () => {
const { repository, sharePoint } = setup([ENTITY_TYPE_ROUTE, { match: '/items(', response: { status: 204 } }]);
await repository.moveObject(7, 2);
await repository.moveObject(7, undefined);
expect(sharePoint.requests[1].request.headers).toEqual(MERGE_HEADERS);
expect(bodyOf(sharePoint, 1)).toEqual({ __metadata: { type: ENTITY_TYPE }, parentId: 2 });
expect(bodyOf(sharePoint, 2)).toEqual({ __metadata: { type: ENTITY_TYPE }, parentId: null });
});
it('ошибка записи: error.message.value → error.message → statusText', async () => { it('ошибка записи: error.message.value → error.message → statusText', async () => {
const verbose = setup([ const verbose = setup([
ENTITY_TYPE_ROUTE, ENTITY_TYPE_ROUTE,
@@ -165,8 +165,6 @@ export interface IObjectsRepository {
createObject(projectTitle: string, title: string, parentId: number | undefined): Promise<void>; createObject(projectTitle: string, title: string, parentId: number | undefined): Promise<void>;
/** newParentId — только при смене родителя Раздела; undefined — поле не трогаем. */ /** newParentId — только при смене родителя Раздела; undefined — поле не трогаем. */
updateObject(itemId: number, title: string, newParentId: number | undefined): Promise<void>; updateObject(itemId: number, title: string, newParentId: number | undefined): Promise<void>;
/** Перенос (drag & drop): меняется только родитель; undefined — в корень. */
moveObject(itemId: number, newParentId: number | undefined): Promise<void>;
deleteObject(itemId: number): Promise<void>; deleteObject(itemId: number): Promise<void>;
downloadObjectsExcel(projectId: string): Promise<Blob>; downloadObjectsExcel(projectId: string): Promise<Blob>;
} }
@@ -211,11 +209,6 @@ export function createObjectsRepository(deps: IObjectsRepositoryDeps): IObjectsR
if (newParentId !== undefined) body[OBJECT_FIELDS.parentWrite] = newParentId; if (newParentId !== undefined) body[OBJECT_FIELDS.parentWrite] = newParentId;
await postVerbose(deps, objectItemUrl(deps.siteUrl, itemId), MERGE_HEADERS, body); await postVerbose(deps, objectItemUrl(deps.siteUrl, itemId), MERGE_HEADERS, body);
}, },
moveObject: async (itemId, newParentId) => {
// Перенос в корень сбрасывает lookup явным null.
const body = { ...(await metadata()), [OBJECT_FIELDS.parentWrite]: newParentId ?? null };
await postVerbose(deps, objectItemUrl(deps.siteUrl, itemId), MERGE_HEADERS, body);
},
deleteObject: (itemId) => deleteObject(deps, itemId), deleteObject: (itemId) => deleteObject(deps, itemId),
downloadObjectsExcel: (projectId) => downloadObjectsExcel(deps, projectId) downloadObjectsExcel: (projectId) => downloadObjectsExcel(deps, projectId)
}; };
@@ -1,7 +1,6 @@
import { import {
buildObjectTree, buildObjectTree,
canAddChild, canAddChild,
canDrop,
getChangedParentId, getChangedParentId,
getDeleteConfirmMessage, getDeleteConfirmMessage,
getErrorText, getErrorText,
@@ -9,8 +8,7 @@ import {
getParentDisplay, getParentDisplay,
getParentOptions, getParentOptions,
getSubmitLabel, getSubmitLabel,
isParentSelectable, isParentSelectable
resolveMove
} from './objectsRules'; } from './objectsRules';
import { buildObjectMenuItems } from './objectMenu'; import { buildObjectMenuItems } from './objectMenu';
import { IProjectObject, ObjectModalMode, ObjectNode, ObjectTree } from './objectsTypes'; import { IProjectObject, ObjectModalMode, ObjectNode, ObjectTree } from './objectsTypes';
@@ -100,26 +98,6 @@ describe('модалка', () => {
}); });
}); });
describe('перетаскивание', () => {
it('нельзя бросить в себя и в своих потомков', () => {
expect(canDrop(tree, 1, 1)).toBe(false);
expect(canDrop(tree, 1, 4)).toBe(false);
expect(canDrop(tree, 4, 1)).toBe(true);
});
it('«внутрь» — родитель = цель, «до/после» — родитель цели', () => {
expect(resolveMove(tree, 4, 3, 'inside')).toEqual({ parentId: 3 });
expect(resolveMove(tree, 4, 3, 'before')).toEqual({ parentId: 1 });
expect(resolveMove(tree, 2, 5, 'after')).toEqual({ parentId: undefined });
});
it('родитель не меняется или перенос запрещён — ничего не делаем', () => {
expect(resolveMove(tree, 2, 3, 'after')).toBeUndefined();
expect(resolveMove(tree, 1, 5, 'before')).toBeUndefined();
expect(resolveMove(tree, 1, 4, 'inside')).toBeUndefined();
});
});
describe('удаление и ошибки', () => { describe('удаление и ошибки', () => {
it('подтверждение с числом дочерних', () => { it('подтверждение с числом дочерних', () => {
expect(getDeleteConfirmMessage(node(1))).toEqual( expect(getDeleteConfirmMessage(node(1))).toEqual(
@@ -1,6 +1,6 @@
import { ISelectOption } from '@tnsu/ui-kit'; import { ISelectOption } from '@tnsu/ui-kit';
import { buildTree, isDescendant } from '../_tree/treeRules'; import { buildTree, isDescendant } from '../_tree/treeRules';
import { DropPosition, IObjectModalTarget, IProjectObject, ObjectNode, ObjectTree } from './objectsTypes'; import { IObjectModalTarget, IProjectObject, ObjectNode, ObjectTree } from './objectsTypes';
import { OBJECTS_TEXTS } from './texts'; import { OBJECTS_TEXTS } from './texts';
// Правила страницы «Объекты» (stroy-bot-objects), вынесенные из DOM-кода в чистые функции. // Правила страницы «Объекты» (stroy-bot-objects), вынесенные из DOM-кода в чистые функции.
@@ -72,33 +72,6 @@ export function getChangedParentId(node: ObjectNode | undefined, selectValue: st
return !isNaN(parentId) && parentId !== node.item.parentId ? parentId : undefined; return !isNaN(parentId) && parentId !== node.item.parentId ? parentId : undefined;
} }
/** Бросать узел можно куда угодно, кроме себя и своих потомков. */
export function canDrop(tree: ObjectTree, dragId: number, targetId: number): boolean {
return dragId !== targetId && !isDescendant(tree.byId, dragId, targetId);
}
export interface IMoveResult {
/** undefined — перенос в корень. */
parentId: number | undefined;
}
/**
* Новый родитель после перетаскивания: «внутрь» — целевой узел, «до/после» — родитель целевого.
* undefined — переносить нечего (родитель не меняется или перенос запрещён).
*/
export function resolveMove(
tree: ObjectTree,
dragId: number,
targetId: number,
position: DropPosition
): IMoveResult | undefined {
const dragNode = tree.byId.get(dragId);
if (!dragNode || !canDrop(tree, dragId, targetId)) return undefined;
const parentId = position === 'inside' ? targetId : tree.byId.get(targetId)?.item.parentId;
return dragNode.item.parentId === parentId ? undefined : { parentId };
}
export function getDeleteConfirmMessage(node: ObjectNode): string { export function getDeleteConfirmMessage(node: ObjectNode): string {
return OBJECTS_TEXTS.deleteConfirm( return OBJECTS_TEXTS.deleteConfirm(
node.item.title || OBJECTS_TEXTS.deleteFallbackTitle(node.id), node.item.title || OBJECTS_TEXTS.deleteFallbackTitle(node.id),
@@ -22,9 +22,6 @@ export interface IObjectModalTarget {
node: ObjectNode | undefined; node: ObjectNode | undefined;
} }
/** Куда бросили узел относительно целевого: до, внутрь, после. */
export type DropPosition = 'before' | 'inside' | 'after';
/** /**
* Строка «Объекты» в ответе SharePoint ($select=Id,Title,parent/Id&$expand=parent). * Строка «Объекты» в ответе SharePoint ($select=Id,Title,parent/Id&$expand=parent).
* Пустой lookup приходит как null — читается через `?.`. * Пустой lookup приходит как null — читается через `?.`.
@@ -58,7 +58,6 @@ export const OBJECTS_TEXTS = {
deleteFallbackTitle: (id: number): string => `ID ${id}`, deleteFallbackTitle: (id: number): string => `ID ${id}`,
serverError: (status: number): string => `Ошибка сервера: ${status}`, serverError: (status: number): string => `Ошибка сервера: ${status}`,
saveError: (message: string): string => `Ошибка сохранения: ${message}`, saveError: (message: string): string => `Ошибка сохранения: ${message}`,
moveError: (message: string): string => `Не удалось переместить: ${message}`,
deleteError: (message: string): string => `Не удалось удалить: ${message}`, deleteError: (message: string): string => `Не удалось удалить: ${message}`,
exportError: (message: string): string => `Не удалось скачать файл: ${message}` exportError: (message: string): string => `Не удалось скачать файл: ${message}`
} as const; } as const;
@@ -1,28 +1,26 @@
import * as React from 'react'; import * as React from 'react';
import { confirmModal } from '@tnsu/ui-kit'; import { confirmModal } from '@tnsu/ui-kit';
import { IObjectsRepository } from './objectsRepository'; import { IObjectsRepository } from './objectsRepository';
import { getChangedParentId, getDeleteConfirmMessage, getErrorText, resolveMove } from './objectsRules'; import { getChangedParentId, getDeleteConfirmMessage, getErrorText } from './objectsRules';
import { DropPosition, IObjectModalTarget, ObjectNode, ObjectTree } from './objectsTypes'; import { IObjectModalTarget, ObjectNode } from './objectsTypes';
import { OBJECTS_TEXTS } from './texts'; import { OBJECTS_TEXTS } from './texts';
interface IActionsDeps { interface IActionsDeps {
repository: IObjectsRepository; repository: IObjectsRepository;
projectTitle: string | undefined; projectTitle: string | undefined;
tree: ObjectTree;
reload: () => void; reload: () => void;
} }
export interface IObjectActions { export interface IObjectActions {
/** Ошибка переноса или удаления (раньше — alert). */ /** Ошибка удаления (раньше — alert). */
actionError: string | undefined; actionError: string | undefined;
/** Создание / правка из модалки. Ошибку бросает дальше — модалка покажет её у себя. */ /** Создание / правка из модалки. Ошибку бросает дальше — модалка покажет её у себя. */
saveObject: (target: IObjectModalTarget, title: string, parentValue: string) => Promise<void>; saveObject: (target: IObjectModalTarget, title: string, parentValue: string) => Promise<void>;
moveObject: (dragId: number, targetId: number, position: DropPosition) => Promise<void>;
deleteObject: (node: ObjectNode) => Promise<void>; deleteObject: (node: ObjectNode) => Promise<void>;
} }
/** Пишущие действия над деревом объектов. После каждого успешного действия дерево перечитывается. */ /** Пишущие действия над деревом объектов. После каждого успешного действия дерево перечитывается. */
export function useObjectActions({ repository, projectTitle, tree, reload }: IActionsDeps): IObjectActions { export function useObjectActions({ repository, projectTitle, reload }: IActionsDeps): IObjectActions {
const [actionError, setActionError] = React.useState<string | undefined>(undefined); const [actionError, setActionError] = React.useState<string | undefined>(undefined);
const saveObject = async (target: IObjectModalTarget, title: string, parentValue: string): Promise<void> => { const saveObject = async (target: IObjectModalTarget, title: string, parentValue: string): Promise<void> => {
@@ -39,18 +37,6 @@ export function useObjectActions({ repository, projectTitle, tree, reload }: IAc
reload(); reload();
}; };
const moveObject = async (dragId: number, targetId: number, position: DropPosition): Promise<void> => {
const move = resolveMove(tree, dragId, targetId, position);
if (!move) return;
setActionError(undefined);
try {
await repository.moveObject(dragId, move.parentId);
reload();
} catch (error) {
setActionError(OBJECTS_TEXTS.moveError(getErrorText(error)));
}
};
const deleteObject = async (node: ObjectNode): Promise<void> => { const deleteObject = async (node: ObjectNode): Promise<void> => {
const isConfirmed = await confirmModal({ const isConfirmed = await confirmModal({
message: getDeleteConfirmMessage(node), message: getDeleteConfirmMessage(node),
@@ -67,5 +53,5 @@ export function useObjectActions({ repository, projectTitle, tree, reload }: IAc
} }
}; };
return { actionError, saveObject, moveObject, deleteObject }; return { actionError, saveObject, deleteObject };
} }
@@ -1,72 +0,0 @@
import * as React from 'react';
import { canDrop } from './objectsRules';
import { DropPosition, ObjectTree } from './objectsTypes';
export interface IDropZone {
targetId: number;
position: DropPosition;
}
export interface IObjectDrag {
draggingId: number | undefined;
/** Подсвеченная зона сброса. */
activeZone: IDropZone | undefined;
onDragStart: (nodeId: number, event: React.DragEvent) => void;
onDragEnd: () => void;
onZoneDragOver: (zone: IDropZone, event: React.DragEvent) => void;
onZoneDragLeave: () => void;
onDrop: (event: React.DragEvent) => void;
}
/**
* Перетаскивание узлов — как в stroy-bot-objects: узел бросается на зону до / внутрь / после
* другого узла. Бросок засчитывается в последнюю «разрешённую» зону, над которой был курсор.
*/
export function useObjectDrag(
tree: ObjectTree,
onMove: (dragId: number, targetId: number, position: DropPosition) => void
): IObjectDrag {
const [draggingId, setDraggingId] = React.useState<number | undefined>(undefined);
const [activeZone, setActiveZone] = React.useState<IDropZone | undefined>(undefined);
// Последняя разрешённая зона: в старом коде dragleave снимал только подсветку, а цель запоминалась.
const dropTargetRef = React.useRef<IDropZone | undefined>(undefined);
const reset = (): void => {
setDraggingId(undefined);
setActiveZone(undefined);
dropTargetRef.current = undefined;
};
const onDragStart = (nodeId: number, event: React.DragEvent): void => {
setDraggingId(nodeId);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(nodeId));
};
const onZoneDragOver = (zone: IDropZone, event: React.DragEvent): void => {
if (draggingId === undefined || !canDrop(tree, draggingId, zone.targetId)) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
setActiveZone(zone);
dropTargetRef.current = zone;
};
const onDrop = (event: React.DragEvent): void => {
event.preventDefault();
const dragId = draggingId;
const target = dropTargetRef.current;
reset();
if (dragId === undefined || !target) return;
onMove(dragId, target.targetId, target.position);
};
return {
draggingId,
activeZone,
onDragStart,
onDragEnd: reset,
onZoneDragOver,
onZoneDragLeave: () => setActiveZone(undefined),
onDrop
};
}
@@ -1,18 +1,23 @@
import * as React from 'react'; import * as React from 'react';
import { useRouter } from '../../app/routing/RouterContext';
import { goBack } from './goBack'; import { goBack } from './goBack';
import { REPORT_TEXTS } from './texts'; import { REPORT_TEXTS } from './texts';
import styles from './ReportPage.module.scss'; import styles from './ReportPage.module.scss';
/** «← Вернуться назад» — назад по истории браузера, как в старой форме. */ /** «← Вернуться назад» — назад по истории браузера, как в старой форме; без истории — к списку отчётов проекта. */
export const BackLink: React.FC = () => ( export const BackLink: React.FC = () => {
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 }),
+10 -1
View File
@@ -1,8 +1,17 @@
/** В новой вкладке (так отчёты открываются из списка) история состоит из одной текущей страницы. */
const NO_HISTORY_LENGTH = 1;
/** /**
* «← Вернуться назад» — как в старом коде: если страница открыта во фрейме, назад идёт верхнее окно, * «← Вернуться назад» — как в старом коде: если страница открыта во фрейме, назад идёт верхнее окно,
* при ошибке доступа к нему (другой домен) — текущее. * при ошибке доступа к нему (другой домен) — текущее.
* Если возвращаться некуда (вкладка открыта из списка отчётов), вызывается onNoHistory: в старом коде
* кнопка в этом случае ничего не делала — исправлено при проверке T-REP-21.
*/ */
export function goBack(): void { export function goBack(onNoHistory: () => void): void {
if (window.history.length <= NO_HISTORY_LENGTH) {
onNoHistory();
return;
}
try { try {
if (window.top && window.top !== window) { if (window.top && window.top !== window) {
window.top.history.back(); window.top.history.back();
@@ -42,6 +42,18 @@ $slide-duration: 0.2s;
animation: slideUp $slide-duration ease-out; animation: slideUp $slide-duration ease-out;
} }
// Как в старом PickerModal: шторка снизу — только на телефоне, на экране шире 640px — окно по центру.
@include above($bp-sm) {
.overlay {
align-items: center;
}
.sheet {
border-radius: $r-lg;
animation: none;
}
}
.header { .header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1,4 +1,5 @@
import { IAppServices } from '../../services/AppServices'; import { IAppServices } from '../../services/AppServices';
import { siteServerRelativeUrl } from '../../services/siteUser';
import { createSharePointReportRepository, ISharePointReportRepository } from './sharePointReportRepository'; import { createSharePointReportRepository, ISharePointReportRepository } from './sharePointReportRepository';
import { createDataverseReportRepository, IDataverseReportRepository } from './dataverseReportRepository'; import { createDataverseReportRepository, IDataverseReportRepository } from './dataverseReportRepository';
@@ -9,13 +10,15 @@ export interface IReportServices {
siteUrl: string; siteUrl: string;
} }
// Всё — от сайта со списками (settings.siteUrl), а не от сайта страницы: в workbench корневого
// сайта id пользователя и путь к библиотеке DocLib там другие.
export function createReportServices(services: IAppServices): IReportServices { export function createReportServices(services: IAppServices): IReportServices {
return { return {
sp: createSharePointReportRepository({ sp: createSharePointReportRepository({
client: services.sharePoint, client: services.sharePoint,
siteUrl: services.settings.siteUrl, siteUrl: services.settings.siteUrl,
userId: services.page.userId, getCurrentUserId: services.getCurrentUserId,
webServerRelativeUrl: services.page.webServerRelativeUrl webServerRelativeUrl: siteServerRelativeUrl(services.settings.siteUrl)
}), }),
dv: createDataverseReportRepository(services.dataverse, services.settings.dataverseUrl), dv: createDataverseReportRepository(services.dataverse, services.settings.dataverseUrl),
siteUrl: services.settings.siteUrl siteUrl: services.settings.siteUrl
@@ -17,7 +17,7 @@ const repositoryWith = (routes: IFakeRoute[]): {
const repository = createSharePointReportRepository({ const repository = createSharePointReportRepository({
client, client,
siteUrl: SITE, siteUrl: SITE,
userId: 42, getCurrentUserId: async () => 42,
webServerRelativeUrl: '/sites/stroy/' webServerRelativeUrl: '/sites/stroy/'
}); });
return { client, repository }; return { client, repository };
@@ -111,9 +111,13 @@ export function fileAddUrl(siteUrl: string, serverRelativeFolderUrl: string, fil
export interface ISharePointReportDeps { export interface ISharePointReportDeps {
client: IHttpClient; client: IHttpClient;
siteUrl: string; siteUrl: string;
/** Id текущего пользователя (legacyPageContext.userId) — по нему ищется строка «Доступы». */ /**
userId: number | undefined; * Id текущего пользователя на сайте со списками — по нему ищется строка «Доступы».
/** Серверный относительный URL сайта — для пути к папке фото. */ * Старый код брал legacyPageContext.userId (id на сайте страницы); в workbench корневого сайта
* это другой id, и доступ не находился. Теперь — `_api/web/currentuser` сайта со списками.
*/
getCurrentUserId: () => Promise<number>;
/** Серверный относительный URL сайта со списками (`/sites/stroy`) — для пути к папке фото. */
webServerRelativeUrl: string; webServerRelativeUrl: string;
} }
@@ -224,8 +228,11 @@ export function createSharePointReportRepository(deps: ISharePointReportDeps): I
createListItem, createListItem,
checkAccess: async (projectId) => { checkAccess: async (projectId) => {
const rows = await getItems<IAccessRaw>(LISTS.access, { filter: `projectId eq ${projectId}` }); const [rows, userId] = await Promise.all([
return pickAccess(rows, deps.userId); getItems<IAccessRaw>(LISTS.access, { filter: `projectId eq ${projectId}` }),
deps.getCurrentUserId()
]);
return pickAccess(rows, userId);
}, },
uploadPhotos: async (projectId, reportNumber, photos) => { uploadPhotos: async (projectId, reportNumber, photos) => {
@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { LISTS } from '../../config/lists'; import { LISTS } from '../../config/lists';
import { IReportLookups, IWorkItem } from './reportTypes'; import { IJobWithPath, IReportLookups, IWorkItem } from './reportTypes';
import { IPickerCreateData, IPickerItem, IPickerOptions } from './picker/pickerTypes'; import { IPickerCreateData, IPickerItem, IPickerOptions } from './picker/pickerTypes';
import { PickerKind, PICKER_OPTIONS, toPickerItems } from './reportPicker'; import { PickerKind, PICKER_OPTIONS, toPickerItems } from './reportPicker';
import { addResource, addWork, createLocalId, createResourceItem } from './reportFormUpdates'; import { addResource, addWork, createLocalId, createResourceItem } from './reportFormUpdates';
@@ -32,8 +32,14 @@ interface IPickerDeps {
projectId: number; projectId: number;
} }
function newWorkItem(item: IPickerItem, lookups: IReportLookups, totalFact: number): IWorkItem { function newWorkItem(
const job = lookups.jobs.filter((candidate) => candidate.cree9_tableid === item.id)[0]; item: IPickerItem,
lookups: IReportLookups,
createdJobs: Map<string, IJobWithPath>,
totalFact: number
): IWorkItem {
const job =
lookups.jobs.filter((candidate) => candidate.cree9_tableid === item.id)[0] ?? createdJobs.get(String(item.id));
return { return {
id: createLocalId(), id: createLocalId(),
jobId: String(item.id), jobId: String(item.id),
@@ -75,6 +81,10 @@ export function usePickerModal(deps: IPickerDeps): IPickerModalApi {
const { services, report } = deps; const { services, report } = deps;
const [active, setActive] = React.useState<IActivePicker | undefined>(undefined); const [active, setActive] = React.useState<IActivePicker | undefined>(undefined);
const [isLoading, setIsLoading] = React.useState<boolean>(false); const [isLoading, setIsLoading] = React.useState<boolean>(false);
// Работы, созданные из пикера в этой форме. Нужны сразу при выборе: в lookups.jobs текущего рендера
// их ещё нет (обновление state придёт позже), и без этого карточка получала путь вместо названия,
// пустую ед. изм. и план 0 — баг старого кода, исправлен при проверке T-REP-15b.
const createdJobsRef = React.useRef<Map<string, IJobWithPath>>(new Map());
const open = (kind: PickerKind, targetWorkId?: string): void => { const open = (kind: PickerKind, targetWorkId?: string): void => {
setActive({ kind, targetWorkId }); setActive({ kind, targetWorkId });
@@ -100,10 +110,8 @@ export function usePickerModal(deps: IPickerDeps): IPickerModalApi {
} else if (kind === 'company') { } else if (kind === 'company') {
report.updateForm((prev) => ({ ...prev, companyId: Number(item.id), companyName: item.title })); report.updateForm((prev) => ({ ...prev, companyId: Number(item.id), companyName: item.title }));
} else if (kind === 'work') { } else if (kind === 'work') {
// 🐞 Как в старом коде: только что созданной работы ещё нет в lookups.jobs этого рендера,
// поэтому у неё название = полный путь, ед. изм. пустая и план 0.
const totalFact = await services.dv.getJobTotalFact(String(item.id)); const totalFact = await services.dv.getJobTotalFact(String(item.id));
const work = newWorkItem(item, report.lookups, totalFact); const work = newWorkItem(item, report.lookups, createdJobsRef.current, totalFact);
report.updateForm((prev) => addWork(prev, work)); report.updateForm((prev) => addWork(prev, work));
} else { } else {
const resource = createResourceItem(Number(item.id), item.title); const resource = createResourceItem(Number(item.id), item.title);
@@ -134,7 +142,9 @@ export function usePickerModal(deps: IPickerDeps): IPickerModalApi {
parentId: data.parentId parentId: data.parentId
}); });
const fullPath = newJobPath(data.title, data.parentId, report.lookups.jobs); const fullPath = newJobPath(data.title, data.parentId, report.lookups.jobs);
report.updateLookups((prev) => ({ jobs: [...prev.jobs, { ...created, fullPath }] })); const createdWithPath = { ...created, fullPath };
createdJobsRef.current.set(created.cree9_tableid, createdWithPath);
report.updateLookups((prev) => ({ jobs: [...prev.jobs, createdWithPath] }));
return { id: created.cree9_tableid, title: fullPath, subtitle: data.unit }; return { id: created.cree9_tableid, title: fullPath, subtitle: data.unit };
}; };
@@ -115,7 +115,8 @@ export function useReportForm(services: IReportServices, params: IReportRoutePar
try { try {
await services.dv.approveReport(existingReportId); await services.dv.approveReport(existingReportId);
setSuccessMessage(MESSAGES.approved); setSuccessMessage(MESSAGES.approved);
// 🐞 Как в старом коде: флаг «утверждён» не обновляется, бейдж остаётся «На утверждении». // В старом коде флаг не обновлялся и бейдж оставался «На утверждении» до F5 — исправлено при проверке T-REP-16.
setIsApproved(true);
setMode('readonly'); setMode('readonly');
} catch (error) { } catch (error) {
setErrorMessage(MESSAGES.approveError(messageOf(error))); setErrorMessage(MESSAGES.approveError(messageOf(error)));
@@ -0,0 +1,40 @@
import * as React from 'react';
import { Button, confirmModal, Spinner } from '@tnsu/ui-kit';
import { IImportedReport } from './reportImportBuilder';
import { ReportImportStatus } from './useReportImport';
import { REPORT_IMPORT_TEXTS } from './texts';
const TEXTS = REPORT_IMPORT_TEXTS.import;
interface Props {
reports: IImportedReport[];
projectTitle: string;
status: ReportImportStatus;
onImport: (reports: IImportedReport[]) => Promise<void>;
}
/** Кнопка «Импортировать» с подтверждением; во время записи — прогресс. После записи кнопки нет. */
export const ReportImportActions: React.FC<Props> = ({ reports, projectTitle, status, onImport }) => {
if (status.kind === 'saving') {
return <Spinner size="small" label={TEXTS.saving(status.savedCount, status.total)} />;
}
if (status.kind === 'finished') return null;
const startImport = async (): Promise<void> => {
const isConfirmed = await confirmModal({
title: TEXTS.confirmTitle,
message: TEXTS.confirmMessage(reports.length, projectTitle),
confirmText: TEXTS.confirm,
cancelText: TEXTS.cancel
});
if (isConfirmed) await onImport(reports);
};
return (
<div>
<Button variant="accent" onClick={() => startImport().catch(() => undefined)}>
{TEXTS.button(reports.length)}
</Button>
</div>
);
};
@@ -0,0 +1,45 @@
import * as React from 'react';
import { Alert } from '@tnsu/ui-kit';
import { RouteLink } from '../../app/routing/RouteLink';
import { formatReportDate } from '../reports/reportsRules';
import { IImportSaveResult, ISavedImportReport } from './reportImportSave';
import { REPORT_IMPORT_TEXTS } from './texts';
import styles from './ReportImportPage.module.scss';
const TEXTS = REPORT_IMPORT_TEXTS.import;
interface Props {
result: IImportSaveResult;
projectId: string;
}
function savedList(saved: ISavedImportReport[]): string {
return saved.map((report) => TEXTS.savedReport(report.reportNumber, formatReportDate(report.reportDate))).join(', ');
}
/** Итог записи. При ошибке явно говорим, что уже записано, — повторная загрузка файла задвоит отчёты. */
export const ReportImportOutcome: React.FC<Props> = ({ result, projectId }) => {
const reportsLink = (
<RouteLink className={styles.reportsLink} to={{ page: 'reports', projectId }}>
{TEXTS.openReports}
</RouteLink>
);
if (!result.errorMessage) {
return (
<Alert variant="ok">
{TEXTS.success(savedList(result.saved))} {reportsLink}
</Alert>
);
}
const failedRows = (result.failedRows || []).join(', ');
return (
<Alert variant="err">
<p className={styles.outcomeLine}>{TEXTS.failed(failedRows, result.errorMessage)}</p>
{result.saved.length > 0 && <p className={styles.outcomeLine}>{TEXTS.partlySaved(savedList(result.saved))}</p>}
<p className={styles.outcomeLine}>{TEXTS.mayBePartial}</p>
{reportsLink}
</Alert>
);
};
@@ -0,0 +1,23 @@
@import '../../../../styles/design-tokens';
.page {
display: flex;
flex-direction: column;
gap: $sp-4;
width: 100%;
}
.intro {
margin: 0;
font-size: $fs-sm;
color: $gray-700;
}
.outcomeLine {
margin: 0 0 $sp-2;
}
.reportsLink {
color: inherit;
font-weight: 600;
}
@@ -0,0 +1,55 @@
import * as React from 'react';
import { Alert } from '@tnsu/ui-kit';
import { useProject } from '../../app/project/ProjectContext';
import { isSectionAvailable } from '../../app/project/projectRules';
import { TreePageHeader } from '../_tree/TreePageHeader';
import { ExcelImportSection } from '../_excelImport/ExcelImportSection';
import { reportImportUrl } from './reportImportRepository';
import { useReportImport } from './useReportImport';
import { ReportImportStep } from './ReportImportStep';
import { REPORT_IMPORT_TEXTS } from './texts';
import styles from './ReportImportPage.module.scss';
/**
* Импорт отчёта из Excel: `?page=reportImport&projectId=`.
* Файл проверяется на сервере сразу после выбора (сервер ничего не сохраняет); если ошибок нет —
* «Импортировать» записывает отчёты в Dataverse тем же кодом, что форма «Отправить отчёт».
*/
export const ReportImportPage: React.FC = () => {
const { currentProject } = useProject();
const importer = useReportImport(currentProject?.id);
const renderContent = (): React.ReactNode => {
if (!currentProject) return <Alert variant="info">{REPORT_IMPORT_TEXTS.noProject}</Alert>;
if (!isSectionAvailable('reportImport', currentProject)) {
return <Alert variant="warn">{REPORT_IMPORT_TEXTS.noAccess}</Alert>;
}
return (
<>
<p className={styles.intro}>{REPORT_IMPORT_TEXTS.intro}</p>
<ExcelImportSection
projectId={currentProject.id}
checkUrl={reportImportUrl}
isLocked={importer.status.kind === 'saving'}
onFileChange={importer.reset}
renderImport={(check) => (
<ReportImportStep
check={check}
projectId={currentProject.id}
projectTitle={currentProject.title}
status={importer.status}
onImport={importer.importReports}
/>
)}
/>
</>
);
};
return (
<div className={styles.page}>
<TreePageHeader icon="upload" label={REPORT_IMPORT_TEXTS.headerLabel} title={currentProject?.title ?? ''} />
{renderContent()}
</div>
);
};
@@ -0,0 +1,28 @@
import * as React from 'react';
import { Alert } from '@tnsu/ui-kit';
import { IExcelImportCheck } from '../_excelImport/excelImportTypes';
import { IImportedReport, tryBuildImportedReports } from './reportImportBuilder';
import { ReportImportStatus } from './useReportImport';
import { ReportImportActions } from './ReportImportActions';
import { ReportImportOutcome } from './ReportImportOutcome';
interface Props {
check: IExcelImportCheck;
projectId: number;
projectTitle: string;
status: ReportImportStatus;
onImport: (reports: IImportedReport[]) => Promise<void>;
}
/** Импорт проверенного файла без ошибок: отчёты собираются заранее, чтобы ошибку сборки показать до записи. */
export const ReportImportStep: React.FC<Props> = ({ check, projectId, projectTitle, status, onImport }) => {
const build = React.useMemo(() => tryBuildImportedReports(check, projectId), [check, projectId]);
if (build.kind === 'error') return <Alert variant="err">{build.message}</Alert>;
return (
<>
<ReportImportActions reports={build.reports} projectTitle={projectTitle} status={status} onImport={onImport} />
{status.kind === 'finished' && <ReportImportOutcome result={status.result} projectId={String(projectId)} />}
</>
);
};
@@ -0,0 +1,157 @@
import { buildImportedReports, toIsoDate } from './reportImportBuilder';
import { IExcelImportCheck, IExcelImportRow } from '../_excelImport/excelImportTypes';
/* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */
const JOB = 'f4cc373e-77cb-f111-a05c-70a8a5039971';
/** Строка как в ответе сервера на «ТЕСТ импорт отчета - проект 51.xlsx»; overrides — отличия. */
function makeRow(
row: number,
values: Record<string, string | null>,
ids: Record<string, unknown>
): IExcelImportRow {
return {
row,
valid: true,
values: {
'Дата': '01.10.2026 5:00:00',
'Компания': 'TANSU',
'Смена': '1',
'Объект 1': 'Тестовый объект',
'Объект 2': 'Бетонирование',
'Объект 3': null,
'Вид работы': 'Арматура',
'Факт': null,
'Техника': null,
'Профессия': null,
'Количество': null,
'Часы': null,
'Тип часов': 'Часы работы',
'Комментарий': null,
'Комментарий (данные)': null,
...values
},
ids: { 'Объект 1': 1516, 'Объект 2': 1517, 'Вид работы': JOB, 'Компания': 29, 'Смена': 1, ...ids },
issues: []
};
}
function makeCheck(rows: IExcelImportRow[]): IExcelImportCheck {
return {
kind: 'reports',
file_name: 'test.xlsx',
project_id: 51,
project_name: 'TEST',
total_rows: rows.length,
valid_rows: rows.length,
invalid_rows: 0,
rows,
notes: []
};
}
const technicRow = makeRow(2, { 'Факт': '5', 'Количество': '1', 'Часы': '11' }, { 'Техника': 327 });
const professionRow = makeRow(3, { 'Факт': '3', 'Количество': '10', 'Часы': '11' }, { 'Профессия': 181 });
describe('сборка отчётов из ответа проверки', () => {
it('тестовый файл: один отчёт, две работы, ресурсы вложены в работы', () => {
const reports = buildImportedReports(makeCheck([technicRow, professionRow]), 51);
expect(reports).toEqual([
{
payload: {
cree9_report_date: '2026-10-01',
cree9_companyid: 29,
cree9_shiftid: 1,
cree9_objectid: 1517,
cree9_projectid: 51,
cree9_comment: undefined
},
rowNumbers: [2, 3],
nodes: [
{
data: { _cree9_jobid_value: JOB, cree9_value: 5, cree9_comment: undefined },
children: [{ cree9_technicid: 327, cree9_count: 1, cree9_hours: 11, cree9_hourstype: 'Часы работы' }]
},
{
data: { _cree9_jobid_value: JOB, cree9_value: 3, cree9_comment: undefined },
children: [{ cree9_professionid: 181, cree9_count: 10, cree9_hours: 11, cree9_hourstype: 'Часы работы' }]
}
]
}
]);
});
it('простой под работой: работа остаётся, техника — отдельной строкой с типом часов и комментарием', () => {
const idleRow = makeRow(
2,
{ 'Факт': '5', 'Количество': '1', 'Часы': '3', 'Тип часов': 'Часы простоя', 'Комментарий (данные)': 'нет бетона' },
{ 'Техника': 327 }
);
const [report] = buildImportedReports(makeCheck([idleRow]), 51);
expect(report.nodes).toEqual([
{ data: { _cree9_jobid_value: JOB, cree9_value: 5, cree9_comment: undefined }, children: undefined },
{
data: {
cree9_technicid: 327,
cree9_count: 1,
cree9_hours: 3,
cree9_hourstype: 'Часы простоя',
cree9_comment: 'нет бетона'
}
}
]);
});
it('комментарий строки с часами работы остаётся на работе', () => {
const row = makeRow(2, { 'Факт': '5', 'Комментарий (данные)': 'залили' }, { 'Техника': 327 });
const [report] = buildImportedReports(makeCheck([row]), 51);
expect(report.nodes[0].data.cree9_comment).toEqual('залили');
expect(report.nodes[0].children?.[0].cree9_comment).toBeUndefined();
});
it('отдельные строки идут после работ: сначала персонал, потом техника — как в форме', () => {
const technic = makeRow(2, { 'Количество': '1', 'Часы': '2', 'Тип часов': 'Часы ремонта' }, { 'Техника': 327 });
const profession = makeRow(3, { 'Количество': '2', 'Часы': '4', 'Тип часов': 'Часы простоя' }, { 'Профессия': 181 });
const [report] = buildImportedReports(makeCheck([technic, profession]), 51);
expect(report.nodes.slice(2).map((node) => node.data)).toEqual([
{ cree9_professionid: 181, cree9_count: 2, cree9_hours: 4, cree9_hourstype: 'Часы простоя' },
{ cree9_technicid: 327, cree9_count: 1, cree9_hours: 2, cree9_hourstype: 'Часы ремонта' }
]);
});
it('разные компании или даты — разные отчёты', () => {
const otherCompany = makeRow(3, {}, { 'Компания': 163 });
const otherDate = makeRow(4, { 'Дата': '02.10.2026 5:00:00' }, {});
const reports = buildImportedReports(makeCheck([technicRow, otherCompany, otherDate]), 51);
expect(reports.map((report) => report.rowNumbers)).toEqual([[2], [3], [4]]);
});
it('объект — самый глубокий заполненный уровень', () => {
const row = makeRow(2, {}, { 'Объект 3': 1600 });
expect(buildImportedReports(makeCheck([row]), 51)[0].payload.cree9_objectid).toEqual(1600);
});
it('нет компании — ошибка с номером строки, ничего не собирается', () => {
const row = makeRow(7, {}, { 'Компания': undefined });
expect(() => buildImportedReports(makeCheck([row]), 51)).toThrow('Строка 7');
});
it('строка без вида работы — ошибка (сервер такие не пропускает, но не пишем молча)', () => {
const row = makeRow(5, {}, { 'Вид работы': undefined, 'Профессия': 181 });
expect(() => buildImportedReports(makeCheck([row]), 51)).toThrow('Строка 5: не определён вид работы.');
});
});
describe('разбор значений Excel', () => {
it('дата: дд.мм.гггг с временем → гггг-мм-дд', () => {
expect(toIsoDate('01.10.2026 5:00:00')).toEqual('2026-10-01');
expect(toIsoDate(null)).toBeUndefined();
});
});
@@ -0,0 +1,128 @@
import { IReportDataFields, IReportDataNode, IReportPayload } from '../report/reportTypes';
import { HOURS_TYPES } from '../report/reportConstants';
import { IExcelImportCheck, IExcelImportRow } from '../_excelImport/excelImportTypes';
import { deepestObjectId, idNumber, textValue, toNumber } from '../_excelImport/excelImportValues';
import { IMPORT_COLUMNS as COL } from './reportImportColumns';
import { REPORT_IMPORT_TEXTS } from './texts';
// Ответ проверки → отчёты в том же виде, в каком их сохраняет форма «Отправить отчёт».
/* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */
/** Отчёт к записи. Номер назначается при сохранении (наибольший в проекте + 1). */
export interface IImportedReport {
payload: Omit<IReportPayload, 'cree9_number'>;
nodes: IReportDataNode[];
/** Строки Excel, из которых собран отчёт, — для сообщений пользователю. */
rowNumbers: number[];
}
/** «01.10.2026 5:00:00» → «2026-10-01» (формат даты, который пишет форма). Время отбрасываем. */
export function toIsoDate(value: string | null): string | undefined {
const match = /^(\d{2})\.(\d{2})\.(\d{4})/.exec(value || '');
return match ? `${match[3]}-${match[2]}-${match[1]}` : undefined;
}
function buildPayload(row: IExcelImportRow, projectId: number): IImportedReport['payload'] {
const reportDate = toIsoDate(row.values[COL.date]);
const companyId = idNumber(row, COL.company);
const shiftId = idNumber(row, COL.shift);
const objectId = deepestObjectId(row, COL.objects);
// Сервер уже проверил строку; если поля всё же нет — не пишем отчёт без шапки.
if (!reportDate || !companyId || !shiftId || !objectId) {
throw new Error(REPORT_IMPORT_TEXTS.build.missingHeader(row.row));
}
return {
cree9_report_date: reportDate,
cree9_companyid: companyId,
cree9_shiftid: shiftId,
cree9_objectid: objectId,
cree9_projectid: projectId,
cree9_comment: textValue(row, COL.reportComment)
};
}
/** Персонал и техника строки (порядок как в форме: сначала персонал). */
function buildResources(row: IExcelImportRow, hoursType: string): IReportDataFields[] {
const common = { cree9_count: toNumber(row.values[COL.count]), cree9_hours: toNumber(row.values[COL.hours]) };
const resources: IReportDataFields[] = [];
const professionId = idNumber(row, COL.profession);
const technicId = idNumber(row, COL.technic);
if (professionId) resources.push({ cree9_professionid: professionId, ...common, cree9_hourstype: hoursType });
if (technicId) resources.push({ cree9_technicid: technicId, ...common, cree9_hourstype: hoursType });
return resources;
}
/**
* Узлы одной строки Excel: своя строка-работа на каждую строку (вид работы сервер требует всегда).
* Ресурс с «Часы работы» — под работу, комментарий строки — работе.
* Простой / ремонт — отдельной строкой, как в форме (под работой форма хранит только часы работы),
* и комментарий строки — ей: в форме это причина простоя. Пустой комментарий импорт не блокирует —
* сервер такой файл принимает; обязательность из формы сработает, только когда отчёт будут править.
*/
function buildRowNodes(row: IExcelImportRow): { work: IReportDataNode; general: IReportDataFields[] } {
const jobId = row.ids[COL.job];
if (typeof jobId !== 'string') throw new Error(REPORT_IMPORT_TEXTS.build.missingJob(row.row));
const hoursType = textValue(row, COL.hoursType) || HOURS_TYPES.work;
const resources = buildResources(row, hoursType);
const comment = textValue(row, COL.rowComment);
const isWorkHours = hoursType === HOURS_TYPES.work;
const work: IReportDataNode = {
data: {
_cree9_jobid_value: jobId,
cree9_value: toNumber(row.values[COL.fact]),
cree9_comment: isWorkHours ? comment : undefined
},
children: isWorkHours && resources.length > 0 ? resources : undefined
};
const general = isWorkHours ? [] : resources.map((item) => ({ ...item, cree9_comment: comment }));
return { work, general };
}
export type ImportBuildResult = { kind: 'ok'; reports: IImportedReport[] } | { kind: 'error'; message: string };
/** Сборка без исключений — для показа на экране до записи. */
export function tryBuildImportedReports(check: IExcelImportCheck, projectId: number): ImportBuildResult {
try {
return { kind: 'ok', reports: buildImportedReports(check, projectId) };
} catch (error) {
return { kind: 'error', message: error instanceof Error ? error.message : String(error) };
}
}
function reportKey(payload: IImportedReport['payload']): string {
return [payload.cree9_report_date, payload.cree9_companyid, payload.cree9_shiftid, payload.cree9_objectid].join('|');
}
interface IReportDraft {
report: IImportedReport;
works: IReportDataNode[];
general: IReportDataFields[];
}
/**
* Строки с одинаковыми датой, компанией, сменой и объектом — один отчёт.
* Порядок узлов как в форме: работы, затем общий персонал, затем общая техника.
*/
export function buildImportedReports(check: IExcelImportCheck, projectId: number): IImportedReport[] {
const drafts = new Map<string, IReportDraft>();
check.rows.forEach((row) => {
const payload = buildPayload(row, projectId);
const key = reportKey(payload);
const draft = drafts.get(key) ?? { report: { payload, nodes: [], rowNumbers: [] }, works: [], general: [] };
const { work, general } = buildRowNodes(row);
draft.works.push(work);
draft.general.push(...general);
draft.report.rowNumbers.push(row.row);
drafts.set(key, draft);
});
return Array.from(drafts.values()).map(({ report, works, general }) => ({
...report,
nodes: [
...works,
...general.filter((item) => item.cree9_professionid).map((data) => ({ data })),
...general.filter((item) => item.cree9_technicid).map((data) => ({ data }))
]
}));
}
@@ -0,0 +1,17 @@
// Заголовки колонок Excel-формы отчёта — ключи values / ids в ответе проверки.
export const IMPORT_COLUMNS = {
date: 'Дата',
company: 'Компания',
shift: 'Смена',
objects: ['Объект 1', 'Объект 2', 'Объект 3'],
job: 'Вид работы',
fact: 'Факт',
technic: 'Техника',
profession: 'Профессия',
count: 'Количество',
hours: 'Часы',
hoursType: 'Тип часов',
reportComment: 'Комментарий',
rowComment: 'Комментарий (данные)'
} as const;
@@ -0,0 +1,7 @@
import { reportImportUrl } from './reportImportRepository';
describe('импорт отчётов: адрес проверки', () => {
it('как в Swagger', () => {
expect(reportImportUrl('https://api.tnsu.kz', 51)).toEqual('https://api.tnsu.kz/Import/StroyBot/Reports?projectId=51');
});
});
@@ -0,0 +1,3 @@
export function reportImportUrl(apiBaseUrl: string, projectId: number): string {
return `${apiBaseUrl}/Import/StroyBot/Reports?projectId=${projectId}`;
}
@@ -0,0 +1,67 @@
import { IReportDataNode, IReportPayload } from '../report/reportTypes';
import { IImportedReport } from './reportImportBuilder';
import { ImportReportWriter, saveImportedReports } from './reportImportSave';
function makeReport(date: string, rowNumbers: number[]): IImportedReport {
return {
payload: {
cree9_report_date: date,
cree9_companyid: 29,
cree9_shiftid: 1,
cree9_objectid: 1517,
cree9_projectid: 51,
cree9_comment: undefined
},
nodes: [{ data: { _cree9_jobid_value: 'job', cree9_value: 5 } }],
rowNumbers
};
}
/** Writer, который выдаёт номера с 10 и запоминает, что записано. failOnCall — номер вызова сохранения с ошибкой. */
function makeWriter(failOnCall?: number): ImportReportWriter & { saved: IReportPayload[]; nodes: IReportDataNode[][] } {
let nextNumber = 10;
const writer = {
saved: [] as IReportPayload[],
nodes: [] as IReportDataNode[][],
getNextReportNumber: async (): Promise<number> => nextNumber++,
saveReportWithData: async (report: IReportPayload, nodes: IReportDataNode[]): Promise<string> => {
if (writer.saved.length + 1 === failOnCall) throw new Error('Dataverse недоступен');
writer.saved.push(report);
writer.nodes.push(nodes);
return `id-${report.cree9_number}`;
}
};
return writer;
}
describe('запись импортированных отчётов', () => {
it('по одному, каждому — свой номер; узлы передаются как есть', async () => {
const writer = makeWriter();
const progress: number[] = [];
const reports = [makeReport('2026-10-01', [2, 3, 4]), makeReport('2026-10-02', [5])];
const result = await saveImportedReports(writer, 51, reports, (count) => progress.push(count));
expect(result).toEqual({
saved: [
{ reportNumber: 10, reportDate: '2026-10-01', rowNumbers: [2, 3, 4] },
{ reportNumber: 11, reportDate: '2026-10-02', rowNumbers: [5] }
]
});
expect(writer.saved.map((report) => report.cree9_number)).toEqual([10, 11]);
expect(writer.nodes[0]).toEqual(reports[0].nodes);
expect(progress).toEqual([1, 2]);
});
it('ошибка останавливает запись и возвращает уже записанные отчёты', async () => {
const writer = makeWriter(2);
const reports = [makeReport('2026-10-01', [2]), makeReport('2026-10-02', [3]), makeReport('2026-10-03', [4])];
const result = await saveImportedReports(writer, 51, reports, () => undefined);
expect(result.saved.map((report) => report.reportNumber)).toEqual([10]);
expect(result.errorMessage).toEqual('Dataverse недоступен');
expect(result.failedRows).toEqual([3]);
expect(writer.saved).toHaveLength(1);
});
});
@@ -0,0 +1,46 @@
import { IDataverseReportRepository } from '../report/dataverseReportRepository';
import { IImportedReport } from './reportImportBuilder';
/** Нужная импорту часть репозитория формы отчёта — тот же код записи, что у «Отправить отчёт». */
export type ImportReportWriter = Pick<IDataverseReportRepository, 'getNextReportNumber' | 'saveReportWithData'>;
export interface ISavedImportReport {
reportNumber: number;
/** «гггг-мм-дд» */
reportDate: string;
rowNumbers: number[];
}
export interface IImportSaveResult {
saved: ISavedImportReport[];
/** Есть — запись остановилась на отчёте из строк failedRows. */
errorMessage?: string;
failedRows?: number[];
}
/**
* Записывает отчёты по одному: номер выдаётся перед каждым (наибольший в проекте + 1),
* поэтому параллельно нельзя — номера совпали бы. Ошибка останавливает запись; уже записанные
* отчёты возвращаем, чтобы пользователь не загрузил тот же файл целиком повторно.
*/
export async function saveImportedReports(
writer: ImportReportWriter,
projectId: number,
reports: IImportedReport[],
onProgress: (savedCount: number) => void
): Promise<IImportSaveResult> {
const saved: ISavedImportReport[] = [];
for (const report of reports) {
try {
const reportNumber = await writer.getNextReportNumber(projectId);
await writer.saveReportWithData({ ...report.payload, cree9_number: reportNumber }, report.nodes);
saved.push({ reportNumber, reportDate: report.payload.cree9_report_date, rowNumbers: report.rowNumbers });
onProgress(saved.length);
} catch (error) {
console.error('Ошибка записи импортированного отчёта:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
return { saved, errorMessage, failedRows: report.rowNumbers };
}
}
return { saved };
}
@@ -0,0 +1,33 @@
// Тексты раздела «Импорт отчёта из Excel».
export const REPORT_IMPORT_TEXTS = {
headerLabel: 'Импорт отчёта из Excel',
noProject: 'Проект не выбран. Выберите проект на главной.',
noAccess: 'Импорт отчётов недоступен: нужно право редактирования, проект не должен быть архивным или завершённым.',
intro:
'Загрузите заполненную форму ежедневного отчёта (.xlsx) — файл сразу проверится. ' +
'Ошибки подсветятся в таблице; если ошибок нет, отчёты можно будет импортировать.',
import: {
button: (reportCount: number): string => `Импортировать (отчётов: ${reportCount})`,
saving: (savedCount: number, total: number): string => `Сохраняем отчёты… ${savedCount} из ${total}`,
confirmTitle: 'Импорт отчётов',
confirmMessage: (reportCount: number, projectTitle: string): string =>
`Будет создано отчётов: ${reportCount} в проекте «${projectTitle}». Продолжить?`,
confirm: 'Импортировать',
cancel: 'Отмена',
savedReport: (reportNumber: number, date: string): string => `№${reportNumber} за ${date}`,
success: (list: string): string => `Импорт завершён. Созданы отчёты: ${list}.`,
failed: (rows: string, details: string): string => `Не удалось записать отчёт из строк ${rows}: ${details}`,
partlySaved: (list: string): string =>
`Уже созданы отчёты: ${list}. Не загружайте этот файл повторно целиком — уберите из него эти строки, ` +
'иначе отчёты задвоятся.',
mayBePartial:
'Отчёт, на котором произошла ошибка, мог записаться частично — проверьте «Отчёты по проекту» ' +
'перед повторной загрузкой.',
openReports: 'Открыть «Отчёты по проекту»'
},
build: {
missingHeader: (row: number): string => `Строка ${row}: не определены дата, компания, смена или объект.`,
missingJob: (row: number): string => `Строка ${row}: не определён вид работы.`
}
} as const;
@@ -0,0 +1,47 @@
import * as React from 'react';
import { useAppServices } from '../../app/AppServicesContext';
import { createDataverseReportRepository } from '../report/dataverseReportRepository';
import { IImportedReport } from './reportImportBuilder';
import { IImportSaveResult, saveImportedReports } from './reportImportSave';
export type ReportImportStatus =
| { kind: 'idle' }
| { kind: 'saving'; savedCount: number; total: number }
| { kind: 'finished'; result: IImportSaveResult };
/** Запись собранных отчётов в Dataverse с прогрессом. reset — при выборе нового файла. */
export function useReportImport(projectId: number | undefined): {
status: ReportImportStatus;
importReports: (reports: IImportedReport[]) => Promise<void>;
reset: () => void;
} {
const services = useAppServices();
const [status, setStatus] = React.useState<ReportImportStatus>({ kind: 'idle' });
const isMountedRef = React.useRef<boolean>(true);
React.useEffect(
() => () => {
isMountedRef.current = false;
},
[]
);
const finish = (result: IImportSaveResult): void => {
if (isMountedRef.current) setStatus({ kind: 'finished', result });
};
const importReports = async (reports: IImportedReport[]): Promise<void> => {
if (projectId === undefined) return;
const total = reports.length;
setStatus({ kind: 'saving', savedCount: 0, total });
const writer = createDataverseReportRepository(services.dataverse, services.settings.dataverseUrl);
const result = await saveImportedReports(writer, projectId, reports, (savedCount) => {
if (isMountedRef.current) setStatus({ kind: 'saving', savedCount, total });
});
finish(result);
};
const reset = React.useCallback((): void => setStatus({ kind: 'idle' }), []);
return { status, importReports, reset };
}
@@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { Badge, Icon } from '@tnsu/ui-kit'; import { Badge, Icon } from '@tnsu/ui-kit';
import { useRouter } from '../../app/routing/RouterContext'; import { useRouter } from '../../app/routing/RouterContext';
import { IRoute } from '../../app/routing/routes';
import { IReportAccess, IReportCard } from './reportsTypes'; import { IReportAccess, IReportCard } from './reportsTypes';
import { canCreateReport, canDeleteReport } from './reportsRules'; import { canCreateReport, canDeleteReport } from './reportsRules';
import { REPORTS_TEXTS } from './texts'; import { REPORTS_TEXTS } from './texts';
@@ -17,16 +18,26 @@ interface Props {
} }
/** /**
* Карточка отчёта. Отчёт и «Создать на основе» открываются в новой вкладке — как в старом * Карточка отчёта. Отчёт и «Создать на основе» открываются в этой же вкладке — переходом
* web part (window.open … '_blank'), но адрес теперь — раздел формы отчёта этого приложения. * на раздел формы отчёта без перезагрузки страницы.
*/ */
export const ReportCard: React.FC<Props> = ({ report, access, projectId, onDelete }) => { export const ReportCard: React.FC<Props> = ({ report, access, projectId, onDelete }) => {
const { buildHref } = useRouter(); const { navigate, buildHref } = useRouter();
const reportHref = buildHref({ page: 'report', projectId, reportId: report.id }); const reportRoute: IRoute = { page: 'report', projectId, reportId: report.id };
const copyHref = buildHref({ page: 'report', projectId, copyFromId: report.id }); const copyRoute: IRoute = { page: 'report', projectId, copyFromId: report.id };
const openReport = (): void => { const openReport = (): void => {
window.open(reportHref, '_blank', 'noopener,noreferrer'); navigate(reportRoute);
};
// Ссылка остаётся настоящим <a href>, чтобы Ctrl+клик / средняя кнопка по-прежнему
// открывали копию в новой вкладке; обычный клик — переход внутри приложения.
const onCopyClick = (event: React.MouseEvent<HTMLAnchorElement>): void => {
event.stopPropagation();
const opensNewTab = event.ctrlKey || event.metaKey || event.shiftKey || event.button !== 0;
if (opensNewTab) return;
event.preventDefault();
navigate(copyRoute);
}; };
// Как в старом коде: Enter/Space внутри карточки (в том числе на её кнопках) открывают отчёт. // Как в старом коде: Enter/Space внутри карточки (в том числе на её кнопках) открывают отчёт.
@@ -65,12 +76,10 @@ export const ReportCard: React.FC<Props> = ({ report, access, projectId, onDelet
{canCreateReport(access) && ( {canCreateReport(access) && (
<a <a
className={styles.cardAction} className={styles.cardAction}
href={copyHref} href={buildHref(copyRoute)}
target="_blank"
rel="noopener noreferrer"
title={TEXTS.copyTitle(report.number)} title={TEXTS.copyTitle(report.number)}
aria-label={TEXTS.copyAriaLabel} aria-label={TEXTS.copyAriaLabel}
onClick={(event) => event.stopPropagation()} onClick={onCopyClick}
> >
<Icon name="copy" size="sm" /> <Icon name="copy" size="sm" />
</a> </a>
@@ -11,7 +11,8 @@ export interface IReportListDeps {
dataverse: IHttpClient; dataverse: IHttpClient;
siteUrl: string; siteUrl: string;
dataverseUrl: string; dataverseUrl: string;
userId: number | undefined; /** Id пользователя на сайте со списками (не на сайте страницы) — см. IAppServices.getCurrentUserId. */
getCurrentUserId: () => Promise<number>;
} }
/** Справочники для подписей карточек: компания по Id, путь объекта. */ /** Справочники для подписей карточек: компания по Id, путь объекта. */
@@ -49,7 +50,8 @@ export async function loadReportList(
projectId: number, projectId: number,
range: IDateRange range: IDateRange
): Promise<ReportListInitResult> { ): Promise<ReportListInitResult> {
const accessResult = await fetchReportAccess(deps.sharePoint, deps.siteUrl, deps.userId, projectId); const userId = await deps.getCurrentUserId();
const accessResult = await fetchReportAccess(deps.sharePoint, deps.siteUrl, userId, projectId);
if (!accessResult) return { kind: 'denied' }; if (!accessResult) return { kind: 'denied' };
const base = { kind: 'ready' as const, projectName: accessResult.projectName, access: accessResult.access }; const base = { kind: 'ready' as const, projectName: accessResult.projectName, access: accessResult.access };
@@ -149,7 +149,7 @@ describe('loadReportList', () => {
} }
} }
]); ]);
const deps = { sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, userId: 42 }; const deps = { sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, getCurrentUserId: async () => 42 };
const result = await loadReportList(deps, 5, WEEK); const result = await loadReportList(deps, 5, WEEK);
@@ -174,7 +174,7 @@ describe('loadReportList', () => {
it('нет доступа — denied, справочники не грузятся', async () => { it('нет доступа — denied, справочники не грузятся', async () => {
const sharePoint = createFakeHttpClient([{ match: 'Доступы', response: { body: { value: [] } } }]); const sharePoint = createFakeHttpClient([{ match: 'Доступы', response: { body: { value: [] } } }]);
const dataverse = createFakeHttpClient([]); const dataverse = createFakeHttpClient([]);
const result = await loadReportList({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, userId: 42 }, 5, WEEK); const result = await loadReportList({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, getCurrentUserId: async () => 42 }, 5, WEEK);
expect(result).toEqual({ kind: 'denied' }); expect(result).toEqual({ kind: 'denied' });
expect(sharePoint.requests).toHaveLength(1); expect(sharePoint.requests).toHaveLength(1);
}); });
@@ -182,7 +182,7 @@ describe('loadReportList', () => {
it('ошибка отчётов — экран с «Ошибка инициализации: …» и без карточек', async () => { it('ошибка отчётов — экран с «Ошибка инициализации: …» и без карточек', async () => {
const sharePoint = createFakeHttpClient([access, companies, objects]); const sharePoint = createFakeHttpClient([access, companies, objects]);
const dataverse = createFakeHttpClient([{ match: 'cree9_reports', response: { status: 500, body: 'fail' } }]); const dataverse = createFakeHttpClient([{ match: 'cree9_reports', response: { status: 500, body: 'fail' } }]);
const result = await loadReportList({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, userId: 42 }, 5, WEEK); const result = await loadReportList({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, getCurrentUserId: async () => 42 }, 5, WEEK);
expect(result).toMatchObject({ expect(result).toMatchObject({
kind: 'ready', kind: 'ready',
reports: undefined, reports: undefined,
@@ -26,7 +26,7 @@ export interface IReportListState {
/** Данные страницы отчётов: первая загрузка, «Применить» и удаление. */ /** Данные страницы отчётов: первая загрузка, «Применить» и удаление. */
export function useReportList(projectId: number, initialRange: IDateRange): IReportListState { export function useReportList(projectId: number, initialRange: IDateRange): IReportListState {
const { sharePoint, dataverse, settings, page } = useAppServices(); const { sharePoint, dataverse, settings, getCurrentUserId } = useAppServices();
const [phase, setPhase] = React.useState<ReportListPhase>('loading'); const [phase, setPhase] = React.useState<ReportListPhase>('loading');
const [projectName, setProjectName] = React.useState<string>(''); const [projectName, setProjectName] = React.useState<string>('');
const [access, setAccess] = React.useState<IReportAccess>(NO_ACCESS); const [access, setAccess] = React.useState<IReportAccess>(NO_ACCESS);
@@ -44,9 +44,9 @@ export function useReportList(projectId: number, initialRange: IDateRange): IRep
dataverse, dataverse,
siteUrl: settings.siteUrl, siteUrl: settings.siteUrl,
dataverseUrl: settings.dataverseUrl, dataverseUrl: settings.dataverseUrl,
userId: page.userId getCurrentUserId
}), }),
[sharePoint, dataverse, settings.siteUrl, settings.dataverseUrl, page.userId] [sharePoint, dataverse, settings.siteUrl, settings.dataverseUrl, getCurrentUserId]
); );
React.useEffect(() => { React.useEffect(() => {
@@ -3,6 +3,7 @@ import {
assignedItemsUrl, assignedItemsUrl,
assignedItemUrl, assignedItemUrl,
deleteAssignedResource, deleteAssignedResource,
existingAssignmentUrl,
fetchAllItems, fetchAllItems,
fetchProjectResources, fetchProjectResources,
poolItemsUrl, poolItemsUrl,
@@ -105,13 +106,29 @@ describe('fetchProjectResources', () => {
}); });
describe('addAssignedResource', () => { describe('addAssignedResource', () => {
/** Проверка на дубль: привязки ещё нет. */
const notAssignedYet = { match: '$top=1', response: { body: { value: [] } } };
it('сначала ищет существующую привязку этого элемента к проекту', () => {
expect(existingAssignmentUrl(SITE, 'professions', '5', 12)).toEqual(
`${items('Профессии по проекту')}?$select=Id&$top=1&$filter=projectIdId eq 5 and professionIdId eq 12`
);
});
it('привязка уже есть (добавили в другой вкладке) — новая не создаётся, возвращается её Id', async () => {
const client = createFakeHttpClient([{ match: '$top=1', response: { body: { value: [{ Id: 77 }] } } }]);
expect(await addAssignedResource(client, SITE, 'professions', '5', { id: 12, title: 'Бетонщик' })).toEqual(77);
expect(client.requests.some((r) => r.request.method === 'POST')).toBe(false);
});
it('профессия: POST в items с телом как в assigner, возвращает Id', async () => { it('профессия: POST в items с телом как в assigner, возвращает Id', async () => {
const client = createFakeHttpClient([{ match: 'items', response: { status: 201, body: { Id: 101 } } }]); const client = createFakeHttpClient([notAssignedYet, { match: 'items', response: { status: 201, body: { Id: 101 } } }]);
const id = await addAssignedResource(client, SITE, 'professions', '5', { id: 12, title: 'Бетонщик' }); const id = await addAssignedResource(client, SITE, 'professions', '5', { id: 12, title: 'Бетонщик' });
expect(id).toEqual(101); expect(id).toEqual(101);
expect(client.requests[0]).toEqual({ expect(client.requests[1]).toEqual({
url: items('Профессии по проекту'), url: items('Профессии по проекту'),
request: { request: {
method: 'POST', method: 'POST',
@@ -126,16 +143,16 @@ describe('addAssignedResource', () => {
}); });
it('компания: поле companyIdId', async () => { it('компания: поле companyIdId', async () => {
const client = createFakeHttpClient([{ match: 'items', response: { status: 201, body: { Id: 7 } } }]); const client = createFakeHttpClient([notAssignedYet, { match: 'items', response: { status: 201, body: { Id: 7 } } }]);
await addAssignedResource(client, SITE, 'companies', '5', { id: 3, title: 'ТОО Строй' }); await addAssignedResource(client, SITE, 'companies', '5', { id: 3, title: 'ТОО Строй' });
expect(client.requests[0].url).toEqual(items('Компании по проекту')); expect(client.requests[1].url).toEqual(items('Компании по проекту'));
expect(client.requests[0].request.body).toEqual('{"Title":"ТОО Строй","projectIdId":5,"companyIdId":3}'); expect(client.requests[1].request.body).toEqual('{"Title":"ТОО Строй","projectIdId":5,"companyIdId":3}');
}); });
it('ошибка сервера пробрасывается', async () => { it('ошибка сервера пробрасывается', async () => {
const client = createFakeHttpClient([{ match: 'items', response: { status: 500, body: 'boom' } }]); const client = createFakeHttpClient([notAssignedYet, { match: 'items', response: { status: 500, body: 'boom' } }]);
await expect(addAssignedResource(client, SITE, 'companies', '5', { id: 3, title: 'X' })).rejects.toThrow( await expect(addAssignedResource(client, SITE, 'companies', '5', { id: 3, title: 'X' })).rejects.toThrow(
'500' '500'
); );
@@ -130,7 +130,20 @@ export async function fetchProjectResources(
}; };
} }
/** Создаёт привязку и возвращает id новой записи. */ /** Уже существующая привязка этого элемента к проекту (`{поле}Id` — id из lookup). */
export function existingAssignmentUrl(siteUrl: string, kind: ResourceKind, projectId: string, itemId: number): string {
const { assignedList, lookupField } = RESOURCE_LISTS[kind];
return (
`${listItemsUrl(siteUrl, assignedList)}?$select=Id&$top=1` +
`&$filter=${PROJECT_LOOKUP_ID_FIELD} eq ${Number(projectId)} and ${lookupField}Id eq ${itemId}`
);
}
/**
* Создаёт привязку и возвращает её id. Если такая привязка уже есть (её создали в другой вкладке
* или другой пользователь, пока у нас был открыт старый список), новая не создаётся — возвращается
* id существующей. В старом assigner такой проверки не было, и получались дубли.
*/
export async function addAssignedResource( export async function addAssignedResource(
client: IHttpClient, client: IHttpClient,
siteUrl: string, siteUrl: string,
@@ -138,6 +151,14 @@ export async function addAssignedResource(
projectId: string, projectId: string,
item: IResourceItem item: IResourceItem
): Promise<number> { ): Promise<number> {
const existing = await getJson<{ value: Array<{ Id: number }> }>(
client,
existingAssignmentUrl(siteUrl, kind, projectId, item.id)
);
if (existing.value.length > 0) {
return existing.value[0].Id;
}
const body = { const body = {
Title: item.title, Title: item.title,
[PROJECT_LOOKUP_ID_FIELD]: Number(projectId), [PROJECT_LOOKUP_ID_FIELD]: Number(projectId),
@@ -0,0 +1,15 @@
import * as React from 'react';
import { Badge } from '@tnsu/ui-kit';
import { isNormalAssessment } from './weatherFormat';
import { Nullable } from './weatherTypes';
import { EmptyValue } from './EmptyValue';
interface Props {
assessment: Nullable<string>;
}
/** Оценка ИИ: «В пределах нормы» — зелёная, любая другая — предупреждение. До оценки — прочерк. */
export const AssessmentBadge: React.FC<Props> = ({ assessment }) => {
if (!assessment) return <EmptyValue />;
return <Badge variant={isNormalAssessment(assessment) ? 'success' : 'warning'}>{assessment}</Badge>;
};
@@ -0,0 +1,26 @@
import * as React from 'react';
import { Badge } from '@tnsu/ui-kit';
import { IWeatherPosition } from './weatherTypes';
import { EmptyValue } from './EmptyValue';
import { WEATHER_TEXTS } from './texts';
import styles from './WeatherPage.module.scss';
const TEXTS = WEATHER_TEXTS.positions;
interface Props {
position: IWeatherPosition;
}
/** «Акт простоя»: рекомендован (с причиной) / не требуется / нет оценки. */
export const DowntimeActCell: React.FC<Props> = ({ position }) => {
if (position.downtime_act_recommended === false) {
return <Badge variant="success">{TEXTS.downtimeNotRequired}</Badge>;
}
if (position.downtime_act_recommended !== true) return <EmptyValue />;
return (
<>
<Badge variant="danger">{TEXTS.downtimeRecommended}</Badge>
{position.downtime_act_reason && <div className={styles.reason}>{position.downtime_act_reason}</div>}
</>
);
};
@@ -0,0 +1,6 @@
import * as React from 'react';
import { WEATHER_TEXTS } from './texts';
import styles from './WeatherPage.module.scss';
/** Серый прочерк вместо пустого значения в таблицах сверки. */
export const EmptyValue: React.FC = () => <span className={styles.muted}>{WEATHER_TEXTS.empty}</span>;
@@ -0,0 +1,43 @@
import * as React from 'react';
import { formatWeatherDate, formatWeatherNumber } from './weatherFormat';
import { IWeatherPosition } from './weatherTypes';
import { PositionWeatherCell } from './PositionWeatherCell';
import { AssessmentBadge } from './AssessmentBadge';
import { DowntimeActCell } from './DowntimeActCell';
import { EmptyValue } from './EmptyValue';
import { WEATHER_TEXTS } from './texts';
import styles from './WeatherPage.module.scss';
interface Props {
position: IWeatherPosition;
}
/** Строка таблицы «Позиции и оценка условий труда». */
export const PositionRow: React.FC<Props> = ({ position }) => (
<tr>
<td className={styles.nowrap}>{formatWeatherDate(position.date)}</td>
<td>
<div className={styles.strong}>{position.profession || WEATHER_TEXTS.empty}</div>
{position.work_type && <div className={styles.muted}>{position.work_type}</div>}
</td>
<td className={styles.nowrap}>
<div>{position.shift || WEATHER_TEXTS.empty}</div>
<div className={styles.muted}>
{position.shift_start || ''}–{position.shift_end || ''}
</div>
</td>
<td className={styles.num}>{formatWeatherNumber(position.hours)}</td>
<td>{position.hours_type || <EmptyValue />}</td>
<td className={styles.num}>{formatWeatherNumber(position.headcount)}</td>
<td>
<PositionWeatherCell weather={position.weather} />
</td>
<td>
<AssessmentBadge assessment={position.assessment} />
</td>
<td className={styles.comment}>{position.comment || <EmptyValue />}</td>
<td>
<DowntimeActCell position={position} />
</td>
</tr>
);
@@ -0,0 +1,35 @@
import * as React from 'react';
import { formatVisibility, formatWeatherNumber } from './weatherFormat';
import { IPositionWeather, Nullable } from './weatherTypes';
import { WeatherPhenomena } from './WeatherPhenomena';
import { WEATHER_TEXTS } from './texts';
import styles from './WeatherPage.module.scss';
const TEXTS = WEATHER_TEXTS.positions.weather;
interface Props {
weather: Nullable<IPositionWeather>;
}
/** «Погода в смену»: сводка наблюдений за часы смены позиции. */
export const PositionWeatherCell: React.FC<Props> = ({ weather }) => {
if (!weather) {
return <span className={styles.muted}>{TEXTS.noData}</span>;
}
const hasGust = weather.gust_max !== null && weather.gust_max !== undefined;
return (
<div className={styles.weatherCell}>
<div>{TEXTS.temperature(formatWeatherNumber(weather.temp_min), formatWeatherNumber(weather.temp_max))}</div>
<div>
{TEXTS.wind(formatWeatherNumber(weather.wind_max))}
{hasGust && TEXTS.gust(formatWeatherNumber(weather.gust_max))}
</div>
<div>{TEXTS.precipitation(formatWeatherNumber(weather.precipitation))}</div>
<div>{TEXTS.dewpoint(formatWeatherNumber(weather.dewpoint_max))}</div>
<div>{TEXTS.visibility(formatVisibility(weather.visibility_min))}</div>
<div className={styles.muted}>{TEXTS.observations(formatWeatherNumber(weather.observations))}</div>
<WeatherPhenomena phenomena={weather.weather} />
</div>
);
};
@@ -0,0 +1,55 @@
import * as React from 'react';
import { formatWeatherDate, formatWeatherNumber } from './weatherFormat';
import { IWeatherDay } from './weatherTypes';
import { WeatherPhenomena } from './WeatherPhenomena';
import { WEATHER_TEXTS } from './texts';
import styles from './WeatherPage.module.scss';
const TEXTS = WEATHER_TEXTS.days;
const COLUMNS = TEXTS.columns;
interface Props {
days: IWeatherDay[];
}
/** «Погода по дням»: наблюдения метеостанции за каждый день периода. */
export const WeatherDaysTable: React.FC<Props> = ({ days }) => {
if (days.length === 0) {
return <div className={styles.state}>{TEXTS.empty}</div>;
}
return (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>{COLUMNS.date}</th>
<th className={styles.num}>{COLUMNS.observations}</th>
<th className={styles.num}>{COLUMNS.tempMin}</th>
<th className={styles.num}>{COLUMNS.tempMax}</th>
<th className={styles.num}>{COLUMNS.wind}</th>
<th className={styles.num}>{COLUMNS.gust}</th>
<th className={styles.num}>{COLUMNS.precipitation}</th>
<th>{COLUMNS.phenomena}</th>
</tr>
</thead>
<tbody>
{days.map((day) => (
<tr key={day.date}>
<td className={styles.nowrap}>{formatWeatherDate(day.date)}</td>
<td className={styles.num}>{formatWeatherNumber(day.observations)}</td>
<td className={styles.num}>{formatWeatherNumber(day.temp_min)}</td>
<td className={styles.num}>{formatWeatherNumber(day.temp_max)}</td>
<td className={styles.num}>{formatWeatherNumber(day.wind_max)}</td>
<td className={styles.num}>{formatWeatherNumber(day.gust_max)}</td>
<td className={styles.num}>{formatWeatherNumber(day.precipitation)}</td>
<td>
<WeatherPhenomena phenomena={day.weather} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
@@ -0,0 +1,41 @@
import * as React from 'react';
import { Icon } from '@tnsu/ui-kit';
import { formatWeatherPeriod } from './weatherFormat';
import { WEATHER_TEXTS } from './texts';
import styles from './WeatherPage.module.scss';
interface Props {
projectTitle: string;
start: string;
end: string;
onBack: () => void;
}
/** Ссылка «Разделы проекта», название проекта и период сверки. */
export const WeatherHeader: React.FC<Props> = ({ projectTitle, start, end, onBack }) => {
const period = formatWeatherPeriod(start, end);
return (
<>
<a
href="#"
className={styles.backLink}
onClick={(event) => {
event.preventDefault();
onBack();
}}
>
<Icon name="chevron-left" size="sm" /> {WEATHER_TEXTS.back}
</a>
<header className={styles.header}>
<div className={styles.headerLabel}>{WEATHER_TEXTS.headerLabel}</div>
<h2 className={styles.headerTitle}>{projectTitle}</h2>
{period && (
<div className={styles.headerMeta}>
{WEATHER_TEXTS.periodPrefix} {period}
</div>
)}
</header>
</>
);
};
@@ -0,0 +1,14 @@
import * as React from 'react';
import { Icon } from '@tnsu/ui-kit';
import styles from './WeatherPage.module.scss';
interface Props {
text: string;
}
/** Подсказка, почему сверку не загрузить (нет проекта / периода). */
export const WeatherHint: React.FC<Props> = ({ text }) => (
<div className={styles.hint}>
<Icon name="info" size="sm" /> {text}
</div>
);
@@ -0,0 +1,255 @@
@import '../../../../styles/design-tokens';
// Размеры из сверки погоды stroy-bot-main.
// Таблицы сверки широкие — странице даём больше места, чем главной.
$page-max-width: 1500px;
$station-icon-size: 40px;
$phenomenon-min-width: 160px;
$phenomenon-max-width: 240px;
$comment-min-width: 240px;
$comment-max-width: 380px;
.page {
display: flex;
flex-direction: column;
gap: $sp-5;
width: 100%;
max-width: $page-max-width;
}
.backLink {
display: inline-flex;
align-items: center;
align-self: flex-start;
gap: $sp-1;
font-size: $fs-sm;
font-weight: 500;
color: $gray-500;
text-decoration: none;
&:hover {
color: $gold-600;
}
}
.header {
@include page-header;
}
.headerLabel {
margin-bottom: $sp-1;
font-size: $fs-overline;
font-weight: 600;
letter-spacing: 1.6px;
text-transform: uppercase;
color: $gold-300;
}
.headerTitle {
margin: 0;
font-family: $font-serif;
font-size: $fs-page-title;
font-weight: 600;
line-height: 1.3;
color: $white;
}
.headerMeta {
margin-top: $sp-2;
font-size: $fs-sm;
color: rgba(255, 255, 255, 0.7); // белый с прозрачностью — второстепенный текст на тёмной шапке
}
.hint {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-3 $sp-4;
border: 1px solid $gold-100;
border-radius: $r-md;
background: $gold-50;
font-size: $fs-sm;
color: $amber-700;
}
.station {
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-3 $sp-4;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-md;
box-shadow: $sh-1;
}
.stationIcon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: $station-icon-size;
height: $station-icon-size;
border: 1px solid $navy-100;
border-radius: $r-md;
background: $navy-50;
color: $navy-700;
}
.stationName {
font-weight: 600;
color: $navy-900;
}
.section {
display: flex;
flex-direction: column;
gap: $sp-3;
}
.sectionRow {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: $sp-3;
}
.sectionLabel {
margin: 0;
font-family: $font-serif;
font-size: $fs-section-title;
font-weight: 600;
color: $navy-900;
}
.hairline {
@include hairline;
margin: 0;
}
.buttonContent {
display: inline-flex;
align-items: center;
gap: $sp-2;
}
.tableWrap {
overflow-x: auto;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-md;
box-shadow: $sh-1;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: $fs-sm;
th {
padding: $sp-2 $sp-3;
border-bottom: 1px solid $gray-200;
background: $gray-50;
font-size: $fs-caption;
font-weight: 600;
letter-spacing: 0.8px;
text-align: left;
text-transform: uppercase;
white-space: nowrap;
color: $gray-500;
}
td {
padding: $sp-2 $sp-3;
border-bottom: 1px solid $gray-100;
vertical-align: top;
color: $gray-900;
}
tbody tr:last-child td {
border-bottom: 0;
}
tbody tr:hover td {
background: $gold-50;
}
// Колонки с числами: выравнивание вправо, перебивает text-align: left у th.
.num {
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
}
.nowrap {
white-space: nowrap;
}
.strong {
font-weight: 600;
color: $navy-900;
}
.muted {
font-size: $fs-label;
color: $gray-400;
}
.weatherCell {
font-size: $fs-label;
line-height: 1.5;
white-space: nowrap;
}
.phenomenon {
min-width: $phenomenon-min-width;
max-width: $phenomenon-max-width;
margin-top: 2px;
font-size: $fs-caption;
white-space: normal;
color: $gray-500;
}
.comment {
min-width: $comment-min-width;
max-width: $comment-max-width;
line-height: 1.45;
}
.reason {
min-width: $phenomenon-min-width;
margin-top: $sp-1;
font-size: $fs-label;
color: $gray-700;
}
.state {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-5;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-md;
font-size: $fs-sm;
color: $gray-500;
}
.assessSummary {
font-size: $fs-sm;
color: $green-600;
}
.notes {
margin: 0;
padding-left: $sp-4;
font-size: $fs-sm;
line-height: 1.55;
color: $gray-700;
li + li {
margin-top: $sp-1;
}
}
@@ -0,0 +1,52 @@
import * as React from 'react';
import { Alert, Spinner } from '@tnsu/ui-kit';
import { useRouter } from '../../app/routing/RouterContext';
import { useProject } from '../../app/project/ProjectContext';
import { parseWeatherPeriod } from './weatherFormat';
import { useWeatherCheck } from './useWeatherCheck';
import { useWeatherAssessment } from './useWeatherAssessment';
import { WeatherHeader } from './WeatherHeader';
import { WeatherReport } from './WeatherReport';
import { WeatherHint } from './WeatherHint';
import { WEATHER_TEXTS } from './texts';
import styles from './WeatherPage.module.scss';
/**
* Сверка погодных условий (из stroy-bot-main): `?page=weather&projectId=&start=YYYY-MM-DD&end=YYYY-MM-DD`.
* В main это был отдельный вид той же web part по хэшу #weathercheck.
*/
export const WeatherPage: React.FC = () => {
const { route, navigate } = useRouter();
const { currentProject, currentProjectId } = useProject();
const period = parseWeatherPeriod(route.start, route.end);
const { status, setData } = useWeatherCheck(currentProject?.id, period);
const data = status.kind === 'ready' ? status.data : undefined;
const assessment = useWeatherAssessment(data, setData);
// Назад — на главную с теми же датами, чтобы не выбирать период заново (как в main).
const goBack = (): void => navigate({ page: 'hub', projectId: currentProjectId, start: route.start, end: route.end });
const renderBody = (): React.ReactNode => {
if (!currentProject) return <WeatherHint text={WEATHER_TEXTS.noProject} />;
if (!period) return <WeatherHint text={WEATHER_TEXTS.noPeriod} />;
if (status.kind === 'error') return <Alert variant="err">{status.message}</Alert>;
if (status.kind === 'ready') return <WeatherReport data={status.data} assessment={assessment} />;
return (
<div className={styles.state}>
<Spinner label={WEATHER_TEXTS.loading} />
</div>
);
};
return (
<div className={styles.page}>
<WeatherHeader
projectTitle={data?.project || currentProject?.title || ''}
start={data?.start_date || period?.start || ''}
end={data?.end_date || period?.end || ''}
onBack={goBack}
/>
{renderBody()}
</div>
);
};
@@ -0,0 +1,21 @@
import * as React from 'react';
import { Nullable } from './weatherTypes';
import styles from './WeatherPage.module.scss';
interface Props {
phenomena: Nullable<string[]> | undefined;
}
/** Погодные явления (дождь, туман…) — каждое отдельной плашкой. */
export const WeatherPhenomena: React.FC<Props> = ({ phenomena }) => {
if (!phenomena || phenomena.length === 0) return null;
return (
<>
{phenomena.map((phenomenon, index) => (
<div key={`${index}-${phenomenon}`} className={styles.phenomenon}>
{phenomenon}
</div>
))}
</>
);
};
@@ -0,0 +1,74 @@
import * as React from 'react';
import { Alert, Button, Spinner } from '@tnsu/ui-kit';
import { InlineIcon } from '../../shared/components/InlineIcon';
import { IWeatherPosition } from './weatherTypes';
import { IWeatherAssessment } from './useWeatherAssessment';
import { PositionRow } from './PositionRow';
import { WEATHER_TEXTS } from './texts';
import styles from './WeatherPage.module.scss';
const TEXTS = WEATHER_TEXTS.positions;
const COLUMNS = TEXTS.columns;
interface Props {
positions: IWeatherPosition[];
assessment: IWeatherAssessment;
}
/** «Позиции и оценка условий труда»: кнопка оценки ИИ, её итог и таблица позиций. */
export const WeatherPositionsSection: React.FC<Props> = ({ positions, assessment }) => {
const hasPositions = positions.length > 0;
const { isAssessing, errorMessage, summary, requestAssessment } = assessment;
return (
<section className={styles.section}>
<div className={styles.sectionRow}>
<h3 className={styles.sectionLabel}>{TEXTS.title}</h3>
<Button variant="secondary" onClick={requestAssessment} disabled={!hasPositions || isAssessing}>
{isAssessing ? (
<Spinner size="xSmall" label={TEXTS.assessLoading} />
) : (
<span className={styles.buttonContent}>
<InlineIcon name="sparkle" />
{TEXTS.assessButton}
</span>
)}
</Button>
</div>
<hr className={styles.hairline} />
{errorMessage && <Alert variant="err">{errorMessage}</Alert>}
{!errorMessage && summary && (
<div className={styles.assessSummary}>{TEXTS.assessSummary(summary.assessed, summary.skipped)}</div>
)}
{!hasPositions && <div className={styles.state}>{TEXTS.empty}</div>}
{hasPositions && (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>{COLUMNS.date}</th>
<th>{COLUMNS.profession}</th>
<th>{COLUMNS.shift}</th>
<th className={styles.num}>{COLUMNS.hours}</th>
<th>{COLUMNS.hoursType}</th>
<th className={styles.num}>{COLUMNS.headcount}</th>
<th>{COLUMNS.weather}</th>
<th>{COLUMNS.assessment}</th>
<th>{COLUMNS.comment}</th>
<th>{COLUMNS.downtime}</th>
</tr>
</thead>
<tbody>
{positions.map((position, index) => (
// У позиции нет id: строка — профессия в смену, порядок задаёт API.
<PositionRow key={`${position.date}-${position.shift}-${position.profession}-${index}`} position={position} />
))}
</tbody>
</table>
</div>
)}
</section>
);
};
@@ -0,0 +1,43 @@
import * as React from 'react';
import { IWeatherCheck } from './weatherTypes';
import { IWeatherAssessment } from './useWeatherAssessment';
import { WeatherStation } from './WeatherStation';
import { WeatherDaysTable } from './WeatherDaysTable';
import { WeatherPositionsSection } from './WeatherPositionsSection';
import { WEATHER_TEXTS } from './texts';
import styles from './WeatherPage.module.scss';
interface Props {
data: IWeatherCheck;
assessment: IWeatherAssessment;
}
/** Загруженная сверка: метеостанция, погода по дням, позиции с оценкой, примечания API. */
export const WeatherReport: React.FC<Props> = ({ data, assessment }) => {
const notes = data.notes || [];
return (
<>
<WeatherStation data={data} />
<section className={styles.section}>
<h3 className={styles.sectionLabel}>{WEATHER_TEXTS.days.title}</h3>
<hr className={styles.hairline} />
<WeatherDaysTable days={data.days || []} />
</section>
<WeatherPositionsSection positions={data.positions || []} assessment={assessment} />
{notes.length > 0 && (
<section className={styles.section}>
<h3 className={styles.sectionLabel}>{WEATHER_TEXTS.notesTitle}</h3>
<hr className={styles.hairline} />
<ul className={styles.notes}>
{notes.map((note, index) => (
<li key={`${index}-${note}`}>{note}</li>
))}
</ul>
</section>
)}
</>
);
};
@@ -0,0 +1,35 @@
import * as React from 'react';
import { InlineIcon } from '../../shared/components/InlineIcon';
import { formatWeatherNumber } from './weatherFormat';
import { IWeatherCheck } from './weatherTypes';
import { WEATHER_TEXTS } from './texts';
import styles from './WeatherPage.module.scss';
const TEXTS = WEATHER_TEXTS.station;
interface Props {
data: IWeatherCheck;
}
/** Метеостанция, по которой сверяется погода, и её удалённость от объекта. */
export const WeatherStation: React.FC<Props> = ({ data }) => {
const distance =
data.wigos_station_distance_km !== null && data.wigos_station_distance_km !== undefined
? TEXTS.distance(formatWeatherNumber(data.wigos_station_distance_km))
: '';
return (
<div className={styles.station}>
<span className={styles.stationIcon}>
<InlineIcon name="cloud" />
</span>
<div>
<div className={styles.stationName}>{TEXTS.name(data.wigos_station_name || WEATHER_TEXTS.empty)}</div>
<div className={styles.muted}>
{TEXTS.code(data.wigos_station || WEATHER_TEXTS.empty)}
{distance}
</div>
</div>
</div>
);
};
@@ -0,0 +1,66 @@
// Тексты сверки погодных условий — дословно из stroy-bot-main.
export const WEATHER_TEXTS = {
openButton: 'Сверка погодных условий',
back: 'Разделы проекта',
headerLabel: 'Сверка погодных условий',
periodPrefix: 'Период:',
noProject: 'Проект не выбран. Вернитесь назад и выберите проект.',
noPeriod: 'Период не указан. Вернитесь назад и выберите даты начала и конца.',
loading: 'Загрузка данных…',
loadError: (details: string): string => `Не удалось загрузить данные: ${details}`,
station: {
name: (name: string): string => `Метеостанция ${name}`,
code: (code: string): string => `WIGOS ${code}`,
distance: (km: string): string => ` · ${km} км от объекта`
},
days: {
title: 'Погода по дням',
empty: 'Нет данных наблюдений за период',
columns: {
date: 'Дата',
observations: 'Наблюдений',
tempMin: 'T мин, °C',
tempMax: 'T макс, °C',
wind: 'Ветер, м/с',
gust: 'Порывы, м/с',
precipitation: 'Осадки, мм',
phenomena: 'Явления'
}
},
positions: {
title: 'Позиции и оценка условий труда',
empty: 'Нет позиций за период',
assessButton: 'Запросить оценку ИИ по международным стандартам',
assessLoading: 'Идёт оценка, это может занять до минуты…',
assessError: (details: string): string => `Не удалось получить оценку: ${details}`,
assessSummary: (assessed: number, skipped: number): string =>
`Оценено позиций: ${assessed}` + (skipped ? `, пропущено: ${skipped}` : ''),
columns: {
date: 'Дата',
profession: 'Профессия / вид работ',
shift: 'Смена',
hours: 'Часы',
hoursType: 'Тип работ',
headcount: 'Чел.',
weather: 'Погода в смену',
assessment: 'Оценка',
comment: 'Комментарий',
downtime: 'Акт простоя'
},
weather: {
temperature: (min: string, max: string): string => `T ${min}…${max} °C`,
wind: (wind: string): string => `Ветер ${wind} м/с`,
gust: (gust: string): string => `, порывы ${gust} м/с`,
precipitation: (value: string): string => `Осадки ${value} мм`,
dewpoint: (value: string): string => `Точка росы до ${value} °C`,
visibility: (value: string): string => `Видимость от ${value}`,
observations: (value: string): string => `Наблюдений: ${value}`,
noData: 'нет данных'
},
downtimeRecommended: 'Рекомендован',
downtimeNotRequired: 'Не требуется'
},
notesTitle: 'Примечания',
empty: '—'
} as const;
@@ -0,0 +1,77 @@
import * as React from 'react';
import { useAppServices } from '../../app/AppServicesContext';
import { requestWeatherAssessment } from './weatherRepository';
import { IWeatherCheck } from './weatherTypes';
import { weatherErrorDetails } from './useWeatherCheck';
import { WEATHER_TEXTS } from './texts';
export interface IWeatherAssessment {
isAssessing: boolean;
errorMessage: string;
summary: { assessed: number; skipped: number } | undefined;
requestAssessment: () => void;
}
/** Какая сверка на экране: другой проект или период — другой ключ. */
function loadKeyOf(data: IWeatherCheck | undefined): string {
return data ? `${data.project_id}|${data.start_date}|${data.end_date}` : '';
}
/**
* «Запросить оценку ИИ»: отправляет позиции и подменяет их ответом.
* Ответ, пришедший после ухода со страницы или для другого периода, игнорируется (как в main).
*/
export function useWeatherAssessment(
data: IWeatherCheck | undefined,
setData: (update: (data: IWeatherCheck) => IWeatherCheck) => void
): IWeatherAssessment {
const services = useAppServices();
const [isAssessing, setIsAssessing] = React.useState<boolean>(false);
const [errorMessage, setErrorMessage] = React.useState<string>('');
const [summary, setSummary] = React.useState<IWeatherAssessment['summary']>(undefined);
const loadKey = loadKeyOf(data);
const loadKeyRef = React.useRef<string>(loadKey);
loadKeyRef.current = loadKey;
// Новая сверка — итоги прошлой оценки к ней не относятся.
React.useEffect(() => {
setIsAssessing(false);
setErrorMessage('');
setSummary(undefined);
}, [loadKey]);
// После ухода со страницы ключ пустой — поздний ответ не тронет state.
React.useEffect(
() => () => {
loadKeyRef.current = '';
},
[]
);
const requestAssessment = (): void => {
if (!data || data.positions.length === 0 || isAssessing) return;
const requestedKey = loadKey;
const isStillCurrent = (): boolean => loadKeyRef.current === requestedKey;
setIsAssessing(true);
setErrorMessage('');
requestWeatherAssessment(services.tnsuApi, services.settings.apiBaseUrl, data.positions)
.then((result) => {
if (!isStillCurrent()) return;
if (result.positions && result.positions.length > 0) {
setData((prev) => ({ ...prev, positions: result.positions }));
}
setSummary({ assessed: result.assessed, skipped: result.skipped });
})
.catch((error: unknown) => {
console.error('Ошибка оценки ИИ:', error);
if (isStillCurrent()) setErrorMessage(WEATHER_TEXTS.positions.assessError(weatherErrorDetails(error)));
})
.then(() => {
if (isStillCurrent()) setIsAssessing(false);
})
.catch(() => undefined);
};
return { isAssessing, errorMessage, summary, requestAssessment };
}
@@ -0,0 +1,58 @@
import * as React from 'react';
import { useAppServices } from '../../app/AppServicesContext';
import { HttpError } from '../../services/http/IHttpClient';
import { fetchWeatherCheck } from './weatherRepository';
import { IWeatherCheck, IWeatherPeriod } from './weatherTypes';
import { WEATHER_TEXTS } from './texts';
export type WeatherLoadStatus =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'error'; message: string }
| { kind: 'ready'; data: IWeatherCheck };
/** Текст ошибки как в main: «статус текст» для ответа API, иначе сообщение ошибки. */
export function weatherErrorDetails(error: unknown): string {
if (error instanceof HttpError) return `${error.status} ${error.statusText}`;
return error instanceof Error ? error.message : String(error);
}
/**
* Загрузка сверки погоды за период. Без проекта или периода — ничего не грузит (idle).
* setData — для замены позиций после оценки ИИ.
*/
export function useWeatherCheck(
projectId: number | undefined,
period: IWeatherPeriod | undefined
): { status: WeatherLoadStatus; setData: (update: (data: IWeatherCheck) => IWeatherCheck) => void } {
const services = useAppServices();
const [status, setStatus] = React.useState<WeatherLoadStatus>({ kind: 'idle' });
const start = period?.start;
const end = period?.end;
React.useEffect(() => {
if (projectId === undefined || !start || !end) {
setStatus({ kind: 'idle' });
return;
}
let isCancelled = false;
setStatus({ kind: 'loading' });
fetchWeatherCheck(services.tnsuApi, services.settings.apiBaseUrl, projectId, { start, end })
.then((data) => {
if (!isCancelled) setStatus({ kind: 'ready', data });
})
.catch((error: unknown) => {
console.error('Ошибка загрузки сверки погоды:', error);
if (!isCancelled) setStatus({ kind: 'error', message: WEATHER_TEXTS.loadError(weatherErrorDetails(error)) });
});
return () => {
isCancelled = true;
};
}, [services, projectId, start, end]);
const setData = React.useCallback((update: (data: IWeatherCheck) => IWeatherCheck): void => {
setStatus((prev) => (prev.kind === 'ready' ? { kind: 'ready', data: update(prev.data) } : prev));
}, []);
return { status, setData };
}
@@ -0,0 +1,56 @@
import {
formatVisibility,
formatWeatherDate,
formatWeatherNumber,
formatWeatherPeriod,
isNormalAssessment,
parseWeatherPeriod
} from './weatherFormat';
describe('parseWeatherPeriod', () => {
it('корректный период', () => {
expect(parseWeatherPeriod('2026-10-01', '2026-10-07')).toEqual({ start: '2026-10-01', end: '2026-10-07' });
expect(parseWeatherPeriod('2026-10-01', '2026-10-01')).toEqual({ start: '2026-10-01', end: '2026-10-01' });
});
it('нет даты, неверный формат или начало позже конца — периода нет', () => {
expect(parseWeatherPeriod(undefined, '2026-10-07')).toBeUndefined();
expect(parseWeatherPeriod('01.10.2026', '2026-10-07')).toBeUndefined();
expect(parseWeatherPeriod('2026-10-08', '2026-10-07')).toBeUndefined();
});
});
describe('форматирование', () => {
it('дата ДД.ММ.ГГГГ, хвост со временем отбрасывается, мусор — как есть', () => {
expect(formatWeatherDate('2026-10-05')).toEqual('05.10.2026');
expect(formatWeatherDate('2026-10-05T08:00:00')).toEqual('05.10.2026');
expect(formatWeatherDate('вчера')).toEqual('вчера');
expect(formatWeatherDate('')).toEqual('—');
});
it('период: один день — одна дата', () => {
expect(formatWeatherPeriod('2026-10-01', '2026-10-07')).toEqual('01.10.2026 — 07.10.2026');
expect(formatWeatherPeriod('2026-10-01', '2026-10-01')).toEqual('01.10.2026');
expect(formatWeatherPeriod('', '')).toEqual('');
});
it('число с запятой, пустое — прочерк, ноль — ноль', () => {
expect(formatWeatherNumber(-3.5)).toEqual('-3,5');
expect(formatWeatherNumber(0)).toEqual('0');
expect(formatWeatherNumber(null)).toEqual('—');
expect(formatWeatherNumber(undefined)).toEqual('—');
});
it('видимость: от 1000 м — км с одним знаком', () => {
expect(formatVisibility(800)).toEqual('800 м');
expect(formatVisibility(1000)).toEqual('1 км');
expect(formatVisibility(1450)).toEqual('1,5 км');
expect(formatVisibility(10000)).toEqual('10 км');
expect(formatVisibility(null)).toEqual('—');
});
it('«В пределах нормы» — норма, без учёта регистра и пробелов', () => {
expect(isNormalAssessment(' В пределах нормы ')).toBe(true);
expect(isNormalAssessment('Неблагоприятные условия')).toBe(false);
});
});
@@ -0,0 +1,45 @@
import { IWeatherPeriod, Nullable } from './weatherTypes';
// Форматирование значений сверки погоды — как в stroy-bot-main.
const EMPTY_VALUE = '—';
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const METERS_PER_KM = 1000;
const NORMAL_ASSESSMENT = 'в пределах нормы';
/** Период из адреса. undefined — дат нет, формат не YYYY-MM-DD или начало позже конца. */
export function parseWeatherPeriod(start: string | undefined, end: string | undefined): IWeatherPeriod | undefined {
if (!start || !end || !ISO_DATE.test(start) || !ISO_DATE.test(end) || start > end) {
return undefined;
}
return { start, end };
}
/** YYYY-MM-DD… → ДД.ММ.ГГГГ. Нераспознанное значение показывается как есть. */
export function formatWeatherDate(iso: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso || '');
return match ? `${match[3]}.${match[2]}.${match[1]}` : iso || EMPTY_VALUE;
}
/** «01.10.2026 — 07.10.2026», один день — одна дата. */
export function formatWeatherPeriod(start: string, end: string): string {
if (!start) return '';
return end && end !== start ? `${formatWeatherDate(start)} — ${formatWeatherDate(end)}` : formatWeatherDate(start);
}
/** Число с запятой, пустое значение — прочерк. */
export function formatWeatherNumber(value: Nullable<number> | undefined): string {
return value === null || value === undefined ? EMPTY_VALUE : String(value).replace('.', ',');
}
/** Видимость: от 1000 м — в километрах с одним знаком (1450 → «1,5 км»), меньше — в метрах. */
export function formatVisibility(meters: Nullable<number> | undefined): string {
if (meters === null || meters === undefined) return EMPTY_VALUE;
if (meters < METERS_PER_KM) return `${meters} м`;
const kmWithOneDecimal = Math.round(meters / 100) / 10;
return `${formatWeatherNumber(kmWithOneDecimal)} км`;
}
export function isNormalAssessment(assessment: string): boolean {
return assessment.trim().toLowerCase() === NORMAL_ASSESSMENT;
}

Some files were not shown because too many files have changed in this diff Show More