feat: weather conditions check from stroy-bot-main

Ports the weather check added to stroy-bot-main on 2026-10-16: daily
station weather, report positions with weather per shift and the AI
assessment of working conditions. Opens as ?page=weather&start=&end=
instead of the #weathercheck hash, and hub dates survive the way back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-20 09:47:02 +05:00
parent b18cf50efd
commit d606f8371c
33 changed files with 1365 additions and 12 deletions
+22
View File
@@ -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=`
+9
View File
@@ -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** Поиск слева, «✕» очищает; «уже добавлено» у привязанных.
+3
View File
@@ -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 <ResourcesPage />;
case 'binder':
return <BinderPage />;
case 'weather':
return <WeatherPage />;
case 'hub':
default:
return <HubPage />;
@@ -28,6 +28,10 @@ describe('navPageFor', () => {
expect(navPageFor('works')).toEqual('objects');
});
it('сверка погоды подсвечивает главную', () => {
expect(navPageFor('weather')).toEqual('hub');
});
it('остальные разделы подсвечивают сами себя', () => {
expect(navPageFor('reports')).toEqual('reports');
});
+7 -2
View File
@@ -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;
}
/**
@@ -1,10 +1,15 @@
import * as React from 'react';
import { buildSearch, IRoute, parseRoute } from './routes';
interface INavigateOptions {
/** Заменить текущую запись истории (replaceState), а не добавить новую. */
replace?: boolean;
}
interface IRouterValue {
route: IRoute;
/** Перейти в раздел: меняет адрес (history.pushState) и перерисовывает приложение. */
navigate: (route: IRoute) => void;
navigate: (route: IRoute, options?: INavigateOptions) => void;
/** Полный адрес раздела — для настоящих ссылок (Ctrl+клик, «открыть в новой вкладке»). */
buildHref: (route: IRoute) => string;
}
@@ -29,8 +34,12 @@ export const RouterProvider: React.FC<{ children: React.ReactNode }> = ({ childr
return () => window.removeEventListener('popstate', onPopState);
}, []);
const navigate = React.useCallback((next: IRoute): void => {
const navigate = React.useCallback((next: IRoute, options: INavigateOptions = {}): void => {
if (options.replace) {
window.history.replaceState(undefined, '', buildHref(next));
} else {
window.history.pushState(undefined, '', buildHref(next));
}
setRoute(next);
window.scrollTo(0, 0);
}, []);
@@ -17,4 +17,20 @@ describe('parseRoute', () => {
it('адрес только с projectId=7', () => {
expect(parseRoute('?projectId=7')).toEqual({ page: 'hub', projectId: '7' });
});
it('сверка погоды с периодом', () => {
expect(parseRoute('?page=weather&projectId=7&start=2026-10-01&end=2026-10-07')).toEqual({
page: 'weather',
projectId: '7',
start: '2026-10-01',
end: '2026-10-07'
});
});
});
describe('buildSearch', () => {
it('параметры прошлого маршрута не переносятся, посторонние параметры страницы остаются', () => {
expect(buildSearch({ page: 'weather', projectId: '7', start: '2026-10-01' }, '?debug=true&reportId=r1')).toEqual(
'?debug=true&page=weather&projectId=7&start=2026-10-01'
);
});
});
+26 -3
View File
@@ -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<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;
+26 -4
View File
@@ -2,6 +2,9 @@ import * as React from 'react';
import { Alert, Badge, BadgeVariant, Button, Icon, Spinner, TextInput } from '@tnsu/ui-kit';
import { IAccessibleProject } from '../../app/project/projectTypes';
import { isExportAvailable } from '../../app/project/projectRules';
import { useRouter } from '../../app/routing/RouterContext';
import { InlineIcon } from '../../shared/components/InlineIcon';
import { WEATHER_TEXTS } from '../weather/texts';
import { getReportType, isInvalidRange, ReportKind } from './exportReport';
import { useReportExport } from './useReportExport';
import { ExportInfoButton } from './ExportInfoButton';
@@ -20,17 +23,22 @@ interface Props {
project: IAccessibleProject | undefined;
}
/** Карточка «Выгрузить за период». Поля дат видны только при выбранном проекте. */
/**
* Карточка «Выгрузить за период» и кнопка «Сверка погодных условий». Поля дат видны только при выбранном проекте.
* Даты берутся из адреса, если вернулись со сверки погоды, — чтобы не выбирать период заново (как в main).
*/
export const ExportCard: React.FC<Props> = ({ project }) => {
const [dateFrom, setDateFrom] = React.useState<string>('');
const [dateTo, setDateTo] = React.useState<string>('');
const { route, navigate } = useRouter();
const [dateFrom, setDateFrom] = React.useState<string>(route.start ?? '');
const [dateTo, setDateTo] = React.useState<string>(route.end ?? '');
const { isExporting, errorMessage, exportReport } = useReportExport();
const isEnabled = !!project && isExportAvailable(project);
const hasBothDates = !!dateFrom && !!dateTo;
const isRangeInvalid = isInvalidRange(dateFrom, dateTo);
const reportType = getReportType(dateFrom, dateTo);
const canExport = isEnabled && hasBothDates && !isRangeInvalid && !isExporting;
const isPeriodReady = isEnabled && hasBothDates && !isRangeInvalid;
const canExport = isPeriodReady && !isExporting;
const onExport = (): void => {
if (project && canExport) {
@@ -38,6 +46,14 @@ export const ExportCard: React.FC<Props> = ({ project }) => {
}
};
const onOpenWeather = (): void => {
if (!project || !isPeriodReady) return;
const projectId = String(project.id);
// Сначала запоминаем даты в адресе главной: «назад» в браузере вернёт их в поля.
navigate({ page: 'hub', projectId, start: dateFrom, end: dateTo }, { replace: true });
navigate({ page: 'weather', projectId, start: dateFrom, end: dateTo });
};
return (
<div className={isEnabled ? styles.exportCard : `${styles.exportCard} ${styles.cardDisabled}`}>
<span className={styles.cardIcon} aria-hidden="true">
@@ -64,6 +80,12 @@ export const ExportCard: React.FC<Props> = ({ project }) => {
<Button variant="accent" onClick={onExport} disabled={!canExport}>
{isExporting ? <Spinner size="xSmall" label={TEXTS.buttonLoading} /> : TEXTS.button}
</Button>
<Button variant="secondary" onClick={onOpenWeather} disabled={!isPeriodReady}>
<span className={styles.buttonContent}>
<InlineIcon name="cloud" />
{WEATHER_TEXTS.openButton}
</span>
</Button>
</div>
)}
@@ -136,6 +136,12 @@ $card-min-width: 260px;
color: $gray-700;
}
.buttonContent {
display: inline-flex;
align-items: center;
gap: $sp-2;
}
.dateError {
font-size: $fs-sm;
color: $red-500;
@@ -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,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>
);