Compare commits

..

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

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