Compare commits
10 Commits
b18cf50efd
...
10ba9a6623
| Author | SHA1 | Date | |
|---|---|---|---|
| 10ba9a6623 | |||
| ee282b61b7 | |||
| a98162fce7 | |||
| 18c2959820 | |||
| db8565b0d3 | |||
| 1ba67fe4c1 | |||
| eb646193fa | |||
| 0ae52ccd6b | |||
| df4998c75e | |||
| d606f8371c |
@@ -75,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=`
|
||||
@@ -201,7 +223,7 @@
|
||||
- Окно выбора на экране шире 640px — по центру (было всегда прижато к низу), поле «Значение» не вылезает за карточку.
|
||||
- После «Утвердить отчет» бейдж оставался «На утверждении» до F5 — теперь сразу «Утверждён».
|
||||
- «← Вернуться назад» во вкладке, открытой из списка отчётов (истории нет), ничего не делал — теперь ведёт в «Отчёты по проекту».
|
||||
- Номер отчёта повторялся после мягкого удаления (номер = количество неудалённых + 1), а вместе с ним и папка фото `{projectId}_{номер}`. Теперь номер = наибольший номер в проекте, включая удалённые, + 1. Как нумерует отчёты Telegram-бот — уточнить у команды API.
|
||||
- Номер отчёта повторялся после мягкого удаления (номер = количество неудалённых + 1), а вместе с ним и папка фото `{projectId}_{номер}`. Теперь номер = наибольший номер в проекте, включая удалённые, + 1.
|
||||
|
||||
**Сознательные отличия от report:**
|
||||
- `confirm('Утвердить отчет? …')` → `confirmModal` из ui-kit, текст тот же.
|
||||
|
||||
@@ -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** Поиск слева, «✕» очищает; «уже добавлено» у привязанных.
|
||||
@@ -166,6 +177,36 @@
|
||||
- [ ] **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. Только просмотр, ничего не сохранять.
|
||||
|
||||
Generated
+4
-4
@@ -17,7 +17,7 @@
|
||||
"@microsoft/sp-office-ui-fabric-core": "1.22.1",
|
||||
"@microsoft/sp-property-pane": "1.22.1",
|
||||
"@microsoft/sp-webpart-base": "1.22.1",
|
||||
"@tnsu/ui-kit": "^0.12.0",
|
||||
"@tnsu/ui-kit": "^0.13.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.13.0",
|
||||
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.13.0/ui-kit-0.13.0.tgz",
|
||||
"integrity": "sha512-FfIfPIXQyDwEb5v8n7EIxfn5V9CYURP98veTK9XUEplq/1lAJF8CXpVDZ/6i1DBOiNqSSs/zGMrydphXw6IMzQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@fluentui/react": "^8.106.4",
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
"@microsoft/sp-office-ui-fabric-core": "1.22.1",
|
||||
"@microsoft/sp-property-pane": "1.22.1",
|
||||
"@microsoft/sp-webpart-base": "1.22.1",
|
||||
"@tnsu/ui-kit": "^0.12.0",
|
||||
"@tnsu/ui-kit": "^0.13.0",
|
||||
"react": "17.0.1",
|
||||
"react-dom": "17.0.1",
|
||||
"tslib": "2.3.1"
|
||||
|
||||
@@ -8,6 +8,9 @@ import { ReportPage } from '../pages/report/ReportPage';
|
||||
import { ImportWorksPage } from '../pages/import/ImportWorksPage';
|
||||
import { ResourcesPage } from '../pages/resources/ResourcesPage';
|
||||
import { BinderPage } from '../pages/binder/BinderPage';
|
||||
import { WeatherPage } from '../pages/weather/WeatherPage';
|
||||
import { ReportImportPage } from '../pages/reportImport/ReportImportPage';
|
||||
import { WorksImportPage } from '../pages/worksImport/WorksImportPage';
|
||||
|
||||
/** Показывает страницу текущего раздела (раздел берётся из ?page=). */
|
||||
export const PageOutlet: React.FC = () => {
|
||||
@@ -28,6 +31,12 @@ export const PageOutlet: React.FC = () => {
|
||||
return <ResourcesPage />;
|
||||
case 'binder':
|
||||
return <BinderPage />;
|
||||
case 'weather':
|
||||
return <WeatherPage />;
|
||||
case 'reportImport':
|
||||
return <ReportImportPage />;
|
||||
case 'worksImport':
|
||||
return <WorksImportPage />;
|
||||
case 'hub':
|
||||
default:
|
||||
return <HubPage />;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { buildSidebarItems, navPageFor } from './navigation';
|
||||
import { buildSidebarItems, CATALOGS_GROUP, navPageFor } from './navigation';
|
||||
|
||||
describe('buildSidebarItems', () => {
|
||||
it('без проекта в меню только главная', () => {
|
||||
expect(buildSidebarItems(undefined).map((item) => item.id)).toEqual(['hub']);
|
||||
it('без проекта в меню только главная и справочники', () => {
|
||||
expect(buildSidebarItems(undefined).map((item) => item.id)).toEqual(['hub', 'catalogs']);
|
||||
});
|
||||
|
||||
it('с проектом — все разделы', () => {
|
||||
@@ -11,15 +11,34 @@ describe('buildSidebarItems', () => {
|
||||
'objects',
|
||||
'reports',
|
||||
'report',
|
||||
'reportImport',
|
||||
'import',
|
||||
'worksImport',
|
||||
'resources',
|
||||
'binder'
|
||||
'binder',
|
||||
'catalogs'
|
||||
]);
|
||||
});
|
||||
|
||||
it('недоступные разделы скрываются, главная остаётся всегда', () => {
|
||||
it('недоступные разделы скрываются, главная и справочники остаются всегда', () => {
|
||||
const onlyReports = (page: string): boolean => page === 'reports';
|
||||
expect(buildSidebarItems('5', onlyReports).map((item) => item.id)).toEqual(['hub', 'reports']);
|
||||
expect(buildSidebarItems('5', onlyReports).map((item) => item.id)).toEqual(['hub', 'reports', 'catalogs']);
|
||||
});
|
||||
|
||||
it('справочники — группа из 8 ссылок на сайт StroyBot', () => {
|
||||
const links = CATALOGS_GROUP.children || [];
|
||||
expect(links.map((link) => link.title)).toEqual([
|
||||
'Проекты',
|
||||
'Компании',
|
||||
'Профессии',
|
||||
'Единицы измерения',
|
||||
'Техника',
|
||||
'Архив',
|
||||
'Инструкция',
|
||||
'Корзина'
|
||||
]);
|
||||
links.forEach((link) => expect(link.href).toMatch(/^https:\/\/tnsukz\.sharepoint\.com\/sites\/stroy\//));
|
||||
expect(links[2].href).toEqual('https://tnsukz.sharepoint.com/sites/stroy/Lists/List5/AllItems.aspx');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +47,10 @@ describe('navPageFor', () => {
|
||||
expect(navPageFor('works')).toEqual('objects');
|
||||
});
|
||||
|
||||
it('сверка погоды подсвечивает главную', () => {
|
||||
expect(navPageFor('weather')).toEqual('hub');
|
||||
});
|
||||
|
||||
it('остальные разделы подсвечивают сами себя', () => {
|
||||
expect(navPageFor('reports')).toEqual('reports');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IconName, ISidebarItem } from '@tnsu/ui-kit';
|
||||
import { PageId } from './routing/routes';
|
||||
import { CATALOG_LINKS } from '../config/catalogLinks';
|
||||
|
||||
export interface INavItem {
|
||||
page: PageId;
|
||||
@@ -15,25 +16,44 @@ export const NAV_ITEMS: INavItem[] = [
|
||||
{ page: 'objects', title: 'Объекты', icon: 'folder', needsProject: true },
|
||||
{ page: 'reports', title: 'Отчёты по проекту', icon: 'clipboard', needsProject: true },
|
||||
{ page: 'report', title: 'Отправить отчёт', icon: 'send', needsProject: true },
|
||||
{ page: 'reportImport', title: 'Импорт отчёта из Excel', icon: 'upload', needsProject: true },
|
||||
{ page: 'import', title: 'Загрузить работы из сметы', icon: 'download', needsProject: true },
|
||||
{ page: 'worksImport', title: 'Импорт работ из Excel', icon: 'document', needsProject: true },
|
||||
{ page: 'resources', title: 'Ресурсы проекта', icon: 'people', needsProject: true },
|
||||
{ page: 'binder', title: 'Привязка к справочнику', icon: 'swap', needsProject: true }
|
||||
];
|
||||
|
||||
/** Пункт меню, который подсвечивается для раздела. Работы — подраздел объектов, форма отчёта — свой пункт. */
|
||||
/**
|
||||
* Пункт меню, который подсвечивается для раздела. Работы — подраздел объектов,
|
||||
* сверка погоды открывается с главной, форма отчёта — свой пункт.
|
||||
*/
|
||||
export function navPageFor(page: PageId): PageId {
|
||||
return page === 'works' ? 'objects' : page;
|
||||
if (page === 'works') return 'objects';
|
||||
if (page === 'weather') return 'hub';
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Пункты меню для текущего состояния. Без проекта — только главная.
|
||||
* «Справочники» — раскрывающаяся группа ссылок на списки сайта (новая вкладка).
|
||||
* Разделом приложения не является: onChange меню её не получает. Права проверяет SharePoint.
|
||||
*/
|
||||
export const CATALOGS_GROUP: ISidebarItem = {
|
||||
id: 'catalogs',
|
||||
title: 'Справочники',
|
||||
icon: 'info',
|
||||
children: CATALOG_LINKS.map((link) => ({ id: link.id, title: link.title, href: link.url }))
|
||||
};
|
||||
|
||||
/**
|
||||
* Пункты меню для текущего состояния. Без проекта — только главная и справочники.
|
||||
* isAvailable — правила доступа к разделу (статус проекта, права); по умолчанию всё доступно.
|
||||
*/
|
||||
export function buildSidebarItems(
|
||||
projectId: string | undefined,
|
||||
isAvailable: (page: PageId) => boolean = () => true
|
||||
): ISidebarItem[] {
|
||||
return NAV_ITEMS.filter((item) => !item.needsProject || (projectId !== undefined && isAvailable(item.page))).map(
|
||||
(item) => ({ id: item.page, title: item.title, icon: item.icon })
|
||||
);
|
||||
const sections = NAV_ITEMS.filter(
|
||||
(item) => !item.needsProject || (projectId !== undefined && isAvailable(item.page))
|
||||
).map((item) => ({ id: item.page, title: item.title, icon: item.icon }));
|
||||
return [...sections, CATALOGS_GROUP];
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
window.history.pushState(undefined, '', buildHref(next));
|
||||
const navigate = React.useCallback((next: IRoute, options: INavigateOptions = {}): void => {
|
||||
if (options.replace) {
|
||||
window.history.replaceState(undefined, '', buildHref(next));
|
||||
} else {
|
||||
window.history.pushState(undefined, '', buildHref(next));
|
||||
}
|
||||
setRoute(next);
|
||||
window.scrollTo(0, 0);
|
||||
}, []);
|
||||
|
||||
@@ -17,4 +17,20 @@ describe('parseRoute', () => {
|
||||
it('адрес только с projectId=7', () => {
|
||||
expect(parseRoute('?projectId=7')).toEqual({ page: 'hub', projectId: '7' });
|
||||
});
|
||||
it('сверка погоды с периодом', () => {
|
||||
expect(parseRoute('?page=weather&projectId=7&start=2026-10-01&end=2026-10-07')).toEqual({
|
||||
page: 'weather',
|
||||
projectId: '7',
|
||||
start: '2026-10-01',
|
||||
end: '2026-10-07'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSearch', () => {
|
||||
it('параметры прошлого маршрута не переносятся, посторонние параметры страницы остаются', () => {
|
||||
expect(buildSearch({ page: 'weather', projectId: '7', start: '2026-10-01' }, '?debug=true&reportId=r1')).toEqual(
|
||||
'?debug=true&page=weather&projectId=7&start=2026-10-01'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
// SharePoint (?page=objects&projectId=5), поэтому ссылку на любой раздел можно
|
||||
// скопировать, открыть в новой вкладке, а «назад» в браузере работает как раньше.
|
||||
|
||||
export const PAGE_IDS = ['hub', 'objects', 'works', 'reports', 'report', 'import', 'resources', 'binder'] as const;
|
||||
export const PAGE_IDS = [
|
||||
'hub',
|
||||
'objects',
|
||||
'works',
|
||||
'reports',
|
||||
'report',
|
||||
'import',
|
||||
'resources',
|
||||
'binder',
|
||||
'weather',
|
||||
'reportImport',
|
||||
'worksImport'
|
||||
] as const;
|
||||
|
||||
export type PageId = (typeof PAGE_IDS)[number];
|
||||
|
||||
@@ -16,6 +28,10 @@ export interface IRoute {
|
||||
reportId?: string;
|
||||
/** Форма отчёта: создать на основе существующего (GUID Dataverse). */
|
||||
copyFromId?: string;
|
||||
/** Главная и сверка погоды: начало периода (YYYY-MM-DD). */
|
||||
start?: string;
|
||||
/** Главная и сверка погоды: конец периода (YYYY-MM-DD). */
|
||||
end?: string;
|
||||
}
|
||||
|
||||
export const ROUTE_PARAMS = {
|
||||
@@ -23,10 +39,19 @@ export const ROUTE_PARAMS = {
|
||||
projectId: 'projectId',
|
||||
objectId: 'objectId',
|
||||
reportId: 'reportId',
|
||||
copyFromId: 'copyFromId'
|
||||
copyFromId: 'copyFromId',
|
||||
start: 'start',
|
||||
end: 'end'
|
||||
} as const;
|
||||
|
||||
const ROUTE_VALUE_KEYS: Array<Exclude<keyof IRoute, 'page'>> = ['projectId', 'objectId', 'reportId', 'copyFromId'];
|
||||
const ROUTE_VALUE_KEYS: Array<Exclude<keyof IRoute, 'page'>> = [
|
||||
'projectId',
|
||||
'objectId',
|
||||
'reportId',
|
||||
'copyFromId',
|
||||
'start',
|
||||
'end'
|
||||
];
|
||||
|
||||
function isPageId(value: string | undefined): value is PageId {
|
||||
return value !== undefined && (PAGE_IDS as readonly string[]).indexOf(value) !== -1;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { SITE_URL } from './constants';
|
||||
|
||||
// Ссылки группы «Справочники» в боковом меню: списки и страницы сайта StroyBot.
|
||||
// Открываются в новой вкладке. Адреса списков — по внутренним именам (List, List2…), а не по названиям.
|
||||
|
||||
export interface ICatalogLink {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const CATALOG_LINKS: ICatalogLink[] = [
|
||||
{ id: 'catalog-projects', title: 'Проекты', url: `${SITE_URL}/Lists/List/AllItems.aspx` },
|
||||
{ id: 'catalog-companies', title: 'Компании', url: `${SITE_URL}/Lists/List2/AllItems.aspx` },
|
||||
{ id: 'catalog-professions', title: 'Профессии', url: `${SITE_URL}/Lists/List5/AllItems.aspx` },
|
||||
{ id: 'catalog-units', title: 'Единицы измерения', url: `${SITE_URL}/Lists/List3/AllItems.aspx` },
|
||||
{ id: 'catalog-technics', title: 'Техника', url: `${SITE_URL}/Lists/List4/AllItems.aspx` },
|
||||
{
|
||||
id: 'catalog-archive',
|
||||
title: 'Архив',
|
||||
url:
|
||||
`${SITE_URL}/Shared%20Documents/Forms/AllItems.aspx` +
|
||||
'?id=%2Fsites%2Fstroy%2FShared%20Documents%2F%D0%90%D1%80%D1%85%D0%B8%D0%B2'
|
||||
},
|
||||
{
|
||||
id: 'catalog-manual',
|
||||
title: 'Инструкция',
|
||||
url: `${SITE_URL}/_layouts/15/Doc.aspx?sourcedoc=%7BC380799B-FF81-4D34-BB61-B4F05B6C43A5%7D&file=stroybot_manual.docx&action=default`
|
||||
},
|
||||
{ id: 'catalog-recycle-bin', title: 'Корзина', url: `${SITE_URL}/_layouts/15/RecycleBin.aspx?view=5` }
|
||||
];
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from 'react';
|
||||
import { IExcelImportCheck } from './excelImportTypes';
|
||||
import { ExcelCheckSummary } from './ExcelCheckSummary';
|
||||
import { ExcelCheckTable } from './ExcelCheckTable';
|
||||
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||
import styles from './ExcelImport.module.scss';
|
||||
|
||||
interface Props {
|
||||
check: IExcelImportCheck;
|
||||
/** Импорт — сразу под итогом проверки, чтобы кнопка не терялась под длинной таблицей. */
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Результат проверки файла: итог, импорт, таблица строк и примечания сервера. */
|
||||
export const ExcelCheckResult: React.FC<Props> = ({ check, actions }) => {
|
||||
const notes = check.notes || [];
|
||||
return (
|
||||
<>
|
||||
<ExcelCheckSummary check={check} />
|
||||
{actions}
|
||||
<ExcelCheckTable key={check.file_name} check={check} />
|
||||
{notes.length > 0 && (
|
||||
<section className={styles.notes}>
|
||||
<h3 className={styles.sectionLabel}>{EXCEL_IMPORT_TEXTS.notesTitle}</h3>
|
||||
<ul>
|
||||
{notes.map((note, index) => (
|
||||
<li key={`${index}-${note}`}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import { issuesByColumn } from './excelImportRules';
|
||||
import { IExcelImportRow } from './excelImportTypes';
|
||||
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||
import styles from './ExcelImport.module.scss';
|
||||
|
||||
interface Props {
|
||||
row: IExcelImportRow;
|
||||
columns: string[];
|
||||
}
|
||||
|
||||
/** Строка файла: ячейки с ошибкой подсвечены (сообщение — во всплывающей подсказке), справа — список ошибок. */
|
||||
export const ExcelCheckRow: React.FC<Props> = ({ row, columns }) => {
|
||||
const issues = issuesByColumn(row);
|
||||
return (
|
||||
<tr className={row.valid ? undefined : styles.invalidRow}>
|
||||
<td className={styles.rowNumber}>{row.row}</td>
|
||||
{columns.map((column) => {
|
||||
const messages = issues.get(column);
|
||||
const value = row.values?.[column];
|
||||
return (
|
||||
<td key={column} className={messages ? styles.invalidCell : undefined} title={messages?.join('\n')}>
|
||||
{value || <span className={styles.muted}>{EXCEL_IMPORT_TEXTS.empty}</span>}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className={styles.issues}>
|
||||
{(row.issues || []).map((issue, index) => (
|
||||
<div key={`${index}-${issue.column}`}>
|
||||
<strong>{issue.column}:</strong> {issue.message}
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react';
|
||||
import { Alert, Badge } from '@tnsu/ui-kit';
|
||||
import { canImportFile } from './excelImportRules';
|
||||
import { IExcelImportCheck } from './excelImportTypes';
|
||||
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||
import styles from './ExcelImport.module.scss';
|
||||
|
||||
const TEXTS = EXCEL_IMPORT_TEXTS.summary;
|
||||
|
||||
interface Props {
|
||||
check: IExcelImportCheck;
|
||||
}
|
||||
|
||||
/** Итог проверки: имя файла, счётчики строк и что делать дальше. */
|
||||
export const ExcelCheckSummary: React.FC<Props> = ({ check }) => {
|
||||
const isEmpty = check.total_rows === 0;
|
||||
return (
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.summaryRow}>
|
||||
<span className={styles.fileName}>{TEXTS.file(check.file_name)}</span>
|
||||
<Badge variant="neutral">{TEXTS.total(check.total_rows)}</Badge>
|
||||
<Badge variant="success">{TEXTS.valid(check.valid_rows)}</Badge>
|
||||
{check.invalid_rows > 0 && <Badge variant="danger">{TEXTS.invalid(check.invalid_rows)}</Badge>}
|
||||
</div>
|
||||
{isEmpty && <Alert variant="warn">{EXCEL_IMPORT_TEXTS.emptyFile}</Alert>}
|
||||
{!isEmpty && canImportFile(check) && <Alert variant="ok">{EXCEL_IMPORT_TEXTS.readyToImport}</Alert>}
|
||||
{check.invalid_rows > 0 && <Alert variant="warn">{EXCEL_IMPORT_TEXTS.fixInExcel}</Alert>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from 'react';
|
||||
import { Checkbox } from '@tnsu/ui-kit';
|
||||
import { columnsOf, visibleRows } from './excelImportRules';
|
||||
import { IExcelImportCheck } from './excelImportTypes';
|
||||
import { ExcelCheckRow } from './ExcelCheckRow';
|
||||
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||
import styles from './ExcelImport.module.scss';
|
||||
|
||||
const COLUMNS = EXCEL_IMPORT_TEXTS.columns;
|
||||
|
||||
interface Props {
|
||||
check: IExcelImportCheck;
|
||||
}
|
||||
|
||||
/** Таблица строк файла с фильтром «Только строки с ошибками». */
|
||||
export const ExcelCheckTable: React.FC<Props> = ({ check }) => {
|
||||
const [onlyInvalid, setOnlyInvalid] = React.useState<boolean>(false);
|
||||
const columns = columnsOf(check);
|
||||
const rows = visibleRows(check, onlyInvalid);
|
||||
|
||||
if (check.rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.tableSection}>
|
||||
{check.invalid_rows > 0 && (
|
||||
<Checkbox label={EXCEL_IMPORT_TEXTS.onlyInvalid} checked={onlyInvalid} onChange={setOnlyInvalid} />
|
||||
)}
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{COLUMNS.row}</th>
|
||||
{columns.map((column) => (
|
||||
<th key={column}>{column}</th>
|
||||
))}
|
||||
<th>{COLUMNS.issues}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<ExcelCheckRow key={row.row} row={row} columns={columns} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as React from 'react';
|
||||
import { Button, Icon } from '@tnsu/ui-kit';
|
||||
import { EXCEL_ACCEPT } from './excelImportRules';
|
||||
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||
import styles from './ExcelImport.module.scss';
|
||||
|
||||
interface Props {
|
||||
selectedFileName: string | undefined;
|
||||
disabled: boolean;
|
||||
onSelect: (file: File) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбор Excel-файла — только выбор, без отправки. Скрытый input сбрасывается,
|
||||
* чтобы тот же файл можно было выбрать повторно.
|
||||
*/
|
||||
export const ExcelFileField: React.FC<Props> = ({ selectedFileName, disabled, onSelect }) => {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const onChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (file) onSelect(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.fileField}>
|
||||
<input ref={inputRef} type="file" accept={EXCEL_ACCEPT} className={styles.hiddenInput} onChange={onChange} />
|
||||
<Button variant="secondary" onClick={() => inputRef.current?.click()} disabled={disabled}>
|
||||
<span className={styles.buttonContent}>
|
||||
<Icon name="upload" size="sm" />
|
||||
{selectedFileName ? EXCEL_IMPORT_TEXTS.chooseAnotherFile : EXCEL_IMPORT_TEXTS.chooseFile}
|
||||
</span>
|
||||
</Button>
|
||||
{selectedFileName && (
|
||||
<span className={styles.fileName}>{EXCEL_IMPORT_TEXTS.selectedFile(selectedFileName)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
// В файле может быть много колонок — таблица прокручивается внутри рамки, а не растягивает страницу.
|
||||
$issues-min-width: 260px;
|
||||
$cell-max-width: 280px;
|
||||
|
||||
.fileField {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-3;
|
||||
}
|
||||
|
||||
.hiddenInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.buttonContent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-3;
|
||||
}
|
||||
|
||||
.summaryRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
margin-right: $sp-2;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tableSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-3;
|
||||
}
|
||||
|
||||
.tableWrap {
|
||||
overflow-x: auto;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-md;
|
||||
box-shadow: $sh-1;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: $fs-sm;
|
||||
|
||||
th {
|
||||
padding: $sp-2 $sp-3;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
background: $gray-50;
|
||||
font-size: $fs-caption;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.8px;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
td {
|
||||
max-width: $cell-max-width;
|
||||
padding: $sp-2 $sp-3;
|
||||
border-bottom: 1px solid $gray-100;
|
||||
vertical-align: top;
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rowNumber {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.invalidRow .rowNumber {
|
||||
border-left: 3px solid $red-500;
|
||||
font-weight: 600;
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.invalidCell {
|
||||
background: $red-50;
|
||||
box-shadow: inset 0 0 0 1px $red-500;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.issues {
|
||||
min-width: $issues-min-width;
|
||||
font-size: $fs-label;
|
||||
line-height: 1.5;
|
||||
color: $red-500;
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: $gray-400;
|
||||
}
|
||||
|
||||
.notes {
|
||||
font-size: $fs-sm;
|
||||
color: $gray-700;
|
||||
|
||||
ul {
|
||||
margin: $sp-2 0 0;
|
||||
padding-left: $sp-4;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
margin: 0;
|
||||
font-family: $font-serif;
|
||||
font-size: $fs-section-title;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react';
|
||||
import { Alert, Spinner } from '@tnsu/ui-kit';
|
||||
import { canImportFile, isExcelFileName } from './excelImportRules';
|
||||
import { IExcelImportCheck } from './excelImportTypes';
|
||||
import { CheckUrlBuilder, useExcelFileCheck } from './useExcelFileCheck';
|
||||
import { ExcelFileField } from './ExcelFileField';
|
||||
import { ExcelCheckResult } from './ExcelCheckResult';
|
||||
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||
|
||||
interface Props {
|
||||
projectId: number;
|
||||
checkUrl: CheckUrlBuilder;
|
||||
/** Пока раздел записывает данные, новый файл не выбрать: иначе итог записи пропадёт с экрана. */
|
||||
isLocked: boolean;
|
||||
/** Выбран новый файл — раздел сбрасывает итог прошлой записи. */
|
||||
onFileChange: () => void;
|
||||
/** Блок «Импортировать» — только для файла без ошибок. */
|
||||
renderImport: (check: IExcelImportCheck) => React.ReactNode;
|
||||
}
|
||||
|
||||
/** Выбор Excel-файла, проверка на сервере сразу после выбора и результат проверки. */
|
||||
export const ExcelImportSection: React.FC<Props> = ({ projectId, checkUrl, isLocked, onFileChange, renderImport }) => {
|
||||
const { status, checkFile, reset } = useExcelFileCheck(projectId, checkUrl);
|
||||
const [selectedFileName, setSelectedFileName] = React.useState<string | undefined>(undefined);
|
||||
const [fileError, setFileError] = React.useState<string>('');
|
||||
const isChecking = status.kind === 'checking';
|
||||
|
||||
const selectFile = (file: File): void => {
|
||||
onFileChange();
|
||||
if (!isExcelFileName(file.name)) {
|
||||
reset();
|
||||
setSelectedFileName(undefined);
|
||||
setFileError(EXCEL_IMPORT_TEXTS.wrongExtension);
|
||||
return;
|
||||
}
|
||||
setSelectedFileName(file.name);
|
||||
setFileError('');
|
||||
checkFile(file).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExcelFileField selectedFileName={selectedFileName} disabled={isChecking || isLocked} onSelect={selectFile} />
|
||||
{fileError && <Alert variant="err">{fileError}</Alert>}
|
||||
{isChecking && <Spinner size="small" label={EXCEL_IMPORT_TEXTS.checking} />}
|
||||
{status.kind === 'error' && <Alert variant="err">{status.message}</Alert>}
|
||||
{status.kind === 'done' && (
|
||||
<ExcelCheckResult
|
||||
check={status.check}
|
||||
actions={canImportFile(status.check) ? renderImport(status.check) : undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { checkExcelFile } from './excelImportRepository';
|
||||
import { createFakeHttpClient } from '../../shared/testing/fakeHttpClient';
|
||||
|
||||
const URL = 'https://api.tnsu.kz/Import/StroyBot/Reports?projectId=51';
|
||||
|
||||
describe('проверка Excel-файла', () => {
|
||||
it('POST multipart: файл в поле file, Content-Type не задаём (boundary ставит браузер)', async () => {
|
||||
const body = { total_rows: 0, valid_rows: 0, invalid_rows: 0, rows: [], notes: [] };
|
||||
const client = createFakeHttpClient([{ match: '/Import/StroyBot/Reports', response: { body } }]);
|
||||
const file = new File(['xlsx'], 'отчёт.xlsx');
|
||||
|
||||
expect(await checkExcelFile(client, URL, file)).toEqual(body);
|
||||
|
||||
const sent = client.requests[0].request;
|
||||
expect(sent.method).toEqual('POST');
|
||||
expect(sent.headers).toEqual({ accept: '*/*' });
|
||||
expect(sent.body).toBeInstanceOf(FormData);
|
||||
expect(((sent.body as FormData).get('file') as File).name).toEqual('отчёт.xlsx');
|
||||
});
|
||||
|
||||
it('ошибка сервера пробрасывается с текстом ответа', async () => {
|
||||
const client = createFakeHttpClient([
|
||||
{ match: '/Import/StroyBot/Reports', response: { status: 400, body: 'неверный формат' } }
|
||||
]);
|
||||
await expect(checkExcelFile(client, URL, new File(['x'], 'a.xlsx'))).rejects.toThrow('неверный формат');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ensureOk, IHttpClient } from '../../services/http/IHttpClient';
|
||||
import { IExcelImportCheck } from './excelImportTypes';
|
||||
|
||||
/** Имя поля формы с файлом — как в Swagger эндпоинтов. */
|
||||
const FILE_FIELD = 'file';
|
||||
|
||||
/**
|
||||
* Проверка Excel-файла на сервере. Эндпоинт ничего не сохраняет: разбирает строки, сопоставляет значения
|
||||
* со справочниками (ids) и возвращает ошибки по строкам. Данные по результату записывает фронт.
|
||||
* Content-Type не задаём: для FormData браузер сам ставит multipart/form-data с boundary.
|
||||
*/
|
||||
export async function checkExcelFile(api: IHttpClient, url: string, file: File): Promise<IExcelImportCheck> {
|
||||
const form = new FormData();
|
||||
form.append(FILE_FIELD, file, file.name);
|
||||
const response = await ensureOk(
|
||||
await api.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { accept: '*/*' },
|
||||
body: form
|
||||
})
|
||||
);
|
||||
return (await response.json()) as IExcelImportCheck;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { canImportFile, columnsOf, isExcelFileName, issuesByColumn, visibleRows } from './excelImportRules';
|
||||
import { IExcelImportCheck, IExcelImportRow } from './excelImportTypes';
|
||||
|
||||
// Строки — по ответу сервера на тестовый файл проекта 51 (сокращённо).
|
||||
const row = (
|
||||
num: number,
|
||||
values: IExcelImportRow['values'],
|
||||
issues: IExcelImportRow['issues'] = []
|
||||
): IExcelImportRow => ({ row: num, valid: issues.length === 0, values, ids: {}, issues });
|
||||
|
||||
const check = (rows: IExcelImportRow[]): IExcelImportCheck => ({
|
||||
kind: 'reports',
|
||||
file_name: 'test.xlsx',
|
||||
project_id: 51,
|
||||
project_name: 'TEST',
|
||||
total_rows: rows.length,
|
||||
valid_rows: rows.filter((item) => item.valid).length,
|
||||
invalid_rows: rows.filter((item) => !item.valid).length,
|
||||
rows,
|
||||
notes: []
|
||||
});
|
||||
|
||||
const technicRow = row(2, { Дата: '01.10.2026', Техника: 'Насос', Профессия: null }, [
|
||||
{ column: 'Профессия', message: 'значение не указано' },
|
||||
{ column: 'Вид профессии', message: 'значение не указано' }
|
||||
]);
|
||||
const validRow = row(3, { Дата: '01.10.2026', Техника: null, Профессия: 'Арматурщик' });
|
||||
|
||||
describe('columnsOf', () => {
|
||||
it('колонки в порядке файла; колонка только из ошибок — в конце', () => {
|
||||
expect(columnsOf(check([technicRow, validRow]))).toEqual(['Дата', 'Техника', 'Профессия', 'Вид профессии']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('issuesByColumn', () => {
|
||||
it('несколько сообщений одной колонки собираются вместе', () => {
|
||||
const issues = issuesByColumn(
|
||||
row(2, {}, [
|
||||
{ column: 'Профессия', message: 'значение не указано' },
|
||||
{ column: 'Профессия', message: 'id неоднозначен' }
|
||||
])
|
||||
);
|
||||
expect(issues.get('Профессия')).toEqual(['значение не указано', 'id неоднозначен']);
|
||||
expect(issues.get('Техника')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('visibleRows / canImportFile', () => {
|
||||
it('фильтр «только с ошибками»', () => {
|
||||
expect(visibleRows(check([technicRow, validRow]), true).map((item) => item.row)).toEqual([2]);
|
||||
expect(visibleRows(check([technicRow, validRow]), false)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('импорт — только при строках без единой ошибки', () => {
|
||||
expect(canImportFile(check([validRow]))).toBe(true);
|
||||
expect(canImportFile(check([technicRow, validRow]))).toBe(false);
|
||||
expect(canImportFile(check([]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExcelFileName', () => {
|
||||
it('только .xlsx, без учёта регистра', () => {
|
||||
expect(isExcelFileName('Отчёт.XLSX')).toBe(true);
|
||||
expect(isExcelFileName('отчёт.xls')).toBe(false);
|
||||
expect(isExcelFileName('отчёт.csv')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { IExcelImportCheck, IExcelImportRow } from './excelImportTypes';
|
||||
|
||||
/**
|
||||
* Колонки таблицы — в порядке, в каком их прислал сервер (это порядок колонок файла).
|
||||
* Колонка, которая есть только в ошибках (её нет в values), добавляется в конец — чтобы ошибка не потерялась.
|
||||
*/
|
||||
export function columnsOf(check: IExcelImportCheck): string[] {
|
||||
const columns: string[] = [];
|
||||
const add = (column: string): void => {
|
||||
if (columns.indexOf(column) === -1) columns.push(column);
|
||||
};
|
||||
check.rows.forEach((row) => Object.keys(row.values || {}).forEach(add));
|
||||
check.rows.forEach((row) => (row.issues || []).forEach((issue) => add(issue.column)));
|
||||
return columns;
|
||||
}
|
||||
|
||||
/** Ошибки строки по колонкам: одна колонка может иметь несколько сообщений. */
|
||||
export function issuesByColumn(row: IExcelImportRow): Map<string, string[]> {
|
||||
const result = new Map<string, string[]>();
|
||||
(row.issues || []).forEach((issue) => {
|
||||
const messages = result.get(issue.column) ?? [];
|
||||
result.set(issue.column, [...messages, issue.message]);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Строки для показа: все или только с ошибками. */
|
||||
export function visibleRows(check: IExcelImportCheck, onlyInvalid: boolean): IExcelImportRow[] {
|
||||
return onlyInvalid ? check.rows.filter((row) => !row.valid) : check.rows;
|
||||
}
|
||||
|
||||
/** Импортировать можно, только если в файле есть строки и ни одной с ошибкой. */
|
||||
export function canImportFile(check: IExcelImportCheck): boolean {
|
||||
return check.total_rows > 0 && check.invalid_rows === 0;
|
||||
}
|
||||
|
||||
/** Расширения Excel, которые принимает поле выбора файла. */
|
||||
export const EXCEL_ACCEPT = '.xlsx';
|
||||
|
||||
export function isExcelFileName(fileName: string): boolean {
|
||||
return /\.xlsx$/i.test(fileName);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Ответ проверки Excel-файла: POST /Import/StroyBot/{Reports|Works}?projectId= (multipart, поле file).
|
||||
// Эндпоинты ничего не сохраняют — только разбирают файл и сопоставляют значения со справочниками.
|
||||
// Имена полей — как в ответе API (snake_case), поэтому не переименовываем.
|
||||
/* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */
|
||||
|
||||
/** Ошибка в ячейке: колонка — заголовок колонки файла («Профессия», «Вид работы»…). */
|
||||
export interface IExcelImportIssue {
|
||||
column: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Строка файла после проверки. */
|
||||
export interface IExcelImportRow {
|
||||
/** Номер строки в Excel (заголовок — строка 1). */
|
||||
row: number;
|
||||
valid: boolean;
|
||||
/** Значения ячеек по заголовкам колонок, как их прочитал сервер. Пустая ячейка — null. */
|
||||
values: Record<string, string | null>;
|
||||
/** Во что сервер сопоставил значения (id объектов, работ, компаний…) — по ним фронт записывает данные. */
|
||||
ids: Record<string, unknown>;
|
||||
issues: IExcelImportIssue[];
|
||||
}
|
||||
|
||||
export interface IExcelImportCheck {
|
||||
/** Что проверялось: 'reports' / 'works'. */
|
||||
kind: string;
|
||||
file_name: string;
|
||||
project_id: number;
|
||||
project_name: string;
|
||||
total_rows: number;
|
||||
valid_rows: number;
|
||||
invalid_rows: number;
|
||||
rows: IExcelImportRow[];
|
||||
notes: string[];
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { deepestObjectId, idNumber, textValue, toNumber } from './excelImportValues';
|
||||
import { IExcelImportRow } from './excelImportTypes';
|
||||
|
||||
/* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */
|
||||
|
||||
const row = (values: IExcelImportRow['values'], ids: IExcelImportRow['ids']): IExcelImportRow => ({
|
||||
row: 2,
|
||||
valid: true,
|
||||
values,
|
||||
ids,
|
||||
issues: []
|
||||
});
|
||||
|
||||
describe('значения строки Excel', () => {
|
||||
it('число: запятая как разделитель, пусто → undefined', () => {
|
||||
expect(toNumber('5,5')).toEqual(5.5);
|
||||
expect(toNumber('11')).toEqual(11);
|
||||
expect(toNumber('')).toBeUndefined();
|
||||
expect(toNumber(null)).toBeUndefined();
|
||||
expect(toNumber('abc')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('id: только число; массив (неоднозначно) и отсутствие → undefined', () => {
|
||||
const item = row({}, { 'Ед.изм.': 37, 'Профессия': [180, 360] });
|
||||
expect(idNumber(item, 'Ед.изм.')).toEqual(37);
|
||||
expect(idNumber(item, 'Профессия')).toBeUndefined();
|
||||
expect(idNumber(item, 'Техника')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('текст: без краевых пробелов, пусто → undefined', () => {
|
||||
const item = row({ 'Вид работ': ' Арматура ', 'Комментарий': ' ', 'Объект 3': null }, {});
|
||||
expect(textValue(item, 'Вид работ')).toEqual('Арматура');
|
||||
expect(textValue(item, 'Комментарий')).toBeUndefined();
|
||||
expect(textValue(item, 'Объект 3')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('объект — самый глубокий уровень, даже если сервер прислал только его', () => {
|
||||
const columns = ['Объект 1', 'Объект 2', 'Объект 3'];
|
||||
expect(deepestObjectId(row({}, { 'Объект 1': 1516, 'Объект 2': 1517 }), columns)).toEqual(1517);
|
||||
expect(deepestObjectId(row({}, { 'Объект 2': 1517 }), columns)).toEqual(1517);
|
||||
expect(deepestObjectId(row({}, {}), columns)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { IExcelImportRow } from './excelImportTypes';
|
||||
|
||||
// Чтение значений строки из ответа проверки — общее для импорта отчётов и работ.
|
||||
/* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */
|
||||
|
||||
/** «5», «5,5» → число; пусто или не число → undefined. */
|
||||
export function toNumber(value: string | null | undefined): number | undefined {
|
||||
if (value === null || value === undefined || value.trim() === '') return undefined;
|
||||
const result = Number(value.replace(',', '.'));
|
||||
return isNaN(result) ? undefined : result;
|
||||
}
|
||||
|
||||
/** Id, который сервер сопоставил ячейке; не число (нет, массив при неоднозначности) → undefined. */
|
||||
export function idNumber(row: IExcelImportRow, column: string): number | undefined {
|
||||
const id = row.ids[column];
|
||||
return typeof id === 'number' ? id : undefined;
|
||||
}
|
||||
|
||||
/** Значение ячейки без краевых пробелов; пустое → undefined. */
|
||||
export function textValue(row: IExcelImportRow, column: string): string | undefined {
|
||||
return row.values[column]?.trim() || undefined;
|
||||
}
|
||||
|
||||
/** Самый глубокий заполненный уровень объекта (Объект 3 → 2 → 1). */
|
||||
export function deepestObjectId(row: IExcelImportRow, objectColumns: readonly string[]): number | undefined {
|
||||
const ids = objectColumns.map((column) => idNumber(row, column)).filter((id) => id !== undefined);
|
||||
return ids[ids.length - 1];
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Общие тексты импорта из Excel: выбор файла и результат проверки.
|
||||
|
||||
export const EXCEL_IMPORT_TEXTS = {
|
||||
chooseFile: 'Выбрать файл',
|
||||
chooseAnotherFile: 'Выбрать другой файл',
|
||||
selectedFile: (name: string): string => `Выбран файл: ${name}`,
|
||||
checking: 'Проверяем файл…',
|
||||
wrongExtension: 'Нужен файл Excel в формате .xlsx',
|
||||
checkError: (details: string): string => `Не удалось проверить файл: ${details}`,
|
||||
summary: {
|
||||
file: (name: string): string => `Файл: ${name}`,
|
||||
total: (count: number): string => `Строк: ${count}`,
|
||||
valid: (count: number): string => `Без ошибок: ${count}`,
|
||||
invalid: (count: number): string => `С ошибками: ${count}`
|
||||
},
|
||||
emptyFile: 'В файле нет строк с данными.',
|
||||
readyToImport: 'Ошибок нет — файл готов к импорту.',
|
||||
fixInExcel: 'Исправьте ошибки в Excel и загрузите файл снова.',
|
||||
onlyInvalid: 'Только строки с ошибками',
|
||||
columns: {
|
||||
row: 'Строка',
|
||||
issues: 'Ошибки'
|
||||
},
|
||||
notesTitle: 'Примечания',
|
||||
empty: '—'
|
||||
} as const;
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from 'react';
|
||||
import { useAppServices } from '../../app/AppServicesContext';
|
||||
import { checkExcelFile } from './excelImportRepository';
|
||||
import { IExcelImportCheck } from './excelImportTypes';
|
||||
import { EXCEL_IMPORT_TEXTS } from './texts';
|
||||
|
||||
export type ExcelFileCheckStatus =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'checking' }
|
||||
| { kind: 'error'; message: string }
|
||||
| { kind: 'done'; check: IExcelImportCheck };
|
||||
|
||||
/** Адрес эндпоинта проверки для раздела (отчёты, работы). */
|
||||
export type CheckUrlBuilder = (apiBaseUrl: string, projectId: number) => string;
|
||||
|
||||
/**
|
||||
* Проверка Excel-файла на сервере. Сервер ничего не сохраняет, поэтому проверку можно
|
||||
* запускать сразу при выборе файла. reset — сбросить результат (файл не Excel).
|
||||
*/
|
||||
export function useExcelFileCheck(
|
||||
projectId: number | undefined,
|
||||
checkUrl: CheckUrlBuilder
|
||||
): {
|
||||
status: ExcelFileCheckStatus;
|
||||
checkFile: (file: File) => Promise<void>;
|
||||
reset: () => void;
|
||||
} {
|
||||
const services = useAppServices();
|
||||
const [status, setStatus] = React.useState<ExcelFileCheckStatus>({ kind: 'idle' });
|
||||
const isMountedRef = React.useRef<boolean>(true);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
isMountedRef.current = false;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const checkFile = async (file: File): Promise<void> => {
|
||||
if (projectId === undefined) return;
|
||||
setStatus({ kind: 'checking' });
|
||||
try {
|
||||
const url = checkUrl(services.settings.apiBaseUrl, projectId);
|
||||
const check = await checkExcelFile(services.tnsuApi, url, file);
|
||||
if (isMountedRef.current) setStatus({ kind: 'done', check });
|
||||
} catch (error) {
|
||||
console.error('Ошибка проверки Excel-файла:', error);
|
||||
const details = error instanceof Error ? error.message : String(error);
|
||||
if (isMountedRef.current) setStatus({ kind: 'error', message: EXCEL_IMPORT_TEXTS.checkError(details) });
|
||||
}
|
||||
};
|
||||
|
||||
const reset = React.useCallback((): void => setStatus({ kind: 'idle' }), []);
|
||||
|
||||
return { status, checkFile, reset };
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import * as React from 'react';
|
||||
import { Alert, Badge, BadgeVariant, Button, Icon, Spinner, TextInput } from '@tnsu/ui-kit';
|
||||
import { IAccessibleProject } from '../../app/project/projectTypes';
|
||||
import { isExportAvailable } from '../../app/project/projectRules';
|
||||
import { useRouter } from '../../app/routing/RouterContext';
|
||||
import { InlineIcon } from '../../shared/components/InlineIcon';
|
||||
import { WEATHER_TEXTS } from '../weather/texts';
|
||||
import { getReportType, isInvalidRange, ReportKind } from './exportReport';
|
||||
import { useReportExport } from './useReportExport';
|
||||
import { ExportInfoButton } from './ExportInfoButton';
|
||||
@@ -20,17 +23,22 @@ interface Props {
|
||||
project: IAccessibleProject | undefined;
|
||||
}
|
||||
|
||||
/** Карточка «Выгрузить за период». Поля дат видны только при выбранном проекте. */
|
||||
/**
|
||||
* Карточка «Выгрузить за период» и кнопка «Сверка погодных условий». Поля дат видны только при выбранном проекте.
|
||||
* Даты берутся из адреса, если вернулись со сверки погоды, — чтобы не выбирать период заново (как в main).
|
||||
*/
|
||||
export const ExportCard: React.FC<Props> = ({ project }) => {
|
||||
const [dateFrom, setDateFrom] = React.useState<string>('');
|
||||
const [dateTo, setDateTo] = React.useState<string>('');
|
||||
const { route, navigate } = useRouter();
|
||||
const [dateFrom, setDateFrom] = React.useState<string>(route.start ?? '');
|
||||
const [dateTo, setDateTo] = React.useState<string>(route.end ?? '');
|
||||
const { isExporting, errorMessage, exportReport } = useReportExport();
|
||||
|
||||
const isEnabled = !!project && isExportAvailable(project);
|
||||
const hasBothDates = !!dateFrom && !!dateTo;
|
||||
const isRangeInvalid = isInvalidRange(dateFrom, dateTo);
|
||||
const reportType = getReportType(dateFrom, dateTo);
|
||||
const canExport = isEnabled && hasBothDates && !isRangeInvalid && !isExporting;
|
||||
const isPeriodReady = isEnabled && hasBothDates && !isRangeInvalid;
|
||||
const canExport = isPeriodReady && !isExporting;
|
||||
|
||||
const onExport = (): void => {
|
||||
if (project && canExport) {
|
||||
@@ -38,6 +46,14 @@ export const ExportCard: React.FC<Props> = ({ project }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenWeather = (): void => {
|
||||
if (!project || !isPeriodReady) return;
|
||||
const projectId = String(project.id);
|
||||
// Сначала запоминаем даты в адресе главной: «назад» в браузере вернёт их в поля.
|
||||
navigate({ page: 'hub', projectId, start: dateFrom, end: dateTo }, { replace: true });
|
||||
navigate({ page: 'weather', projectId, start: dateFrom, end: dateTo });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={isEnabled ? styles.exportCard : `${styles.exportCard} ${styles.cardDisabled}`}>
|
||||
<span className={styles.cardIcon} aria-hidden="true">
|
||||
@@ -64,6 +80,12 @@ export const ExportCard: React.FC<Props> = ({ project }) => {
|
||||
<Button variant="accent" onClick={onExport} disabled={!canExport}>
|
||||
{isExporting ? <Spinner size="xSmall" label={TEXTS.buttonLoading} /> : TEXTS.button}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onOpenWeather} disabled={!isPeriodReady}>
|
||||
<span className={styles.buttonContent}>
|
||||
<InlineIcon name="cloud" />
|
||||
{WEATHER_TEXTS.openButton}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -136,6 +136,12 @@ $card-min-width: 260px;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.buttonContent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
}
|
||||
|
||||
.dateError {
|
||||
font-size: $fs-sm;
|
||||
color: $red-500;
|
||||
|
||||
@@ -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,7 +22,7 @@ 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('отчёт, счётчик, строки, факт', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ImportWorksWriter, saveImportedWorks } from './worksImportSave';
|
||||
import { IImportedWork, IWorksImportPlan } from './worksImportBuilder';
|
||||
|
||||
interface IWrite {
|
||||
method: 'create' | 'update';
|
||||
id?: string;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Writer, который запоминает запросы. failOnCall — номер вызова (с 1), на котором бросить ошибку. */
|
||||
function makeWriter(failOnCall?: number): ImportWorksWriter & { writes: IWrite[] } {
|
||||
const writes: IWrite[] = [];
|
||||
const record = async (write: IWrite): Promise<void> => {
|
||||
if (writes.length + 1 === failOnCall) throw new Error('Dataverse недоступен');
|
||||
writes.push(write);
|
||||
};
|
||||
return {
|
||||
writes,
|
||||
createWork: (body) => record({ method: 'create', body }),
|
||||
updateWork: (id, body) => record({ method: 'update', id, body })
|
||||
};
|
||||
}
|
||||
|
||||
const work = (rowNumber: number, title: string): IImportedWork => ({
|
||||
rowNumber,
|
||||
objectId: '1517',
|
||||
objectPath: 'Тестовый объект / Бетонирование',
|
||||
title,
|
||||
unit: 'м',
|
||||
volume: 100
|
||||
});
|
||||
|
||||
// Как в тестовом файле: строка 2 — «Арматура» уже есть, строка 3 — новая работа.
|
||||
const plan: IWorksImportPlan = {
|
||||
toCreate: [work(3, 'Тестовая работа импорта')],
|
||||
toUpdate: [{ ...work(2, 'Арматура'), workId: 'guid-armature', previousVolume: 120, previousUnit: 'м' }]
|
||||
};
|
||||
|
||||
describe('запись импортированных работ', () => {
|
||||
it('в порядке строк файла: обновление — только объём и единица, создание — с объектом', async () => {
|
||||
const writer = makeWriter();
|
||||
const progress: number[] = [];
|
||||
|
||||
const result = await saveImportedWorks(writer, plan, (count) => progress.push(count));
|
||||
|
||||
expect(result).toEqual({ createdCount: 1, updatedCount: 1 });
|
||||
expect(writer.writes).toEqual([
|
||||
{ method: 'update', id: 'guid-armature', body: { cree9_unit: 'м', cree9_volume: 100 } },
|
||||
{
|
||||
method: 'create',
|
||||
body: { cree9_title: 'Тестовая работа импорта', cree9_unit: 'м', cree9_volume: 100, cree9_objectid: '1517' }
|
||||
}
|
||||
]);
|
||||
expect(progress).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('ошибка останавливает запись, итог — что успели и на какой строке упали', async () => {
|
||||
const writer = makeWriter(2);
|
||||
|
||||
const result = await saveImportedWorks(writer, plan, () => undefined);
|
||||
|
||||
expect(result).toEqual({ createdCount: 0, updatedCount: 1, errorMessage: 'Dataverse недоступен', failedRow: 3 });
|
||||
expect(writer.writes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { IWorksRepository, WORK_COLUMNS } from '../works/worksRepository';
|
||||
import { IImportedWork, IWorksImportPlan, IWorkUpdate } from './worksImportBuilder';
|
||||
|
||||
/** Нужная импорту часть репозитория раздела «Работы» — те же запросы, что у окна работы. */
|
||||
export type ImportWorksWriter = Pick<IWorksRepository, 'createWork' | 'updateWork'>;
|
||||
|
||||
export interface IWorksSaveResult {
|
||||
createdCount: number;
|
||||
updatedCount: number;
|
||||
/** Есть — запись остановилась на строке failedRow. */
|
||||
errorMessage?: string;
|
||||
failedRow?: number;
|
||||
}
|
||||
|
||||
/** Новая работа — верхнего уровня объекта; даты не задаём (в файле их нет). */
|
||||
export function createWorkBody(work: IImportedWork): Record<string, unknown> {
|
||||
return {
|
||||
[WORK_COLUMNS.title]: work.title,
|
||||
[WORK_COLUMNS.unit]: work.unit,
|
||||
[WORK_COLUMNS.volume]: work.volume,
|
||||
[WORK_COLUMNS.objectId]: work.objectId
|
||||
};
|
||||
}
|
||||
|
||||
/** У существующей работы меняем только план: название, родителя и даты не трогаем. */
|
||||
export function updateWorkBody(work: IWorkUpdate): Record<string, unknown> {
|
||||
return { [WORK_COLUMNS.unit]: work.unit, [WORK_COLUMNS.volume]: work.volume };
|
||||
}
|
||||
|
||||
type PlannedWrite = { kind: 'create'; work: IImportedWork } | { kind: 'update'; work: IWorkUpdate };
|
||||
|
||||
/** Порядок записи — как строки в файле, чтобы прогресс и ошибка были понятны пользователю. */
|
||||
function writesInFileOrder(plan: IWorksImportPlan): PlannedWrite[] {
|
||||
const writes: PlannedWrite[] = [
|
||||
...plan.toCreate.map((work): PlannedWrite => ({ kind: 'create', work })),
|
||||
...plan.toUpdate.map((work): PlannedWrite => ({ kind: 'update', work }))
|
||||
];
|
||||
return writes.sort((a, b) => a.work.rowNumber - b.work.rowNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Записывает работы по одной. Ошибка останавливает запись; уже записанное остаётся.
|
||||
* Повторная загрузка того же файла безопасна: созданные работы найдутся по названию и обновятся.
|
||||
*/
|
||||
export async function saveImportedWorks(
|
||||
writer: ImportWorksWriter,
|
||||
plan: IWorksImportPlan,
|
||||
onProgress: (savedCount: number) => void
|
||||
): Promise<IWorksSaveResult> {
|
||||
const result: IWorksSaveResult = { createdCount: 0, updatedCount: 0 };
|
||||
for (const write of writesInFileOrder(plan)) {
|
||||
try {
|
||||
if (write.kind === 'create') {
|
||||
await writer.createWork(createWorkBody(write.work));
|
||||
result.createdCount++;
|
||||
} else {
|
||||
await writer.updateWork(write.work.workId, updateWorkBody(write.work));
|
||||
result.updatedCount++;
|
||||
}
|
||||
onProgress(result.createdCount + result.updatedCount);
|
||||
} catch (error) {
|
||||
console.error('Ошибка записи импортированной работы:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { ...result, errorMessage, failedRow: write.work.rowNumber };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -8,7 +8,8 @@ export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
export interface IHttpRequest {
|
||||
method?: HttpMethod;
|
||||
headers?: Record<string, string>;
|
||||
body?: string | ArrayBuffer;
|
||||
/** FormData — для загрузки файлов (multipart): Content-Type с boundary браузер ставит сам. */
|
||||
body?: string | ArrayBuffer | FormData;
|
||||
}
|
||||
|
||||
export interface IHttpResponse {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react';
|
||||
|
||||
// Иконки, которых пока нет в спрайте ui-kit (контуры из stroy-bot-main). Позже переедут в кит.
|
||||
const ICON_PATHS = {
|
||||
cloud: ['M17.5 19H7a5 5 0 1 1 1.02-9.9A6 6 0 0 1 19.5 11a4 4 0 0 1-2 8z'],
|
||||
sparkle: [
|
||||
'M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z',
|
||||
'M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z'
|
||||
]
|
||||
} as const;
|
||||
|
||||
export type InlineIconName = keyof typeof ICON_PATHS;
|
||||
|
||||
/** Размер как у Icon size="sm" из ui-kit. */
|
||||
const ICON_SIZE_PX = 16;
|
||||
|
||||
interface Props {
|
||||
name: InlineIconName;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const InlineIcon: React.FC<Props> = ({ name, className }) => (
|
||||
<svg
|
||||
className={className}
|
||||
width={ICON_SIZE_PX}
|
||||
height={ICON_SIZE_PX}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{ICON_PATHS[name].map((path) => (
|
||||
<path key={path} d={path} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
Reference in New Issue
Block a user