diff --git a/docs/MIGRATION-CHECKLIST.md b/docs/MIGRATION-CHECKLIST.md index 0750689..08685ba 100644 --- a/docs/MIGRATION-CHECKLIST.md +++ b/docs/MIGRATION-CHECKLIST.md @@ -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=` diff --git a/docs/TEST-PLAN.md b/docs/TEST-PLAN.md index 6fc9b13..d5015d1 100644 --- a/docs/TEST-PLAN.md +++ b/docs/TEST-PLAN.md @@ -81,6 +81,15 @@ - [ ] **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: таблицы прокручиваются внутри своей рамки, страница целиком вбок не едет. + ## 3. Ресурсы проекта — `T-RES` - [ ] **T-RES-01** Большой проект: вкладки «Профессии (N)», «Компании (N)» — числа и списки совпадают со старой страницей. - [ ] **T-RES-02** Поиск слева, «✕» очищает; «уже добавлено» у привязанных. diff --git a/src/webparts/stroyBot/app/PageOutlet.tsx b/src/webparts/stroyBot/app/PageOutlet.tsx index e50601a..4173263 100644 --- a/src/webparts/stroyBot/app/PageOutlet.tsx +++ b/src/webparts/stroyBot/app/PageOutlet.tsx @@ -8,6 +8,7 @@ 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'; /** Показывает страницу текущего раздела (раздел берётся из ?page=). */ export const PageOutlet: React.FC = () => { @@ -28,6 +29,8 @@ export const PageOutlet: React.FC = () => { return ; case 'binder': return ; + case 'weather': + return ; case 'hub': default: return ; diff --git a/src/webparts/stroyBot/app/navigation.test.ts b/src/webparts/stroyBot/app/navigation.test.ts index 14f4177..41175dc 100644 --- a/src/webparts/stroyBot/app/navigation.test.ts +++ b/src/webparts/stroyBot/app/navigation.test.ts @@ -28,6 +28,10 @@ describe('navPageFor', () => { expect(navPageFor('works')).toEqual('objects'); }); + it('сверка погоды подсвечивает главную', () => { + expect(navPageFor('weather')).toEqual('hub'); + }); + it('остальные разделы подсвечивают сами себя', () => { expect(navPageFor('reports')).toEqual('reports'); }); diff --git a/src/webparts/stroyBot/app/navigation.ts b/src/webparts/stroyBot/app/navigation.ts index 24dcca5..9411c82 100644 --- a/src/webparts/stroyBot/app/navigation.ts +++ b/src/webparts/stroyBot/app/navigation.ts @@ -20,9 +20,14 @@ export const NAV_ITEMS: INavItem[] = [ { 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; } /** diff --git a/src/webparts/stroyBot/app/routing/RouterContext.tsx b/src/webparts/stroyBot/app/routing/RouterContext.tsx index 8c2e395..0fbbad1 100644 --- a/src/webparts/stroyBot/app/routing/RouterContext.tsx +++ b/src/webparts/stroyBot/app/routing/RouterContext.tsx @@ -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); }, []); diff --git a/src/webparts/stroyBot/app/routing/routes.test.ts b/src/webparts/stroyBot/app/routing/routes.test.ts index f278183..e0ad5ad 100644 --- a/src/webparts/stroyBot/app/routing/routes.test.ts +++ b/src/webparts/stroyBot/app/routing/routes.test.ts @@ -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' + ); + }); }); diff --git a/src/webparts/stroyBot/app/routing/routes.ts b/src/webparts/stroyBot/app/routing/routes.ts index c38694a..f3e3e3c 100644 --- a/src/webparts/stroyBot/app/routing/routes.ts +++ b/src/webparts/stroyBot/app/routing/routes.ts @@ -2,7 +2,17 @@ // 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' +] as const; export type PageId = (typeof PAGE_IDS)[number]; @@ -16,6 +26,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 +37,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> = ['projectId', 'objectId', 'reportId', 'copyFromId']; +const ROUTE_VALUE_KEYS: Array> = [ + '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; diff --git a/src/webparts/stroyBot/pages/hub/ExportCard.tsx b/src/webparts/stroyBot/pages/hub/ExportCard.tsx index eab66db..cd12a17 100644 --- a/src/webparts/stroyBot/pages/hub/ExportCard.tsx +++ b/src/webparts/stroyBot/pages/hub/ExportCard.tsx @@ -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 = ({ project }) => { - const [dateFrom, setDateFrom] = React.useState(''); - const [dateTo, setDateTo] = React.useState(''); + const { route, navigate } = useRouter(); + const [dateFrom, setDateFrom] = React.useState(route.start ?? ''); + const [dateTo, setDateTo] = React.useState(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 = ({ 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 (
)} diff --git a/src/webparts/stroyBot/pages/hub/HubPage.module.scss b/src/webparts/stroyBot/pages/hub/HubPage.module.scss index 7f1726e..05b8634 100644 --- a/src/webparts/stroyBot/pages/hub/HubPage.module.scss +++ b/src/webparts/stroyBot/pages/hub/HubPage.module.scss @@ -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; diff --git a/src/webparts/stroyBot/pages/weather/AssessmentBadge.tsx b/src/webparts/stroyBot/pages/weather/AssessmentBadge.tsx new file mode 100644 index 0000000..c09f715 --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/AssessmentBadge.tsx @@ -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; +} + +/** Оценка ИИ: «В пределах нормы» — зелёная, любая другая — предупреждение. До оценки — прочерк. */ +export const AssessmentBadge: React.FC = ({ assessment }) => { + if (!assessment) return ; + return {assessment}; +}; diff --git a/src/webparts/stroyBot/pages/weather/DowntimeActCell.tsx b/src/webparts/stroyBot/pages/weather/DowntimeActCell.tsx new file mode 100644 index 0000000..9f3369c --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/DowntimeActCell.tsx @@ -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 = ({ position }) => { + if (position.downtime_act_recommended === false) { + return {TEXTS.downtimeNotRequired}; + } + if (position.downtime_act_recommended !== true) return ; + return ( + <> + {TEXTS.downtimeRecommended} + {position.downtime_act_reason &&
{position.downtime_act_reason}
} + + ); +}; diff --git a/src/webparts/stroyBot/pages/weather/EmptyValue.tsx b/src/webparts/stroyBot/pages/weather/EmptyValue.tsx new file mode 100644 index 0000000..cb4efc8 --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/EmptyValue.tsx @@ -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 = () => {WEATHER_TEXTS.empty}; diff --git a/src/webparts/stroyBot/pages/weather/PositionRow.tsx b/src/webparts/stroyBot/pages/weather/PositionRow.tsx new file mode 100644 index 0000000..8c596c4 --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/PositionRow.tsx @@ -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 = ({ position }) => ( + + {formatWeatherDate(position.date)} + +
{position.profession || WEATHER_TEXTS.empty}
+ {position.work_type &&
{position.work_type}
} + + +
{position.shift || WEATHER_TEXTS.empty}
+
+ {position.shift_start || ''}–{position.shift_end || ''} +
+ + {formatWeatherNumber(position.hours)} + {position.hours_type || } + {formatWeatherNumber(position.headcount)} + + + + + + + {position.comment || } + + + + +); diff --git a/src/webparts/stroyBot/pages/weather/PositionWeatherCell.tsx b/src/webparts/stroyBot/pages/weather/PositionWeatherCell.tsx new file mode 100644 index 0000000..5aab258 --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/PositionWeatherCell.tsx @@ -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; +} + +/** «Погода в смену»: сводка наблюдений за часы смены позиции. */ +export const PositionWeatherCell: React.FC = ({ weather }) => { + if (!weather) { + return {TEXTS.noData}; + } + + const hasGust = weather.gust_max !== null && weather.gust_max !== undefined; + return ( +
+
{TEXTS.temperature(formatWeatherNumber(weather.temp_min), formatWeatherNumber(weather.temp_max))}
+
+ {TEXTS.wind(formatWeatherNumber(weather.wind_max))} + {hasGust && TEXTS.gust(formatWeatherNumber(weather.gust_max))} +
+
{TEXTS.precipitation(formatWeatherNumber(weather.precipitation))}
+
{TEXTS.dewpoint(formatWeatherNumber(weather.dewpoint_max))}
+
{TEXTS.visibility(formatVisibility(weather.visibility_min))}
+
{TEXTS.observations(formatWeatherNumber(weather.observations))}
+ +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/weather/WeatherDaysTable.tsx b/src/webparts/stroyBot/pages/weather/WeatherDaysTable.tsx new file mode 100644 index 0000000..f08b415 --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/WeatherDaysTable.tsx @@ -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 = ({ days }) => { + if (days.length === 0) { + return
{TEXTS.empty}
; + } + + return ( +
+ + + + + + + + + + + + + + + {days.map((day) => ( + + + + + + + + + + + ))} + +
{COLUMNS.date}{COLUMNS.observations}{COLUMNS.tempMin}{COLUMNS.tempMax}{COLUMNS.wind}{COLUMNS.gust}{COLUMNS.precipitation}{COLUMNS.phenomena}
{formatWeatherDate(day.date)}{formatWeatherNumber(day.observations)}{formatWeatherNumber(day.temp_min)}{formatWeatherNumber(day.temp_max)}{formatWeatherNumber(day.wind_max)}{formatWeatherNumber(day.gust_max)}{formatWeatherNumber(day.precipitation)} + +
+
+ ); +}; diff --git a/src/webparts/stroyBot/pages/weather/WeatherHeader.tsx b/src/webparts/stroyBot/pages/weather/WeatherHeader.tsx new file mode 100644 index 0000000..f655e71 --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/WeatherHeader.tsx @@ -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 = ({ projectTitle, start, end, onBack }) => { + const period = formatWeatherPeriod(start, end); + return ( + <> + { + event.preventDefault(); + onBack(); + }} + > + {WEATHER_TEXTS.back} + + +
+
{WEATHER_TEXTS.headerLabel}
+

{projectTitle}

+ {period && ( +
+ {WEATHER_TEXTS.periodPrefix} {period} +
+ )} +
+ + ); +}; diff --git a/src/webparts/stroyBot/pages/weather/WeatherHint.tsx b/src/webparts/stroyBot/pages/weather/WeatherHint.tsx new file mode 100644 index 0000000..071cc17 --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/WeatherHint.tsx @@ -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 = ({ text }) => ( +
+ {text} +
+); diff --git a/src/webparts/stroyBot/pages/weather/WeatherPage.module.scss b/src/webparts/stroyBot/pages/weather/WeatherPage.module.scss new file mode 100644 index 0000000..e1fe504 --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/WeatherPage.module.scss @@ -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; + } +} diff --git a/src/webparts/stroyBot/pages/weather/WeatherPage.tsx b/src/webparts/stroyBot/pages/weather/WeatherPage.tsx new file mode 100644 index 0000000..e34191e --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/WeatherPage.tsx @@ -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 ; + if (!period) return ; + if (status.kind === 'error') return {status.message}; + if (status.kind === 'ready') return ; + return ( +
+ +
+ ); + }; + + return ( +
+ + {renderBody()} +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/weather/WeatherPhenomena.tsx b/src/webparts/stroyBot/pages/weather/WeatherPhenomena.tsx new file mode 100644 index 0000000..44c8242 --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/WeatherPhenomena.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { Nullable } from './weatherTypes'; +import styles from './WeatherPage.module.scss'; + +interface Props { + phenomena: Nullable | undefined; +} + +/** Погодные явления (дождь, туман…) — каждое отдельной плашкой. */ +export const WeatherPhenomena: React.FC = ({ phenomena }) => { + if (!phenomena || phenomena.length === 0) return null; + return ( + <> + {phenomena.map((phenomenon, index) => ( +
+ {phenomenon} +
+ ))} + + ); +}; diff --git a/src/webparts/stroyBot/pages/weather/WeatherPositionsSection.tsx b/src/webparts/stroyBot/pages/weather/WeatherPositionsSection.tsx new file mode 100644 index 0000000..f53a1e2 --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/WeatherPositionsSection.tsx @@ -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 = ({ positions, assessment }) => { + const hasPositions = positions.length > 0; + const { isAssessing, errorMessage, summary, requestAssessment } = assessment; + + return ( +
+
+

{TEXTS.title}

+ +
+
+ + {errorMessage && {errorMessage}} + {!errorMessage && summary && ( +
{TEXTS.assessSummary(summary.assessed, summary.skipped)}
+ )} + + {!hasPositions &&
{TEXTS.empty}
} + {hasPositions && ( +
+ + + + + + + + + + + + + + + + + {positions.map((position, index) => ( + // У позиции нет id: строка — профессия в смену, порядок задаёт API. + + ))} + +
{COLUMNS.date}{COLUMNS.profession}{COLUMNS.shift}{COLUMNS.hours}{COLUMNS.hoursType}{COLUMNS.headcount}{COLUMNS.weather}{COLUMNS.assessment}{COLUMNS.comment}{COLUMNS.downtime}
+
+ )} +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/weather/WeatherReport.tsx b/src/webparts/stroyBot/pages/weather/WeatherReport.tsx new file mode 100644 index 0000000..0b8ce3a --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/WeatherReport.tsx @@ -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 = ({ data, assessment }) => { + const notes = data.notes || []; + return ( + <> + + +
+

{WEATHER_TEXTS.days.title}

+
+ +
+ + + + {notes.length > 0 && ( +
+

{WEATHER_TEXTS.notesTitle}

+
+
    + {notes.map((note, index) => ( +
  • {note}
  • + ))} +
+
+ )} + + ); +}; diff --git a/src/webparts/stroyBot/pages/weather/WeatherStation.tsx b/src/webparts/stroyBot/pages/weather/WeatherStation.tsx new file mode 100644 index 0000000..091564a --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/WeatherStation.tsx @@ -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 = ({ data }) => { + const distance = + data.wigos_station_distance_km !== null && data.wigos_station_distance_km !== undefined + ? TEXTS.distance(formatWeatherNumber(data.wigos_station_distance_km)) + : ''; + + return ( +
+ + + +
+
{TEXTS.name(data.wigos_station_name || WEATHER_TEXTS.empty)}
+
+ {TEXTS.code(data.wigos_station || WEATHER_TEXTS.empty)} + {distance} +
+
+
+ ); +}; diff --git a/src/webparts/stroyBot/pages/weather/texts.ts b/src/webparts/stroyBot/pages/weather/texts.ts new file mode 100644 index 0000000..9ad6ade --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/texts.ts @@ -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; diff --git a/src/webparts/stroyBot/pages/weather/useWeatherAssessment.ts b/src/webparts/stroyBot/pages/weather/useWeatherAssessment.ts new file mode 100644 index 0000000..969a586 --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/useWeatherAssessment.ts @@ -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(false); + const [errorMessage, setErrorMessage] = React.useState(''); + const [summary, setSummary] = React.useState(undefined); + const loadKey = loadKeyOf(data); + const loadKeyRef = React.useRef(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 }; +} diff --git a/src/webparts/stroyBot/pages/weather/useWeatherCheck.ts b/src/webparts/stroyBot/pages/weather/useWeatherCheck.ts new file mode 100644 index 0000000..878da20 --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/useWeatherCheck.ts @@ -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({ 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 }; +} diff --git a/src/webparts/stroyBot/pages/weather/weatherFormat.test.ts b/src/webparts/stroyBot/pages/weather/weatherFormat.test.ts new file mode 100644 index 0000000..0c3bd62 --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/weatherFormat.test.ts @@ -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); + }); +}); diff --git a/src/webparts/stroyBot/pages/weather/weatherFormat.ts b/src/webparts/stroyBot/pages/weather/weatherFormat.ts new file mode 100644 index 0000000..6a60045 --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/weatherFormat.ts @@ -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 | undefined): string { + return value === null || value === undefined ? EMPTY_VALUE : String(value).replace('.', ','); +} + +/** Видимость: от 1000 м — в километрах с одним знаком (1450 → «1,5 км»), меньше — в метрах. */ +export function formatVisibility(meters: Nullable | 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; +} diff --git a/src/webparts/stroyBot/pages/weather/weatherRepository.test.ts b/src/webparts/stroyBot/pages/weather/weatherRepository.test.ts new file mode 100644 index 0000000..a56aca2 --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/weatherRepository.test.ts @@ -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 = 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('Бетонщик')] }); + }); +}); diff --git a/src/webparts/stroyBot/pages/weather/weatherRepository.ts b/src/webparts/stroyBot/pages/weather/weatherRepository.ts new file mode 100644 index 0000000..4179af7 --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/weatherRepository.ts @@ -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 { + 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 { + 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; +} diff --git a/src/webparts/stroyBot/pages/weather/weatherTypes.ts b/src/webparts/stroyBot/pages/weather/weatherTypes.ts new file mode 100644 index 0000000..804880a --- /dev/null +++ b/src/webparts/stroyBot/pages/weather/weatherTypes.ts @@ -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 | 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; +} diff --git a/src/webparts/stroyBot/shared/components/InlineIcon.tsx b/src/webparts/stroyBot/shared/components/InlineIcon.tsx new file mode 100644 index 0000000..9407ac4 --- /dev/null +++ b/src/webparts/stroyBot/shared/components/InlineIcon.tsx @@ -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 = ({ name, className }) => ( + +);