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:
@@ -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=`
|
||||
|
||||
@@ -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** Поиск слева, «✕» очищает; «уже добавлено» у привязанных.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 => {
|
||||
window.history.pushState(undefined, '', buildHref(next));
|
||||
const navigate = React.useCallback((next: IRoute, options: INavigateOptions = {}): void => {
|
||||
if (options.replace) {
|
||||
window.history.replaceState(undefined, '', buildHref(next));
|
||||
} else {
|
||||
window.history.pushState(undefined, '', buildHref(next));
|
||||
}
|
||||
setRoute(next);
|
||||
window.scrollTo(0, 0);
|
||||
}, []);
|
||||
|
||||
@@ -17,4 +17,20 @@ describe('parseRoute', () => {
|
||||
it('адрес только с projectId=7', () => {
|
||||
expect(parseRoute('?projectId=7')).toEqual({ page: 'hub', projectId: '7' });
|
||||
});
|
||||
it('сверка погоды с периодом', () => {
|
||||
expect(parseRoute('?page=weather&projectId=7&start=2026-10-01&end=2026-10-07')).toEqual({
|
||||
page: 'weather',
|
||||
projectId: '7',
|
||||
start: '2026-10-01',
|
||||
end: '2026-10-07'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSearch', () => {
|
||||
it('параметры прошлого маршрута не переносятся, посторонние параметры страницы остаются', () => {
|
||||
expect(buildSearch({ page: 'weather', projectId: '7', start: '2026-10-01' }, '?debug=true&reportId=r1')).toEqual(
|
||||
'?debug=true&page=weather&projectId=7&start=2026-10-01'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
Reference in New Issue
Block a user