Compare commits

..

224 Commits

Author SHA1 Message Date
goodmice
04bd79d563
Исправлена ссылка на мониторинг в меню 2022-12-28 11:39:35 +05:00
goodmice
4b0adeeb7b
Исправлена ошибка перехода с сообщений на мониторинг 2022-12-28 11:37:04 +05:00
goodmice
3b0182216e
Белый IP DEV-сервера обновлён 2022-12-26 12:49:38 +05:00
goodmice
269b59560a
Добавлена секция работы с репозиторием 2022-12-23 17:12:02 +05:00
goodmice
c867c51bf5
Добавлена секция настройки ssh/gpg ключей 2022-12-23 17:11:46 +05:00
goodmice
5d64102a7c
Обновлено API WellOperationService 2022-12-23 14:45:13 +05:00
goodmice
d0c2774774
Удалено логгирование в useElementSize 2022-12-23 10:45:08 +05:00
goodmice
de58c81e87
* Описан хук useElementSize на основе ResizeObserver
* пакет usehooks-ts удалён
2022-12-22 12:33:01 +05:00
goodmice
860d74f2db
Merge branch 'dev' into feature/limit-parameter-staistics-window 2022-12-22 10:38:59 +05:00
ed43ebb082 Merge pull request 'Актуализировать обработку данных на странице "Операции".' (#4) from fix/changing-operation-categories-controller into dev
Reviewed-on: http://46.146.209.148:8080/DDrilling/asb_cloud_front/pulls/4
Reviewed-by: Александр Сироткин <av.sirotkin@digitaldrilling.ru>
2022-12-20 14:28:13 +05:00
44104f672b
Исправлена опечатка "загрзуить" -> "загрузить" 2022-12-20 12:53:39 +05:00
fce6c1909e
1. Изменена логика зависимостей в дочернем компоненте на странице "Операции".
2. Уменьшена длина поля "Верхняя граница", на странице "Операции".
2022-12-20 12:36:31 +05:00
c0b5f82ad2
Убран вызов метода получения списка категорий из дочернего компонента
на странице "Операции".
2022-12-20 12:04:58 +05:00
8d6f5ac1a5
На странице "Операции" изменен метод получения категорий операций. (Upd) 2022-12-20 11:27:37 +05:00
bef281feb8
На странице "Операции" изменен метод получения категорий операций. (Upd) 2022-12-20 11:23:56 +05:00
a79cac9d51
На странице "Операции" изменен метод получения категорий операций 2022-12-20 10:49:12 +05:00
878ab921c1 Merge pull request 'Добавлена кнопка скачивания "Выгрузка расширенной автоформируемой РТК"' (#1) from feature/add-download-button into dev
Reviewed-on: http://46.146.209.148:8080/DDrilling/asb_cloud_front/pulls/1
2022-12-19 18:24:56 +05:00
8154f2f0ba Merge branch 'dev' into feature/add-download-button 2022-12-19 18:24:47 +05:00
94707b3c9a Merge branch 'feature/add-download-button' of ssh://46.146.209.148:35222/DDrilling/asb_cloud_front into feature/add-download-button 2022-12-19 18:02:06 +05:00
8825c4c26c
Исправления по стилю кода 2022-12-19 17:56:14 +05:00
0e25e10785 Исправлена ошибка описания таблицы IP адресов 2022-12-19 17:43:31 +05:00
b063b5dcd9 Merge branch 'dev' into feature/add-download-button 2022-12-19 17:16:11 +05:00
969edda933 Merge pull request 'Исправлена опечатка на странице 404, “запрешён” -> “запрещён”.' (#2) from fix/spelling-errors into dev
Reviewed-on: http://46.146.209.148:8080/DDrilling/asb_cloud_front/pulls/2
2022-12-19 17:12:42 +05:00
3396050fae Merge branch 'dev' into fix/spelling-errors 2022-12-19 16:58:58 +05:00
d36cd1acbd Добавлена кнопка скачивания "Выгрузка расширенной автоформируемой РТК" 2022-12-19 15:25:38 +05:00
77d65be601 Исправлена опечатка на странице 404, “запрешён” -> “запрещён”. 2022-12-19 11:37:06 +05:00
16fec4e42f Обновлены команды в readme 2022-12-19 09:25:05 +05:00
923d469a86 Merge branch 'dev' into feature/documentation 2022-12-19 08:07:37 +05:00
8f52066bce Добавлена документация компонента LoaderPortal 2022-12-19 08:07:08 +05:00
bfd1e51cfa Контексты разнесены по файлам 2022-12-19 08:05:30 +05:00
0e63d93fa7 Добавлена первая версия стандарта кода 2022-12-19 08:05:00 +05:00
0ef6d67772 * Страница РТК приведена к новой модели
* РТК вырезано из мониторинга
* Удалена страница "Режимы"
* Удалено формирование новых режимов на странице "Композитная скважина"
2022-12-13 18:30:05 +05:00
f4adb528ca * Изменена сигнатура функций makeNumericColumn и её обёрток
* Улучшено отображение некоторых элементов
2022-12-13 18:28:30 +05:00
7af33d702d Документирована часть комментариев и функций 2022-12-13 15:29:19 +05:00
d235b01c80 Стили разделены по директориям 2022-12-08 12:13:58 +05:00
5af996f9e5 Стили вынесены в отдельный файл 2022-12-08 11:48:30 +05:00
65b9ede580 Файл страницы Наработки АКБ переименован 2022-12-08 11:18:42 +05:00
08fcf2736e Таблица на странице Наработка АКБ переработана в подсказку 2022-12-08 11:14:37 +05:00
28a0962793 Доработка страницы Наработки АКБ
* Добавлены цвета
* Добавлены иконки для подсистем
* Добавлена фильтрация на null
2022-12-08 10:55:11 +05:00
8f98cc066c Функция range ускорена в 5 раз 2022-12-08 08:26:10 +05:00
4b20a44d88 Добавлена базовая версия страницы 2022-12-06 04:44:03 +05:00
043f73fde3 Крошки переработаны 2022-12-06 00:56:02 +05:00
1a737b6afe Добавлена обработка в случае, когда не выбрано месторождение/куст/скважина 2022-12-06 00:51:41 +05:00
17d7b7c41d * Добавленна ссылка на месторождение в подсказке значка на карте
* Исправлено выделение месторождения в селекторе
2022-12-06 00:23:45 +05:00
4dd57aff98 Добавлено меню навигации для раздела месторождения 2022-12-05 22:49:55 +05:00
ebe3a50fbe WellTreeSelector доработан для работы с нецифровыми значениями в местах смены переменных 2022-12-05 21:07:17 +05:00
dc0f80fee5 Исправлено название для контекста списка месторождений 2022-12-05 20:39:14 +05:00
cb8be79274 Переработан PrivateMenu для работы с любыми указанными переменными 2022-12-05 20:35:29 +05:00
ee289cc619 Удалена колонка общего времени работы уставки 2022-11-29 12:17:17 +05:00
e430cdd5b4 Добавлена использование новых полей 2022-11-29 12:11:33 +05:00
540da341da Merge branch 'master' into dev 2022-11-28 10:15:21 +05:00
20b271d91e Логотип вынесен в отдельный SVG. Добавлен логотип АСБ на случай замены 2022-11-28 10:15:01 +05:00
259e2e4be8 Добавлен черновик окна статистики использования уставок 2022-11-28 10:13:40 +05:00
0cbd9559f2 Исправлена ошибка с ключами при отображении подсказки мониторинга 2022-11-28 07:35:42 +05:00
40d3a77c1c Исправлена работа истории программы бурения 2022-11-28 05:59:20 +05:00
7731dfe8e7 Добавлена кнопка оповещения ответственных по категориям на странице "Дело скважины" 2022-11-28 05:52:24 +05:00
7acb7ce2b2 Улучшено отображение РТК в подсказке мониторинга 2022-11-28 05:18:55 +05:00
0aeef42811 комбинация map и flat заменена на flatMap 2022-11-28 05:18:16 +05:00
5eb66e5fbc Упоминания archive устаранены 2022-11-28 05:17:33 +05:00
44afd5f1f0 * Данные РТК вынесены в отдельный проп графика
* Добавлена прозрачность РТК
* Добавлен метод расчёта данных для РТК
2022-11-22 09:26:44 +05:00
a2d641abdd Hotfix Отображение верхнего блока 2022-11-22 07:24:23 +05:00
51ac260c74 Исправлена работа крошек 2022-11-21 12:04:48 +05:00
16fb37910f Дополнены кофигурации webpack 2022-11-21 11:31:33 +05:00
de7e8fd259 Исправлена ошибка работы панели импорта/экспорта 2022-11-21 11:20:40 +05:00
a9aeeb6da3 Исправлена ошибка выхода из режима архива при резком прокручивании графика вниз 2022-11-21 11:02:50 +05:00
fe52116a9b Улучшено отображение при переходе в архив 2022-11-21 10:58:02 +05:00
d56810700f Страница архива удалена 2022-11-21 10:41:33 +05:00
e39ce8d410 Улучшено отображение автоопределяемых диапазонов 2022-11-21 10:40:47 +05:00
bd81aa0401 Добавлена фильтрация отображаемых данных на телеметрии 2022-11-21 10:40:27 +05:00
59b1d49286 Добавлена подгрузка данных для архива 2022-11-21 08:56:44 +05:00
871e71e777 Дата удалена из текущих значений 2022-11-21 08:49:21 +05:00
fc91cfc6ff Исправлена ошибка отображения регуляторов 2022-11-21 08:49:03 +05:00
3216a90af3 К кнопке копирования URL добавлена подсказка 2022-11-21 08:47:59 +05:00
11a632c246 * Мониторинг переписан на flex
* Блок текущих значений перемещён наверх и переписан
* Выделена строка управления графиком
* Удалена мнемосхема
2022-11-16 11:58:03 +05:00
b2c34d07a9 Добавлена replace для handleDataSaub 2022-11-16 11:29:53 +05:00
ec2513b4a0 Merge branch 'dev' into feature/merging-telemetry-view-and-archive 2022-11-16 11:29:06 +05:00
9c9fc63bc2 Изменён цвет фона заполнения новых режимов на странице композитной скважины 2022-11-15 10:32:47 +05:00
cb17c6868c "Давление" переименовано в "Перепад давления" на РТК и режимах 2022-11-15 10:06:05 +05:00
b2d241a8e7 Улучшено отображение на маленьких экранах 2022-11-13 14:25:03 +05:00
cc1c6a0661 * Получение списка месторождений вынесено в контекст для сокращения колличества запросов
* Обёртки для страниц вынесены в ленивую загрузку
* Страница месторождений и селекторы скважин переписаны с использованием контекста
* Улучшена мемоизация и оптимизированы расчёты
2022-11-11 00:06:11 +05:00
ac9b4d6c0d Датасеты телеметрий вынесены в отдельный файл 2022-11-10 23:45:35 +05:00
18f789c980 Стили сеток исправлены 2022-11-10 21:52:09 +05:00
6375db5a0b Merge branch 'dev' into feature/merging-telemetry-view-and-archive 2022-11-08 08:13:33 +05:00
aa0fafb7a1 * EditableCell улучшена мемоизация
* Добавлена поддержка составных ключей для столбцов таблиц
* Улучшена типизация
* Методы-генераторы столбцов переписаны
* Методы-генераторы сортировок переписаны с учётом составных ключей
2022-11-08 08:09:22 +05:00
bc73490029 Зависимость Table заменена с antd на @components/Table 2022-11-08 08:03:05 +05:00
goodmice
1ec74e92f1
Merge branch 'master' into dev 2022-11-07 07:13:16 +05:00
goodmice
a2ca2c3251
Merge branch 'master' of bitbucket.org:autodrilling/asb_cloud_front 2022-11-07 07:12:50 +05:00
goodmice
a04090db57
Исправлено наложение активных телеметрий 2022-11-07 07:12:44 +05:00
goodmice
f82b80d0d0
Доработано очищение графика телеметрии 2022-11-07 06:52:14 +05:00
9c85b1e229
Исправлена ошибка наложения телеметрий при активных скважинах 2022-11-07 06:52:10 +05:00
goodmice
4b63c26306
Merge branch 'master' into dev 2022-11-07 06:49:51 +05:00
goodmice
33b6a2012a
Доработано очищение графика телеметрии 2022-11-07 06:33:14 +05:00
bf2d6d6d36 Исправлена ошибка наложения телеметрий при активных скважинах 2022-11-07 05:38:31 +05:00
683ccb1c03 web-vitals удалён 2022-11-05 03:20:21 +05:00
Салихов Тимур
a0e488389b Merged in fix/file-download-page-fix (pull request #12)
На странице скачивания файла исправлено название файла и переход на предыдущую страницу

Approved-by: Александр Васильевич Сироткин
2022-10-31 09:30:18 +00:00
goodmice
45c63317a2
Исправлена ошибка фильтрации суточных рапортов 2022-10-31 13:52:33 +05:00
ts_salikhov
a4db41fdd4 Merge branch 'dev' into fix/file-download-page-fix
# Conflicts:
#	src/pages/FileDownload.jsx
2022-10-31 11:30:03 +04:00
17ccecb2dd Выделены методы генерации датасетов для графика мониторинга 2022-10-31 05:12:33 +05:00
c8753378eb Исправлена ошибка в назначении Breadcrumb.Item 2022-10-31 05:10:28 +05:00
685191484a Добавлена подсветка совпадения в поиске 2022-10-31 04:06:34 +05:00
e773943b61 Добавлена панель быстрого перехода между страницами 2022-10-31 01:18:16 +05:00
goodmice
11cb245cf5
Исправлена ошибка работы WellOperations 2022-10-27 16:48:58 +05:00
goodmice
6024c7ca64
Вырезан web-vitals 2022-10-27 16:48:44 +05:00
goodmice
811d48a47b
Скорректированно название HOC и удалены лишние экспорты 2022-10-27 15:33:12 +05:00
ts_salikhov
ccbc0e1938 Обновлен роут для компонента FileDownload 2022-10-27 10:35:32 +04:00
ts_salikhov
bea165d76e Merge branch 'dev' into fix/file-download-page-fix
# Conflicts:
#	src/pages/FileDownload.jsx
2022-10-27 10:28:59 +04:00
goodmice
b7317a02d5
Улучшено отображение логотипа в меню 2022-10-25 19:32:52 +05:00
goodmice
d7669a2317
Merge remote-tracking branch 'origin/dev' 2022-10-25 19:05:26 +05:00
goodmice
dcd37177a1
Импорт/экспорт на план/факт перенесён справа от крошек 2022-10-25 19:00:14 +05:00
goodmice
2072ac2072
Экспорты TVD перенесены справа от крошек 2022-10-25 18:59:44 +05:00
goodmice
c3d53284bd
* Добавлены крошки в разделе админки, куста и скважины
* Улучшены стили на страницах скважины
2022-10-25 18:59:10 +05:00
goodmice
879fea41a5
Добавлена возможность отображения крошек 2022-10-25 18:57:23 +05:00
goodmice
26222104b7
Доработано лого (добавлен режим без текста) 2022-10-25 18:53:37 +05:00
goodmice
0948c2a602
Улучшены типы 2022-10-25 18:51:39 +05:00
44543fec31 Добавлено обновление данных пользователя, если они отсутствуют в хранилище 2022-10-24 12:18:38 +05:00
0797c20ff4 Исправлен манифест 2022-10-24 09:35:57 +05:00
2bfdd4385f Добавлено заполение средних значений на режимах композитной скважины 2022-10-24 09:35:31 +05:00
121cb83d83 Улучшена работа с хранилищем 2022-10-24 07:35:56 +05:00
e852ede73c Добавлен горизонтальный скролл для таблиц (+адаптивности на малых мониторах) 2022-10-24 07:12:36 +05:00
e52013a685 До решения проблемы с высотой активной страницы убран отступ и изменён цвет фона 2022-10-24 06:55:06 +05:00
1337656828 Добавлена провера разрешений из старого варианта хранилища 2022-10-24 06:42:08 +05:00
1b9db1c35a Блок импорта/экспорта операций перемещён на страницы план/факт 2022-10-24 06:41:30 +05:00
goodmice
a1f1ce1915
Подготовка к обновлению AntD и мелкие исправления 2022-10-21 10:35:13 +05:00
ts_salikhov
5dd1fc8258 Убрана информация об id скважины 2022-10-19 15:19:19 +04:00
ts_salikhov
53a1d33a55 На странице скачивания файла исправлено название файла и переход на предыдущую страницу 2022-10-17 17:19:13 +04:00
ts_salikhov
1a4189901a На странице скачивания файла исправлено название файла и переход на предыдущую страницу 2022-10-17 16:59:45 +04:00
goodmice
91556aaf81
Merge branch 'dev' into feature/sider-navigation-menu 2022-10-17 14:39:40 +05:00
goodmice
85b17ccf66
удалён аргумент idMark в getFilesInfo 2022-10-17 14:39:02 +05:00
goodmice
530001d110
Merge branch 'dev' into feature/sider-navigation-menu 2022-10-17 14:31:08 +05:00
goodmice
af031d94d7
Merge branch 'dev' of bitbucket.org:autodrilling/asb_cloud_front into dev 2022-10-17 14:30:41 +05:00
goodmice
1a02b39e52
Обновлено использование FileService 2022-10-17 14:30:35 +05:00
goodmice
d14513e249
Подготовка к обновлению AntD и мелкие исправления 2022-10-13 18:49:26 +05:00
goodmice
718d32f2b9
* Навигационное меню скважины перенесено в соответствующую директорию
* LayoutPortal переделан в обёртку для сокращения повторений
* Добавлен контекст и хук для обновления параметров LayoutPortal
2022-10-13 18:16:43 +05:00
goodmice
fc8b351b7c
* Связанные со скважиной страницы перемещены в общую директорию
* Роутинг в разделе скважины перенесён в главный файл раздела
2022-10-13 16:59:16 +05:00
goodmice
93e6d2171d
Общий метод форматирования координат вынесен в утилиты 2022-10-13 16:45:30 +05:00
goodmice
847cfce2b6
* Роутинг по возможности перенесён в корневые файлы разделов
* В разделе скважины и панели администратора добавлены
* Операции по скважине разнесены на разные файлы
2022-10-13 16:35:19 +05:00
Салихов Тимур
c9885e4603 Merged in feature/add-page-operation-time (pull request #10)
Доработка странице Наработка

Approved-by: Александр Васильевич Сироткин
2022-10-13 10:34:02 +00:00
ts_salikhov
fee0849ea4 Доработан график D3HorizontalChart 2022-10-13 14:28:06 +04:00
goodmice
bd8962df26
* Переработан LayoutPortal
* Переработан профиль пользователя
* Переработана система организации ссылок меню
* Новый LayoutPortal добавлен на все страницы
* Изменён редирект со страницы загрузки файла
2022-10-13 14:31:11 +05:00
goodmice
60118f9327
* Публичные старницы перемещены в общую директорию
* SuspenseFallback перемещён в компоненты
* Добавлена ленивая загрузка основных страниц
2022-10-13 14:00:17 +05:00
goodmice
9c2b0ecd26
Удалены старые меню 2022-10-13 13:45:32 +05:00
goodmice
411b79ee60
Вынесены стили для нового каркаса и меню пользователя 2022-10-13 13:11:07 +05:00
goodmice
ddc6f7840d
Добавлено хранение данных пользователя в localStorage для использования в контексте 2022-10-13 13:10:19 +05:00
ts_salikhov
a574b78cdb На странице Наработка добавлен сброс даты и выделение данных при наведении 2022-10-13 09:44:59 +04:00
ts_salikhov
83666424ad На странице Наработка добавлен сброс даты и выделение данных при наведении 2022-10-12 17:37:45 +04:00
ts_salikhov
d43e7d3da9 Merge branch 'dev' into feature/add-page-operation-time 2022-10-12 17:30:16 +04:00
ts_salikhov
116de6e912 На странице Наработка добавлен сброс даты и выделение данных при наведении 2022-10-12 16:45:34 +04:00
goodmice
8c2c1b7913
Улучшена TelemetryView и страница просмотра телеметрий 2022-10-11 17:36:00 +05:00
ts_salikhov
6455be0891 На странице Наработка добавлен сброс даты и выделение данных при наведении 2022-10-11 11:05:53 +04:00
ts_salikhov
1b23ee3437 На странице Наработка добавлен сброс даты и выделение данных при наведении 2022-10-11 01:27:27 +04:00
goodmice
978a26e455
Исправлен стиль файлов стилей 2022-10-10 13:47:42 +05:00
goodmice
c1dced00f7
Скрыт console.log на WellTreeSelector 2022-10-07 14:55:22 +05:00
goodmice
3d98a2c6df
Исправлена привязка полей DailyReport 2022-10-07 11:49:21 +05:00
ts_salikhov
bbf15c1f35 На странице Наработка добавлен сброс даты и выделение данных при наведении 2022-10-06 18:20:54 +04:00
goodmice
38004088a7
Исправлена ошибка парсинга пути при переходе через WellTreeSelector 2022-10-06 15:21:25 +05:00
goodmice
61d71899db
Убран сброс пути при выборе месторождения/куста/скважины 2022-10-06 15:14:44 +05:00
goodmice
836bcd583d
Стандартизирован вид даты в подсказке графиков телеметрии 2022-10-06 15:14:01 +05:00
goodmice
70a04ad228
Кнпока ограничения подачи временно скрыта в редакторе графиков 2022-10-06 14:48:26 +05:00
goodmice
7f2d337b4f
* Выделен стиль блока фильтрации
* Исправлена работа страница "Наработка"
2022-10-06 14:37:39 +05:00
goodmice
39c1289d32
Исправлена типизация методов работы с колонками таблиц 2022-10-06 14:37:07 +05:00
goodmice
336fe6e0d4
Исправлена работа компонента D3HorizontalPercentChart 2022-10-06 14:36:26 +05:00
goodmice
2aca41da83
Исправлены пропы при использований D3MonitoringCurrentValues 2022-10-06 12:37:35 +05:00
goodmice
eb9d85c1a4
Исправлены стили страницы Сообщений 2022-10-06 11:47:48 +05:00
goodmice
799fff7c0e
Исправлено отображение размера в таблицах документов 2022-10-06 11:47:05 +05:00
goodmice
443f14c0a8
Улучшена типизация таблиц и методов работы с ними 2022-10-06 11:46:48 +05:00
goodmice
c33674c6c5
Улучшено отображений текущих значчений на графиках 2022-10-06 11:45:42 +05:00
goodmice
d3fd851e20
Улучшено позиционирование подсказки о скважине в сообщениях с ошибкой 2022-10-06 11:19:56 +05:00
goodmice
6a97da855d
Задана минимальная высота TVD 2022-10-05 15:33:05 +05:00
ts_salikhov
321900da89 Merge branch 'dev' into feature/add-page-operation-time 2022-10-05 14:28:54 +04:00
goodmice
84204ab146
Разделены конфигурации webpack 2022-10-05 15:28:27 +05:00
ts_salikhov
be7e76acf7 На странице Наработка добавлен сброс даты и выделение данных при наведении 2022-10-05 14:20:39 +04:00
ts_salikhov
74b309ee87 Доработан график D3HorizontalChart 2022-10-04 13:55:18 +04:00
goodmice
8db523b6de
Добавлено сворачивание месторождений кроме выбранного в селекторе скважин на странице месторождений 2022-10-03 21:35:26 +05:00
goodmice
34c9b81e77
Добавлена обработка ошибок при скачивании суточного рапорта и изменён формат даты 2022-10-03 20:40:13 +05:00
goodmice
b02b828cb6
Исправлена привязка полей к dto в Суточном рапорте 2022-10-03 20:37:48 +05:00
goodmice
b2feb95e82
Обновлены пакеты 2022-10-03 20:17:58 +05:00
goodmice
01f499b85d
Merge branch 'dev' of bitbucket.org:autodrilling/asb_cloud_front into dev 2022-10-03 15:59:58 +05:00
ts_salikhov
a965a9b693 Исправлена работа фильтра подсистем 2022-10-03 13:47:34 +04:00
ngfrolov
e147f44032 Merge branch 'feature/add-page-operation-time' into dev 2022-09-29 17:48:04 +05:00
Салихов Тимур
66d801fb80 Merged in fix/fixed-display-data-in-table-on-page-modes (pull request #9)
Fix/fixed display data in table on page modes

Approved-by: Никита Фролов
2022-09-29 12:43:00 +00:00
ts_salikhov
9d5c38f984 Исправлено отображение данных в таблице "Заполнить режимы текущей скважины" и в таблице на странице "Режимы" 2022-09-29 16:28:14 +04:00
ts_salikhov
38fa8ffbeb Исправлено отображение данных в таблице "Заполнить режимы текущей скважины" и в таблице на странице "Режимы" 2022-09-29 16:26:55 +04:00
ts_salikhov
c82fec8f60 Исправлено отображение данных в таблице "Заполнить режимы текущей скважины" и в таблице на странице "Режимы" 2022-09-29 13:37:48 +04:00
ts_salikhov
e0f8583f99 Исправлено отображение данных в таблице "Заполнить режимы текущей скважины" и в таблице на странице "Режимы" 2022-09-29 13:36:07 +04:00
ngfrolov
1f8bf12821 OperationTime. Adapt Dto. 2022-09-28 18:04:55 +05:00
ts_salikhov
0ce979fdc8 Исправлено отображение данных в таблице "Заполнить режимы текущей скважины" и в таблице на странице "Режимы" 2022-09-28 14:33:34 +04:00
ts_salikhov
b74d6d1e4f Исправлено отображение данных в таблице "Заполнить режимы текущей скважины" и в таблице на странице "Режимы" 2022-09-26 17:23:44 +04:00
goodmice
f038e5e36e
Улучшены стили страницы мониторинга 2022-09-24 11:37:42 +05:00
ts_salikhov
95950458c8 Исправлено отображение данных в таблице "Заполнить режимы текущей скважины" и в таблице на странице "Режимы" 2022-09-23 16:23:10 +04:00
ts_salikhov
292d4e0ef0 Исправлено отображение данных в таблице "Заполнить режимы текущей скважины" и в таблице на странице "Режимы" 2022-09-22 17:38:43 +04:00
ts_salikhov
564ea43a80 Добавлена страница "Наработка" 2022-09-20 12:39:05 +04:00
goodmice
d49a6e1df3 Улучшена адаптивность элементов на странице операций 2022-09-15 16:52:13 +05:00
goodmice
9017ddf835 Стилистические изменения Дела скважины 2022-09-13 16:53:12 +05:00
goodmice
6588e3accb Улучшено отображение блока заказчика 2022-09-13 16:46:02 +05:00
goodmice
664e3aa57a Закончена первая версия Дела скважины 2022-09-13 16:21:57 +05:00
goodmice
6c52a091c9 Стилистические изменения 2022-09-13 16:21:30 +05:00
goodmice
e610ab2768 Добавлен проп multiple в UploadForm 2022-09-13 16:17:32 +05:00
goodmice
ea4dd1dfe0 Добавлены стили "Дело скважины" 2022-09-13 16:15:02 +05:00
goodmice
6a74544901 Merge branch 'dev' into feature/well-case 2022-09-12 14:45:18 +05:00
goodmice
922a329e76 Изменено название страницы и логотип в шапке 2022-09-12 13:22:14 +05:00
goodmice
eb8cf1f1d4 Изменено название страницы и логотип в шапке 2022-09-12 13:21:37 +05:00
goodmice
e7306eae79 Исправлена работа очистки операций перед импортом 2022-09-12 12:42:16 +05:00
goodmice
bd7c2842c5 Исправлена работа очистки операций перед импортом 2022-09-12 12:40:59 +05:00
goodmice
c387ded5ce "Дело скважины" добавлено в меню 2022-09-12 12:30:41 +05:00
goodmice
fc5b7d217a Добавлены скелеты страниц "Дело скважины" 2022-09-12 12:30:27 +05:00
goodmice
a22b043af9 Улучшен вид UserView 2022-09-12 12:30:07 +05:00
goodmice
567e6c4510 Добавлен метод для создания задержки в асинхронных функциях 2022-09-12 12:29:40 +05:00
goodmice
c3200427e4 Merge branch 'dev' of bitbucket.org:autodrilling/asb_cloud_front into dev 2022-09-09 11:38:29 +05:00
goodmice
77cf9ab794 Укорочены названия графиков на Телеметрии 2022-09-09 11:38:01 +05:00
Салихов Тимур
3045bf10e6 Merged in feature/fix-tooltip-width (pull request #8)
Исправлена ширина всплывающей подсказки на графике Телеметрия - Мониторинг

Approved-by: Александр Васильевич Сироткин
2022-09-08 06:31:02 +00:00
ts_salikhov
d792f75861 Исправлена ширина всплывающей подсказки на графике Телеметрия - Мониторинг 2022-09-06 19:39:26 +04:00
ts_salikhov
9d0ddc6f21 Исправлена ширина всплывающей подсказки на графике Телеметрия - Мониторинг 2022-09-06 18:58:20 +04:00
ts_salikhov
0e6a0888d8 Исправлена ширина всплывающей подсказки на графике Телеметрия - Мониторинг 2022-09-06 14:53:44 +04:00
ts_salikhov
cc1eb706ea Исправлена ширина всплывающей подсказки на графике Телеметрия - Мониторинг 2022-09-06 10:37:40 +04:00
ts_salikhov
2a490243d7 Исправлена ширина всплывающей подсказки на графике Телеметрия - Мониторинг 2022-09-06 10:34:52 +04:00
ts_salikhov
bc8dbaf113 Исправлена ширина всплывающей подсказки на графике Телеметрия - Мониторинг 2022-09-05 17:17:39 +04:00
Салихов Тимур
2dbc9720bb Merged in feature/edit-tooltips-on-graphic (pull request #7)
Исправлено отображение подсказок для графика TVD - статистика

Approved-by: Александр Васильевич Сироткин
2022-09-05 11:39:19 +00:00
Александр Васильевич Сироткин
bd803c2f46 Merged dev into feature/edit-tooltips-on-graphic 2022-09-05 11:38:48 +00:00
Салихов Тимур
30a73184f4 Merged in feature/rearranging-rows-in-dimensions-tab (pull request #6)
Исправлено отображение строк в таблицах во вкладке Измерения

Approved-by: Александр Васильевич Сироткин
2022-09-05 11:30:36 +00:00
ts_salikhov
a41f5d4bf5 Исправлено отображение строк в таблицах во вкладке Измерения 2022-09-05 15:18:36 +04:00
ts_salikhov
fbe86e23f6 Исправлено отображение строк в таблицах во вкладке Измерения 2022-09-05 14:51:43 +04:00
ts_salikhov
11187fa937 Исправлено отображение подсказок для графика TVD - статистика 2022-09-02 14:53:59 +04:00
goodmice
c8050b91e5 Исправлено наложение телеметрий при смене скважины
* Добавлено отображение загрузки графика
2022-08-23 17:44:02 +05:00
ts_salikhov
aceedd8dee Исправлено отображение строк в таблицах во вкладке Измерения 2022-08-23 15:55:48 +04:00
279 changed files with 17744 additions and 10054 deletions

4
.gitignore vendored
View File

@ -11,8 +11,10 @@
# testing # testing
/coverage /coverage
# production # build directories
/build /build
/dev_build
/prod_build
# misc # misc
.DS_Store .DS_Store

15
.vscode/settings.json vendored
View File

@ -1,6 +1,17 @@
{ {
"cSpell.words": [ "cSpell.words": [
"день" "день",
"спиннера",
"Saub",
"КНБК",
"САУБ",
"antd",
"Poprompt",
"saub",
"setpoint",
"Setpoints",
"usehooks"
], ],
"liveServer.settings.port": 5501 "liveServer.settings.port": 5501,
"cSpell.language": "en,ru"
} }

191
CODE_STANDART.md Normal file
View File

@ -0,0 +1,191 @@
## 1. Общие положения
1. Все несамостоятельные компоненты должны быть написаны на TypeScript. Для самостоятельных компонентов (использующихся как страницы) (далее страницы) допускается использование JavaScript для ускорения написания;
### 1.1. Файловая структура проекта
1. Компоненты должны распределяться по директориям в соответствии со своим назначением:
* `src/context` - Для контекстов приложения;
* `src/components` - Для несамостоятельных компонентов, применяющихся многократно;
* `src/pages` - Для страниц и компонентов, использующихся исключительно в единственном экземпляре;
* `src/images` - Для компонентов-изображений.
2. Если страница описывается 1 файлом она должна именоваться в соответствии с содержимым, в ином случае должна быть создана директория с соответствующим названием, внутри которой будут находиться файлы страницы. Основной файл в таком случае должен быть переименован в `index.jsx`;
3. Файлы именуются в соответствии с таблицей:
| Тип содержимого файла | Расширение | Стиль именования |
|--------------------------------------|------------|--------------------------|
| Компонент или страница | jsx/tsx | **PascalCase** |
| Файл стилей | css/less | **snake_case** |
| Вспомогательные методы или константы | js/ts | **snake_case** |
| Описательные документы | md | **SCREAMING_SNAKE_CASE** |
### 1.2. Стилизация кода
1. Все строки должны по возможности описываться одинарными кавычками или при необходимости обратными:
```js
const name = 'world'
const msg = 'Hello, \'' + name + '\'!'
const toPrint = `Message: ${msg}`
```
2. Все переменные по возможности должны инициализироваться как `const`, применение `var` не допускается;
3. Переменные именуются в соответствии с таблицей:
| Тип переменной | Стиль именования |
|-------------------|--------------------------|
| Метод, переменная | **camelCase** |
| Константы | **SCREAMING_SNAKE_CASE** |
| Компонент | **PascalCase** |
### 1.3. Импортирование / Экспортирование
1. Импортированные файлы (в том числе lazy import) необходимо указывать в самом верху документа в следующем порядке с разделением пустой строкой:
1. Внешние зависимости (`react`, `antd`, `webpack` и т.д.);
2. Локальные компоненты по порядку:
1. Контексты (`@asb/context`);
2. Компоненты (`@components/Table`);
3. Вспомогательные методы (`@utils`);
4. Сервисы API (`@api`).
3. Изображения и компоненты-изображения (`@images`);
4. Стили (`@styles`);
5. Lazy import (`const page = React.lazy(() => import('./page'))`).
2. При импорте локальных файлов стоит пользоваться alias'ами:
| Путь | Alias |
|------------------|--------------|
| src/components | @components |
| src/context | @asb/context |
| src/images | @images |
| src/pages | @pages |
| src/services/api | @api |
| src/styles | @styles |
| src/utils | @utils |
3. По возможности импортировать из пакетов и файлов только использующиеся сущности:
```tsx
// вместо
import React from 'react'
const page: React.ReactNode = React.lazy(() => import (...))
// стоит использовать
import { lazy, ReactNode } from 'react'
const page: ReactNode = lazy(() => import (...))
```
### 1.4. Работа с репозиторием
#### 1.4.1. Подготовка к публикации работы по заданию
При получений задания необходимо создать для неё ветку, наследуемую от **dev**.
Ветка должна именоваться в **kebab-case** и иметь префикс соответствующий типу задачи:
* "**feature/**" - для нового функционала или визуала;
* "**fix/**" - для багов и любых исправлений.
Название ветки должно кратко описывать проблему или новые возможности.
Далее необходимо создать *pull request* на ветку dev от новосозданной и сразу отметить его как WIP.
При завершении задания метку WIP необходимо снять.
#### 1.4.2 Оформление коммита
Изменения файлов необходимо разделять на коммиты по общим изменениям и соответствующе его именовать.
Если в коммит попадает более одного логического изменения стоит указывать их в виде маркированного списка, например:
```
* На странице "Мониторинг" и "Архив" сокращено колличество запросов;
* Страница "Сообщения" удалена.
```
## 2. JS
1. Методы, константы и переменные документируются в соответствии с `JSDoc`;
2. При документации страниц необходимо указать её название, краткое описание и описание получаемых параметров:
```jsx
import { memo } from 'react'
import LoaderPortal from '@components/LoaderPortal'
/**
* Тестовая страница
*
* @description Данная страница не имеет смысла и просто выводит переданное название и контент
* @param title - Название страницы
* @param content - Контент страницы
* @param loading - Отображать ли оверлей загрузки над блоком страницы
*/
export const TestPage = memo(({ title, content, loading }) => (
<LoaderPortal show={loading}>
<div className={'dd-test-page'}>
<div className={'dd-test-page-title'}>{title}</div>
<div className={'dd-test-page-content'}>{content}</div>
</div>
</LoaderPortal>
))
export default TestPage
```
## 3. TS
1. Методы, константы и переменные документируются в соответствии с `TSDoc`;
2. При документации компонентов необходимо указать их название, краткое описание, а также описать параметры в типе:
```tsx
import { memo } from 'react'
import LoaderPortal from '@components/LoaderPortal'
export type TestPageProps = {
/** Название страницы */
title: ReactNode
/** Контент страницы */
content: ReactNode
/** Отображать ли оверлей загрузки над блоком страницы */
loading: boolean
}
/**
* Тестовая страница
*
* @description Данная страница не имеет смысла и просто выводит переданное название и контент
*/
export const TestPage = memo<TestPageProps>(({ title, content, loading }) => (
<LoaderPortal show={loading}>
<div className={'dd-test-page'}>
<div className={'dd-test-page-title'}>{title}</div>
<div className={'dd-test-page-content'}>{content}</div>
</div>
</LoaderPortal>
))
export default TestPage
```
3. Использование `any` в типах допустимо только, если значение используется только в параметрах компонентов, обозначенных типом `any`. Если метод предполагает работу с разными типами значений стоит описать его как обобщённый.
## 4. JSX/TSX
### 4.1. Стилизация кода
1. Все указываемые к компоненту параметры должны быть обёрнуты в фигурные скобки, кроме параметров флагов со значением `true`:
```jsx
<Button disabled title={'Hello, world!'} type={'ghost'}>Click me!</Button>
```
2. Если описание параметров компонента не укладывается в ширину в 120 строк стоит перенести их в соответствии с шаблоном:
```jsx
<Button
disabled
title={'Hello, world!'}
type={'ghost'}
>
Click me!
</Button>
```
3. Если JSX код передаётся как значение стоит обернуть его в круглые скобки:
```jsx
const a = (
<Button disabled title={'Hello, world!'} type={'ghost'}>Click me!</Button>
)
```
### 4.2. Логика поведения
1. Не допускается создание значений ссылочных типов в области рендера. Они должны быть вынесены в переменные или константы;
2. Не допускается создание переменных в функциональных компонентов без использования хуков `useMemo`/`useCallback`/`useState`;
3. Если переменные или методы не имеют зависимостей и не вызывают методы, доступные исключительно внутри компонента, они должны быть вынесены выше кода компонента.
## 5. LESS
1. Использование id должно быть сведено к минимуму;
2. Все классы именуются с префиксом компании "`dd-`";
3. Слова в классах разделяются тире ("`-`");
4. Файлы именуются в соответствии с компонентом, к которому относятся;
5. В одном файле описываются стили либо к конкретному компоненту, либо к странице;
6. Файл со стилями должен подключаться не более чем к одному компоненту (странице);
7. Файлы поделены на директории виду компонента, к которому применяются стили:
* `styles/components` - для компонентов, не использующихся самостоятельно;
* `styles/pages` - для компонентов, использующихся как страница;
* `styles/widgets` - для компонентов, применяющихся как виджеты в дашбордах.

View File

@ -7,7 +7,7 @@
Установка выполняется одной командой: Установка выполняется одной командой:
```bash ```bash
npm i npm ci
``` ```
## 2. Автогенерация сервисов ## 2. Автогенерация сервисов
@ -17,14 +17,14 @@ npm i
Автогенерацию можно запустить с помощью уже прописанных в [package.json](package.json) скриптов, либо вручную. Автогенерацию можно запустить с помощью уже прописанных в [package.json](package.json) скриптов, либо вручную.
Если сервер запущен на текущей машине достаточно написать: Если сервер запущен на текущей машине достаточно написать:
```bash ```bash
npm run update_openapi npm run oul
``` ```
Для получения сервисов с основного сервера: Для получения сервисов с основного сервера:
```bash ```bash
npm run update_openapi_server npm run oug_dev
``` ```
или же ручной вариант: или же ручной вариант:
@ -36,12 +36,12 @@ npx openapi -i http://{IP_ADDRESS}:{PORT}/swagger/v1/swagger.json -o src/service
На данный момент имеются следующие IP-адреса: На данный момент имеются следующие IP-адреса:
| IP-адрес | Описание | | IP-адрес | Команда | Описание |
|:-|:-| |:-------------------------|:--------|:------------------------------------|
| 127.0.0.1:5000 | Локальный адрес вашей машины (привязан к `update_openapi`) | | 127.0.0.1:5000 | oul | Локальный адрес вашей машины |
| 192.168.1.70:5000 | Локальный адрес development-сервера (привязан к `update_openapi_server`) | | 192.168.1.113:5000 | oud | Локальный адрес development-сервера |
| 46.146.209.148:89 | Внешний адрес development-сервера | | 46.146.207.184:80 | oug_dev | Внешний адрес development-сервера |
| 46.146.209.148 | Внешний адрес production-сервера | | cloud.digitaldrilling.ru | oug | Внешний адрес production-сервера |
## 3. Компиляция production-версии приложения ## 3. Компиляция production-версии приложения
После выполнения вышеописанных пунктов приложение готово к компиляции. После выполнения вышеописанных пунктов приложение готово к компиляции.
@ -60,3 +60,53 @@ npm run build
```bash ```bash
npm start npm start
``` ```
## 5. Подготовка к работе с гит репозиторием
### 5.1. Генерация SSH-ключей
Для генерации ключей, в **Git Bash**, либо в **bash** консоли необходимо ввести команду:
```bash
ssh-keygen
```
Предложенный путь сохранения ключа оставить без изменений
Пароль для ускорения работы можно не задавать
После чего публичный ключ необходимо занести ключ в [Gitea](http://46.146.207.184:8080/), в настройках пользователя.
Чтобы получить публичный ключ необходимо ввести в консоли команду:
```bash
cat ~/.ssh/id_rsa.pub
```
Далее ключ небходимо проверить, для этого необходимо нажать соответствующую кнопку в Gitea, скопировать и выполнить предложенную команду в консоли, после чего вывод вставить в поле на странице.
### 5.2. Генерация GPG-ключей
Для генерации ключей, в **Git Bash**, либо в **bash** консоли необходимо ввести команду:
```bash
gpg --full-generate-key
```
Тип ключа выбираем *RSA and RSA* (по умолчанию 1). Длину ключа рекомендуется задавать 4096. Далее необходимо заполнить все опрошенные данные, пароль оставить пустым.
После чего публичный ключ необходимо занести ключ в [Gitea](http://46.146.207.184:8080/), в настройках пользователя.
Чтобы получить публичный ключ необходимо ввести в консоли команду:
```bash
gpg --export --armor <email>
```
Где вместо `<email>` необходимо подставить электронную почту, указанную к ключу.
Далее ключ небходимо проверить, для этого необходимо нажать соответствующую кнопку в Gitea, скопировать и выполнить предложенную команду в консоли, после чего вывод вставить в поле на странице.
### 5.3. Настройка подписания коммитов (требуется GPG-ключ)
Перед началом необходимо получить ID GPG-ключа, для этого выполним команду:
```bash
gpg --list-keys <email>
```
Где вместо `<email>` необходимо подставить электронную почту, указанную к ключу. Из полученного вывода нам нужна только строка под строкой `rsa4096`. Эту строку мы передадим в следующую команду на место `<key-id>`:
```bash
git config --user.signingkey <key-id>
```

15908
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,20 +11,24 @@
"react": "^18.1.0", "react": "^18.1.0",
"react-dom": "^18.1.0", "react-dom": "^18.1.0",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"rxjs": "^7.5.5", "rxjs": "^7.5.5"
"usehooks-ts": "^2.6.0",
"web-vitals": "^2.1.4"
}, },
"scripts": { "scripts": {
"start": "webpack-dev-server --mode=development --open --hot",
"build": "webpack --mode=production",
"test": "jest", "test": "jest",
"oul": "npx openapi -i http://localhost:52123/swagger/v1/swagger.json -o src/services/api", "build": "webpack --env=\"ENV=prod\"",
"oud": "npx openapi -i http://192.168.1.70:5000/swagger/v1/swagger.json -o src/services/api", "dev_build": "webpack --env=\"ENV=dev\"",
"oug": "npx openapi -i http://46.146.209.148/swagger/v1/swagger.json -o src/services/api", "prod_build": "webpack --env=\"ENV=prod\"",
"oug_dev": "npx openapi -i http://46.146.209.148:89/swagger/v1/swagger.json -o src/services/api"
"start": "webpack-dev-server --env=\"ENV=dev\" --open --hot",
"prod": "webpack-dev-server --env=\"ENV=prod\" --open --hot",
"dev": "webpack-dev-server --env=\"ENV=dev\" --open --hot",
"oul": "npx openapi -i http://127.0.0.1:5000/swagger/v1/swagger.json -o src/services/api",
"oud": "npx openapi -i http://192.168.1.10:5000/swagger/v1/swagger.json -o src/services/api",
"oug": "npx openapi -i https://cloud.digitaldrilling.ru/swagger/v1/swagger.json -o src/services/api",
"oug_dev": "npx openapi -i http://46.146.207.184/swagger/v1/swagger.json -o src/services/api"
}, },
"proxy": "http://localhost:52123", "proxy": "http://46.146.207.184",
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
"react-app", "react-app",
@ -83,24 +87,30 @@
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"babel-jest": "^28.1.0", "babel-jest": "^28.1.0",
"babel-loader": "^8.2.5", "babel-loader": "^8.2.5",
"colors": "^1.4.0",
"css-loader": "^6.7.1", "css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^4.2.0",
"extract-loader": "^5.1.0",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0", "html-webpack-plugin": "^5.5.0",
"interpolate-html-plugin": "^4.0.0", "interpolate-html-plugin": "^4.0.0",
"jest": "^28.1.0", "jest": "^28.1.0",
"less": "^4.1.3", "less": "^4.1.3",
"less-loader": "^11.0.0", "less-loader": "^11.0.0",
"mini-css-extract-plugin": "^2.6.1",
"openapi-typescript": "^5.4.0", "openapi-typescript": "^5.4.0",
"openapi-typescript-codegen": "^0.23.0", "openapi-typescript-codegen": "^0.23.0",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"react-test-renderer": "^18.1.0", "react-test-renderer": "^18.1.0",
"source-map-loader": "^3.0.1", "source-map-loader": "^3.0.1",
"style-loader": "^3.3.1", "style-loader": "^3.3.1",
"terser-webpack-plugin": "^5.3.6",
"ts-loader": "^9.3.0", "ts-loader": "^9.3.0",
"typescript": "^4.7.4", "typescript": "^4.7.4",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"webpack": "^5.73.0", "webpack": "^5.73.0",
"webpack-cli": "^4.9.2", "webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.9.1" "webpack-dev-server": "^4.9.1",
"webpack-merge": "^5.8.0"
} }
} }

View File

@ -8,7 +8,7 @@
<meta name="theme-color" media="(prefers-color-scheme: light)" content="white" /> <meta name="theme-color" media="(prefers-color-scheme: light)" content="white" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="black" /> <meta name="theme-color" media="(prefers-color-scheme: dark)" content="black" />
<meta name="description" content="Онлайн мониторинг процесса бурения в реальном времени в офисе заказчика" /> <meta name="description" content="Онлайн мониторинг процесса бурения в реальном времени в офисе заказчика" />
<title>АСБ Vision</title> <title>DDrilling</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

View File

@ -1,6 +1,6 @@
{ {
"short_name": "React App", "short_name": "ЕЦП",
"name": "Create React App Sample", "name": "Единая Цифровая Платформа",
"icons": [ "icons": [
{ {
"src": "favicon.ico", "src": "favicon.ico",

View File

@ -1,50 +1,57 @@
import { BrowserRouter as Router, Navigate, Route, Routes } from 'react-router-dom' import { BrowserRouter as Router, Navigate, Route, Routes } from 'react-router-dom'
import { memo } from 'react' import { lazy, memo, Suspense } from 'react'
import { ConfigProvider } from 'antd'
import locale from 'antd/lib/locale/ru_RU'
import { RootPathContext } from '@asb/context' import { RootPathContext } from '@asb/context'
import { getUserToken, NoAccessComponent } from '@utils' import SuspenseFallback from '@components/SuspenseFallback'
import { OpenAPI } from '@api' import { NoAccessComponent } from '@utils'
import AdminPanel from '@pages/AdminPanel' import '@styles/pages/App.less'
import Well from '@pages/Well'
import Login from '@pages/Login'
import Cluster from '@pages/Cluster'
import Deposit from '@pages/Deposit'
import Register from '@pages/Register'
import FileDownload from '@pages/FileDownload'
import '@styles/App.less' const UserOutlet = lazy(() => import('@components/outlets/UserOutlet'))
const DepositsOutlet = lazy(() => import('@components/outlets/DepositsOutlet'))
const LayoutPortal = lazy(() => import('@components/LayoutPortal'))
//OpenAPI.BASE = 'http://localhost:3000' const Login = lazy(() => import('@pages/public/Login'))
OpenAPI.TOKEN = async () => getUserToken() ?? '' const Register = lazy(() => import('@pages/public/Register'))
OpenAPI.HEADERS = {'Content-Type': 'application/json'} const FileDownload = lazy(() => import('@pages/FileDownload'))
const AdminPanel = lazy(() => import('@pages/AdminPanel'))
const Deposit = lazy(() => import('@pages/Deposit'))
const Cluster = lazy(() => import('@pages/Cluster'))
const Well = lazy(() => import('@pages/Well'))
export const App = memo(() => ( export const App = memo(() => (
<ConfigProvider locale={locale}> <RootPathContext.Provider value={''}>
<RootPathContext.Provider value={''}> <Suspense fallback={<SuspenseFallback style={{ minHeight: '100vh' }} />}>
<Router> <Router>
<Routes> <Routes>
<Route index element={<Navigate to={Deposit.getKey()} replace />} /> <Route index element={<Navigate to={'deposit'} replace />} />
<Route path={'*'} element={<NoAccessComponent />} /> <Route path={'*'} element={<NoAccessComponent />} />
{/* Public pages */} {/* Public pages */}
<Route path={Login.route} element={<Login />} /> <Route path={'/login'} element={<Login />} />
<Route path={Register.route} element={<Register />} /> <Route path={'/register'} element={<Register />} />
{/* Admin pages */}
<Route path={AdminPanel.route} element={<AdminPanel />} />
{/* User pages */} {/* User pages */}
<Route path={Deposit.route} element={<Deposit />} /> <Route element={<UserOutlet />}>
<Route path={Cluster.route} element={<Cluster />} /> <Route path={'/file_download/:idFile/*'} element={<FileDownload />} />
<Route path={Well.route} element={<Well />} />
<Route path={FileDownload.route} element={<FileDownload />} /> <Route element={<DepositsOutlet />}>
<Route element={<LayoutPortal />}>
{/* Admin pages */}
<Route path={'/admin/*'} element={<AdminPanel />} />
{/* Client pages */}
<Route path={'/deposit/*'} element={<Deposit />} />
<Route path={'/cluster/:idCluster'} element={<Cluster />} />
<Route path={'/well/:idWell/*'} element={<Well />} />
</Route>
</Route>
</Route>
</Routes> </Routes>
</Router> </Router>
</RootPathContext.Provider> </Suspense>
</ConfigProvider> </RootPathContext.Provider>
)) ))
export default App export default App

View File

@ -2,8 +2,8 @@ import { memo, useCallback, useMemo, useState } from 'react'
import { Rule } from 'antd/lib/form' import { Rule } from 'antd/lib/form'
import { Form, Input, Modal, FormProps } from 'antd' import { Form, Input, Modal, FormProps } from 'antd'
import { useUser } from '@asb/context'
import { AuthService, UserDto } from '@api' import { AuthService, UserDto } from '@api'
import { getUserId, getUserLogin } from '@utils'
import { passwordRules, createPasswordRules } from '@utils/validationRules' import { passwordRules, createPasswordRules } from '@utils/validationRules'
import LoaderPortal from './LoaderPortal' import LoaderPortal from './LoaderPortal'
@ -31,7 +31,8 @@ export const ChangePassword = memo<ChangePasswordProps>(({ user, visible, onCanc
const [showLoader, setShowLoader] = useState<boolean>(false) const [showLoader, setShowLoader] = useState<boolean>(false)
const [isDisabled, setIsDisabled] = useState(true) const [isDisabled, setIsDisabled] = useState(true)
const userData = useMemo(() => user ?? { id: getUserId(), login: getUserLogin() } as UserDto, [user]) const userContext = useUser()
const userData = useMemo(() => user ?? userContext, [user])
const [form] = Form.useForm() const [form] = Form.useForm()
@ -63,7 +64,7 @@ export const ChangePassword = memo<ChangePasswordProps>(({ user, visible, onCanc
{user && <>&nbsp;(<UserView user={user} />)</>} {user && <>&nbsp;(<UserView user={user} />)</>}
</> </>
)} )}
visible={visible} open={visible}
onCancel={onModalCancel} onCancel={onModalCancel}
onOk={() => form.submit()} onOk={() => form.submit()}
okText={'Сохранить'} okText={'Сохранить'}

View File

@ -106,7 +106,7 @@ export const ColorPicker = memo<ColorPickerProps>(({ value = '#AA33BB', onChange
return ( return (
<Popover <Popover
trigger={'click'} trigger={'click'}
onVisibleChange={onClose} onOpenChange={onClose}
content={( content={(
<div className={'asb-color-picker-content'}> <div className={'asb-color-picker-content'}>
<div className={'asb-color-picker-sliders'}> <div className={'asb-color-picker-sliders'}>

View File

@ -1,5 +1,5 @@
import { cloneElement, memo, useCallback, useMemo, useState } from 'react' import { cloneElement, memo, useCallback, useMemo, useState } from 'react'
import { Button, ButtonProps } from 'antd' import { Button, ButtonProps, Tooltip } from 'antd'
import { CopyOutlined } from '@ant-design/icons' import { CopyOutlined } from '@ant-design/icons'
import { invokeWebApiWrapperAsync, notify } from './factory' import { invokeWebApiWrapperAsync, notify } from './factory'
@ -43,11 +43,9 @@ export type CopyUrlButtonProps = Omit<CopyUrlProps, 'children'> & ButtonProps
export const CopyUrlButton = memo<CopyUrlButtonProps>(({ sendLoading, hideUnsupported, onCopy, ...other }) => { export const CopyUrlButton = memo<CopyUrlButtonProps>(({ sendLoading, hideUnsupported, onCopy, ...other }) => {
return ( return (
<CopyUrl sendLoading={sendLoading} hideUnsupported={hideUnsupported} onCopy={onCopy}> <CopyUrl sendLoading={sendLoading} hideUnsupported={hideUnsupported} onCopy={onCopy}>
<Button <Tooltip title={'Скопировать URL в буфер обмена'}>
icon={<CopyOutlined />} <Button icon={<CopyOutlined />} {...other} />
title={'Скопировать URL в буфер обмена'} </Tooltip>
{...other}
/>
</CopyUrl> </CopyUrl>
) )
}) })

View File

@ -1,101 +0,0 @@
import moment from 'moment'
import { useState, useEffect, memo, ReactNode } from 'react'
import {CaretUpOutlined, CaretDownOutlined, CaretRightOutlined} from '@ant-design/icons'
import '@styles/display.less'
export const formatNumber = (value?: unknown, format?: number) =>
Number.isInteger(format) && Number.isFinite(value)
? Number(value).toFixed(format)
: Number(value).toPrecision(4)
const iconStyle = { color:'#0008' }
const displayValueStyle = { display: 'flex', flexGrow: 1 }
export type ValueDisplayProps = {
prefix?: ReactNode
suffix?: ReactNode
format?: number | string | ((arg: string) => ReactNode)
isArrowVisible?: boolean
enumeration?: Record<string, string>
value: string
}
export type DisplayProps = ValueDisplayProps & {
className?: string
label?: ReactNode
}
export const ValueDisplay = memo<ValueDisplayProps>(({ prefix, value, suffix, isArrowVisible, format, enumeration }) => {
const [val, setVal] = useState<ReactNode>('---')
const [arrowState, setArrowState] = useState({
preVal: NaN,
preTimestamp: Date.now(),
direction: 0,
})
useEffect(() => {
setVal((preVal) => {
if ((value ?? '-') === '-' || value === '--') return '---'
if (typeof format === 'function') return format(enumeration?.[value] ?? value)
if (enumeration?.[value]) return enumeration[value]
if (Number.isFinite(+value)) {
if (isArrowVisible && (arrowState.preTimestamp + 1000 < Date.now())) {
let direction = 0
if (+value > arrowState.preVal)
direction = 1
if (+value < arrowState.preVal)
direction = -1
setArrowState({
preVal: +value,
preTimestamp: Date.now(),
direction: direction,
})
}
return formatNumber(value, Number(format))
}
if (value.length > 4) {
const valueDate = moment(value)
if (valueDate.isValid())
return valueDate.format(String(format))
}
return value
})
},[value, isArrowVisible, arrowState, format, enumeration])
let arrow = null
if(isArrowVisible)
switch (arrowState.direction){
case 0:
arrow = <CaretRightOutlined style={iconStyle}/>
break
case 1:
arrow = <CaretUpOutlined style={iconStyle}/>
break
case -1:
arrow = <CaretDownOutlined style={iconStyle}/>
break
default:
break
}
return(
<span className={'display_value'}>
{prefix} {val} {suffix}{arrow}
</span>
)
})
export const Display = memo<DisplayProps>(({ className, label, ...other })=> (
<div className={className}>
<div className={'display_label'}>{label}</div>
<div style={displayValueStyle}>
<ValueDisplay {...other}/>
</div>
</div>
))

View File

@ -0,0 +1,165 @@
import { memo, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { BaseSelectRef } from 'rc-select'
import { AutoComplete } from 'antd'
import { join } from 'path'
import { useWell } from '@asb/context'
import { makeItem, PrivateMenuItem } from './PrivateMenu'
import { hasPermission, isURLAvailable } from '@utils'
import { menuItems as adminMenuItems } from '@pages/AdminPanel/AdminNavigationMenu'
import { menuItems as wellMenuItems } from '@pages/Well/WellNavigationMenu'
import '@styles/components/fast_run_menu.less'
const transliterationTable = {
'q': 'й', 'w': 'ц', 'e': 'у', 'r': 'к', 't': 'е', 'y': 'н', 'u': 'г', 'i': 'ш', 'o': 'щ', 'p': 'з', '[': 'х', ']': 'ъ', '{': 'х', '}': 'ъ',
'a': 'ф', 's': 'ы', 'd': 'в', 'f': 'а', 'g': 'п', 'h': 'р', 'j': 'о', 'k': 'л', 'l': 'д', ';': 'ж', "'": 'э', ':': 'ж', '"': 'э',
'z': 'я', 'x': 'ч', 'c': 'с', 'v': 'м', 'b': 'и', 'n': 'т', 'm': 'ь', ',': 'б', '.': 'ю', '<': 'б', '>': 'ю',
}
type OptionType = {
value: string
label: ReactNode
}
const transliterateToRu = (text: string) => Object.entries(transliterationTable).reduce((out, [en, ru]) => out.replaceAll(en, ru), text.toLowerCase())
const transliterateToEn = (text: string) => Object.entries(transliterationTable).reduce((out, [en, ru]) => out.replaceAll(ru, en), text.toLowerCase())
const applyVars = (route: string, vars?: object): string => !vars ? route :
Object.entries(vars).reduce((out, [key, value]) => out.replaceAll(`{${key}}`, value), route)
const makeOptions = (items: PrivateMenuItem[], vars?: object): OptionType[] => {
const out: OptionType[] = []
items.forEach((item) => {
if (!hasPermission(item.permissions)) return
out.push({
label: item.title,
value: applyVars(item.route, vars),
})
if (item.children) {
const childrenOptions = makeOptions(item.children).map((child) => ({
label: `${item.title} > ${child.label}`,
value: applyVars(join(item.route, String(child.value)), vars),
}))
out.push(...childrenOptions)
}
})
return out
}
const makeFindInString = (text: string) => {
const searchText = text.toLowerCase()
const toRu = transliterateToRu(searchText)
const toEn = transliterateToEn(searchText)
return (sourceText: string) => {
const text = sourceText.toLowerCase()
let idx = text.indexOf(searchText)
if (idx < 0 && (idx = text.indexOf(toRu)) < 0 && (idx = text.indexOf(toEn)) < 0) return false
return { from: idx, to: idx + searchText.length }
}
}
export const FastRunMenu = memo(() => {
const [isOpen, setIsOpen] = useState(false)
const [value, setValue] = useState<string | null>()
const [results, setResults] = useState<OptionType[]>([])
const ref = useRef<BaseSelectRef | null>(null)
const [well] = useWell()
const navigate = useNavigate()
const location = useLocation()
const options = useMemo(() => {
const menus = [
makeItem('Месторождения', '/deposit', []),
]
if (isURLAvailable('/admin'))
menus.push(makeItem('Панель администратора', '/admin', [], undefined, adminMenuItems as PrivateMenuItem[]))
if (well.id)
menus.push(
makeItem(`Куст (${well.cluster})`, `/cluster/${well.idCluster}`, []),
makeItem(`Скважина (${well.caption})`, '/well/{idWell}', [], undefined, wellMenuItems),
)
return makeOptions(menus, { idWell: well.id })
}, [well])
const onClose = useCallback(() => {
setIsOpen(false)
setValue(null)
}, [])
const onTextChanged = useCallback((value: any) => {
navigate(value, { state: { from: location.pathname } })
onClose()
}, [onClose])
const onSearch = useCallback((text: string) => {
if (text.trim() === '') {
setResults(options)
return
}
const findInString = makeFindInString(text)
const results = options.map((option) => {
const label = String(option.label)
const idx = findInString(label.toLowerCase())
if (!idx) return findInString(option.value.toLowerCase()) ? option : false
return {
...option,
label: <>
{label.slice(0, idx.from)}
<span className={'fast-run-menu-text-found'}>
{label.slice(idx.from, idx.to)}
</span>
{label.slice(idx.to)}
</>
}
}).filter(Boolean) as OptionType[]
setResults(results)
}, [options])
useEffect(() => {
const listener = (event: KeyboardEvent) => {
if (event.altKey && event.code === 'KeyA')
setIsOpen((prev) => !prev)
}
document.addEventListener('keyup', listener)
return () => document.removeEventListener('keyup', listener)
}, [ref.current])
useEffect(() => {
if (!isOpen) return
ref.current?.focus()
ref.current?.scrollTo(0)
}, [isOpen])
useEffect(() => onSearch(''), [onSearch])
return isOpen ? (
<div className={'fast-run-menu'}>
<AutoComplete
ref={ref}
autoFocus
style={{ width: '100%' }}
options={results}
onBlur={onClose}
onChange={setValue}
onSearch={onSearch}
placeholder={'Введите название страницы...'}
onSelect={onTextChanged}
value={value}
/>
</div>
) : <></>
})
export default FastRunMenu

View File

@ -22,7 +22,7 @@ export const Grid = memo<ComponentProps>(({ children, style, ...other }) => (
</div> </div>
)) ))
export const GridItem = memo<GridItemProps>(({ children, row, col, rowSpan, colSpan, style, ...other }) => { export const GridItem = memo<GridItemProps>(({ children, row, col, rowSpan, colSpan, style, className, ...other }) => {
const localRow = +row const localRow = +row
const localCol = +col const localCol = +col
const localColSpan = colSpan ? colSpan - 1 : 0 const localColSpan = colSpan ? colSpan - 1 : 0
@ -32,12 +32,11 @@ export const GridItem = memo<GridItemProps>(({ children, row, col, rowSpan, colS
gridColumnEnd: localCol + localColSpan, gridColumnEnd: localCol + localColSpan,
gridRowStart: localRow, gridRowStart: localRow,
gridRowEnd: localRow + localRowSpan, gridRowEnd: localRow + localRowSpan,
padding: '4px',
...style, ...style,
} }
return ( return (
<div style={gridItemStyle} {...other}> <div className={`asb-grid-item ${className || ''}`} style={gridItemStyle} {...other}>
{children} {children}
</div> </div>
) )

View File

@ -1,28 +0,0 @@
import { memo, ReactNode } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { Button, Layout, LayoutProps } from 'antd'
import PageHeader from '@components/PageHeader'
export type AdminLayoutPortalProps = LayoutProps & {
title?: ReactNode
}
export const AdminLayoutPortal = memo<AdminLayoutPortalProps>(({ title, ...props }) => {
const location = useLocation()
return (
<Layout.Content>
<PageHeader isAdmin title={title} style={{ backgroundColor: '#900' }}>
<Button size={'large'}>
<Link to={'/'}>Вернуться на сайт</Link>
</Button>
</PageHeader>
<Layout>
<Layout.Content className={'site-layout-background sheet'} {...props}/>
</Layout>
</Layout.Content>
)
})
export default AdminLayoutPortal

View File

@ -1,31 +0,0 @@
import { memo, ReactNode } from 'react'
import { Layout, LayoutProps } from 'antd'
import PageHeader from '@components/PageHeader'
import WellTreeSelector from '@components/selectors/WellTreeSelector'
import { wrapPrivateComponent } from '@utils'
export type LayoutPortalProps = LayoutProps & {
title?: ReactNode
noSheet?: boolean
showSelector?: boolean
}
const _LayoutPortal = memo<LayoutPortalProps>(({ title, noSheet, showSelector, ...props }) => (
<Layout.Content>
<PageHeader title={title}>
<WellTreeSelector show={showSelector} />
</PageHeader>
<Layout>
{noSheet ? props.children : (
<Layout.Content className={'site-layout-background sheet'} {...props}/>
)}
</Layout>
</Layout.Content>
))
export const LayoutPortal = wrapPrivateComponent(_LayoutPortal, {
requirements: ['Deposit.get'],
})
export default LayoutPortal

View File

@ -1,5 +0,0 @@
export * from './AdminLayoutPortal'
export * from './LayoutPortal'
export type { AdminLayoutPortalProps } from './AdminLayoutPortal'
export type { LayoutPortalProps } from './LayoutPortal'

View File

@ -0,0 +1,139 @@
import { Breadcrumb, Layout, LayoutProps, Menu, SiderProps } from 'antd'
import { Key, memo, ReactNode, Suspense, useCallback, useEffect, useMemo, useState } from 'react'
import { ItemType } from 'antd/lib/menu/hooks/useItems'
import { Link, Outlet, useLocation } from 'react-router-dom'
import {
ApartmentOutlined,
CodeOutlined,
HomeOutlined,
UserOutlined,
} from '@ant-design/icons'
import { LayoutPropsContext } from '@asb/context'
import { UserMenu, UserMenuProps } from '@components/UserMenu'
import { WellTreeSelector, WellTreeSelectorProps } from '@components/selectors/WellTreeSelector'
import { isURLAvailable, withPermissions } from '@utils'
import SuspenseFallback from './SuspenseFallback'
import Logo from '@images/Logo'
import '@styles/components/layout.less'
const { Content, Sider } = Layout
export type LayoutPortalProps = Omit<LayoutProps, 'children'> & {
title?: ReactNode
sheet?: boolean
showSelector?: boolean
selectorProps?: WellTreeSelectorProps
sider?: boolean | JSX.Element
siderProps?: SiderProps & { userMenuProps?: UserMenuProps }
isAdmin?: boolean
fallback?: JSX.Element
breadcrumb?: boolean | ((path: string) => JSX.Element)
topRightBlock?: JSX.Element
}
const defaultProps: LayoutPortalProps = {
title: 'Единая цифровая платформа',
sider: true,
sheet: true,
}
const makeItem = (title: string, key: Key, icon: JSX.Element, label?: ReactNode, onClick?: () => void) => ({ icon, key, title, label: label ?? title, onClick })
const _LayoutPortal = memo(() => {
const [menuCollapsed, setMenuCollapsed] = useState<boolean>(true)
const [wellsTreeOpen, setWellsTreeOpen] = useState<boolean>(false)
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
const [currentWell, setCurrentWell] = useState<string>('')
const [props, setProps] = useState<LayoutPortalProps>(defaultProps)
const location = useLocation()
const { isAdmin, title, sheet, showSelector, selectorProps, sider, siderProps, fallback, breadcrumb, topRightBlock, ...other } = useMemo(() => props, [props])
const setLayoutProps = useCallback((props: LayoutPortalProps) => setProps({ ...defaultProps, ...props}), [])
useEffect(() => {
if (typeof showSelector === 'boolean')
setWellsTreeOpen(showSelector)
}, [showSelector])
const menuItems = useMemo(() => [
!isAdmin && makeItem(currentWell, 'well', <ApartmentOutlined/>, null, () => setWellsTreeOpen((prev) => !prev)),
isAdmin && makeItem('Вернуться на сайт', 'go_back', <HomeOutlined />, <Link to={'/'}>Домой</Link>),
!isAdmin && isURLAvailable('/admin') && makeItem('Панель администратора', 'admin_panel', <CodeOutlined />, <Link to={'/admin'}>Панель администратора</Link>),
makeItem('Профиль', 'profile', <UserOutlined/>, null, () => setUserMenuOpen((prev) => !prev)),
].filter(Boolean) as ItemType[], [isAdmin, currentWell])
const breadcrumbItems = useMemo(() => typeof breadcrumb === 'function' && breadcrumb(location.pathname), [breadcrumb, location.pathname])
return (
<Layout className={`page-layout ${isAdmin ? 'page-layout-admin' : ''}`}>
{(sider || siderProps) && (
<Sider {...siderProps} collapsedWidth={50} collapsed={menuCollapsed} trigger={null} collapsible className={`menu-sider ${siderProps?.className || ''}`}>
<div className={'sider-content'}>
<button className={'sider-toogle'} onClick={() => setMenuCollapsed((prev) => !prev)}>
<Logo onlyIcon={menuCollapsed} />
</button>
<div className={'scrollable hide-slider'}>
{sider}
</div>
<Menu
mode={'inline'}
items={menuItems}
theme={'dark'}
selectable={false}
/>
<UserMenu
open={userMenuOpen}
onClose={() => setUserMenuOpen(false)}
isAdmin={isAdmin}
{...siderProps?.userMenuProps}
/>
</div>
</Sider>
)}
{!isAdmin && (
<WellTreeSelector
open={wellsTreeOpen}
onClose={() => setWellsTreeOpen(false)}
{...selectorProps}
onChange={(well) => setCurrentWell(well ?? 'Выберите месторождение')}
/>
)}
<Layout className={'page-content'}>
<Content {...other} className={`${sheet ? 'site-layout-background sheet' : ''} ${other.className ?? ''}`}>
{(breadcrumb || topRightBlock) && (
<div className={'breadcrumb-block'}>
{breadcrumb && (
<Breadcrumb>
<Breadcrumb.Item href={'/'}>
<HomeOutlined />
</Breadcrumb.Item>
{!isAdmin && (
<Breadcrumb.Item>
<a style={{ userSelect: 'none' }} onClick={() => setWellsTreeOpen((prev) => !prev)}>{currentWell}</a>
</Breadcrumb.Item>
)}
{breadcrumbItems}
</Breadcrumb>
)}
{topRightBlock}
</div>
)}
<LayoutPropsContext.Provider value={setLayoutProps}>
<Suspense fallback={fallback ?? <SuspenseFallback style={{ minHeight: '100%' }} />}>
<Outlet />
</Suspense>
</LayoutPropsContext.Provider>
</Content>
</Layout>
</Layout>
)
})
export const LayoutPortal = withPermissions(_LayoutPortal, ['Deposit.get'])
export default LayoutPortal

View File

@ -3,14 +3,22 @@ import { HTMLAttributes } from 'react'
import { Loader } from '@components/icons' import { Loader } from '@components/icons'
type LoaderPortalProps = HTMLAttributes<HTMLDivElement> & { type LoaderPortalProps = HTMLAttributes<HTMLDivElement> & {
/** Показать ли загрузку */
show?: boolean, show?: boolean,
/** Затемнять ли дочерний блок */
fade?: boolean, fade?: boolean,
/** Параметры спиннера */
spinnerProps?: HTMLAttributes<HTMLDivElement>, spinnerProps?: HTMLAttributes<HTMLDivElement>,
/** Заполнять ли контент на 100% */
fillContent?: boolean
} }
export const LoaderPortal: React.FC<LoaderPortalProps> = ({ className = '', show, fade = true, children, spinnerProps, ...other }) => ( /**
* @description Добавляет оверлей загрузки над обёрнутым блоком
*/
export const LoaderPortal: React.FC<LoaderPortalProps> = ({ className = '', show, fade = true, children, spinnerProps, fillContent, ...other }) => (
<div className={`loader-container ${className}`} {...other}> <div className={`loader-container ${className}`} {...other}>
<div className={'loader-content'}>{children}</div> <div className={`loader-content${fillContent ? ' loader-content-fill' : ''}`}>{children}</div>
{show && fade && <div className={'loader-fade'}/>} {show && fade && <div className={'loader-fade'}/>}
{show && <div className={'loader-overlay'}><Loader {...spinnerProps} /></div>} {show && <div className={'loader-overlay'}><Loader {...spinnerProps} /></div>}
</div> </div>

View File

@ -0,0 +1,45 @@
import { Breadcrumb, BreadcrumbItemProps } from 'antd'
import { Link } from 'react-router-dom'
import { join } from 'path'
import { PrivateMenuItem } from '@components/PrivateMenu'
import { FunctionalValue, getFunctionalValue, } from '@utils'
export const makeBreadcrumbItems = (items: PrivateMenuItem[], pathParts: string[], root: string = '/') => {
const out = []
const parts = [...pathParts]
let route = root
let arr: PrivateMenuItem[] | undefined = items
while (arr && parts.length > 0) {
const child: PrivateMenuItem | undefined = arr.find(elm => elm.route.toLowerCase() === parts[0].toLowerCase())
if (!child) break
route = join(route, child.route)
out.push({ ...child, route })
parts.splice(0, 1)
arr = child.children
}
return out
}
export const makeMenuBreadcrumbItemsRender = (
menuItems: PrivateMenuItem[],
pathRoot: RegExp = /^\//,
itemsProps?: FunctionalValue<(item: PrivateMenuItem) => BreadcrumbItemProps>,
itemRender?: (item: PrivateMenuItem) => JSX.Element,
) => (path: string) => {
const getItemProps = getFunctionalValue(itemsProps)
const rootPart = pathRoot.exec(path)
if (!rootPart || rootPart.length <= 0) return []
const root = rootPart[0]
const parts = path.trim().slice(root.length).split('/')
const items = makeBreadcrumbItems(menuItems, parts, root)
return items.map((item) => (
<Breadcrumb.Item key={item.route} {...getItemProps(item)}>
{itemRender ? itemRender(item) : (
<Link to={item.route}>{item.title}</Link>
)}
</Breadcrumb.Item>
))
}

View File

@ -1,34 +0,0 @@
import { memo } from 'react'
import { Layout } from 'antd'
import { Link, useLocation } from 'react-router-dom'
import { BasicProps } from 'antd/lib/layout/layout'
import { headerHeight } from '@utils'
import { UserMenu } from './UserMenu'
import Logo from '@images/Logo'
export type PageHeaderProps = BasicProps & {
title?: string
isAdmin?: boolean
children?: React.ReactNode
}
export const PageHeader: React.FC<PageHeaderProps> = memo(({ title = 'Мониторинг', isAdmin, children, ...other }) => {
const location = useLocation()
return (
<Layout>
<Layout.Header className={'header'} {...other}>
<Link to={'/'} style={{ height: headerHeight }}>
<Logo />
</Link>
<h1 className={'title'}>{title}</h1>
{children}
<UserMenu isAdmin={isAdmin} />
</Layout.Header>
</Layout>
)
})
export default PageHeader

View File

@ -1,14 +0,0 @@
import { memo, ReactElement } from 'react'
import { isURLAvailable } from '@utils'
export type PrivateContentProps = {
absolutePath: string
children?: ReactElement<any, any>
}
export const PrivateContent = memo<PrivateContentProps>(({ absolutePath, children = null }) =>
isURLAvailable(absolutePath) ? children : null
)
export default PrivateContent

View File

@ -1,19 +0,0 @@
import { memo } from 'react'
import { Navigate, Route, RouteProps } from 'react-router-dom'
import { isURLAvailable } from '@utils'
import { getDefaultRedirectPath } from './PrivateRoutes'
export type PrivateDefaultRouteProps = RouteProps & {
urls: string[]
elseRedirect?: string
}
export const PrivateDefaultRoute = memo<PrivateDefaultRouteProps>(({ elseRedirect, urls, ...other }) => (
<Route {...other} path={'/'} element={(
<Navigate replace to={urls.find((url) => isURLAvailable(url)) ?? elseRedirect ?? getDefaultRedirectPath()} />
)} />
))
export default PrivateDefaultRoute

View File

@ -1,75 +0,0 @@
import { join } from 'path'
import { Menu, MenuProps } from 'antd'
import { ItemType } from 'antd/lib/menu/hooks/useItems'
import { Link, LinkProps } from 'react-router-dom'
import { Children, isValidElement, memo, ReactNode, RefAttributes, useMemo } from 'react'
import { useRootPath } from '@asb/context'
import { getTabname, hasPermission, PrivateComponent, PrivateProps } from '@utils'
export type PrivateMenuProps = MenuProps & { root?: string }
export type PrivateMenuLinkProps = Partial<ItemType> & Omit<LinkProps, 'to'> & RefAttributes<HTMLAnchorElement> & {
icon?: ReactNode
danger?: boolean
title?: ReactNode
content?: PrivateComponent<any>
path?: string
visible?: boolean
permissions?: string[]
}
export const PrivateMenuLink = memo<PrivateMenuLinkProps>(({ content, path = '', title, ...other }) => (
<Link to={path} {...other}>{title ?? content?.title}</Link>
))
const PrivateMenuMain = memo<PrivateMenuProps>(({ selectable, mode, selectedKeys, root, children, ...other }) => {
const rootContext = useRootPath()
const rootPath = useMemo(() => root ?? rootContext ?? '', [root, rootContext])
const tab = getTabname()
const keys = useMemo(() => selectedKeys ?? (tab ? [tab] : []), [selectedKeys, tab])
const items = useMemo(() => Children.map(children, (child) => {
if (!child || !isValidElement<PrivateMenuLinkProps>(child))
return null
const content: PrivateProps | undefined = child.props.content
const visible: boolean | undefined = child.props.visible
if (visible === false) return null
let key
if (content?.key)
key = content.key
else if (content?.route)
key = content.route
else if (child.key) {
key = child.key?.toString()
key = key.slice(key.lastIndexOf('$') + 1)
} else return null
const permissions = child.props.permissions ?? content?.requirements
const path = child.props.path ?? join(rootPath, key)
if (visible || hasPermission(permissions))
return {
...child.props,
key,
label: <PrivateMenuLink {...child.props} path={path} />,
}
return null
})?.filter((v) => v) ?? [], [children, rootPath])
return (
<Menu
selectable={selectable ?? true}
mode={mode ?? 'horizontal'}
selectedKeys={keys}
items={items as ItemType[]}
{...other}
/>
)
})
export const PrivateMenu = Object.assign(PrivateMenuMain, { Link: PrivateMenuLink })
export default PrivateMenu

View File

@ -1,37 +0,0 @@
import { join } from 'path'
import { Menu, MenuItemProps } from 'antd'
import { memo, NamedExoticComponent } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { isURLAvailable } from '@utils'
export type PrivateMenuItemProps = MenuItemProps & {
root: string
path: string
}
export type PrivateMenuItemLinkProps = MenuItemProps & {
root?: string
path: string
title: string
}
export const PrivateMenuItemLink = memo<PrivateMenuItemLinkProps>(({ root = '', path, title, ...other }) => {
const location = useLocation()
return (
<PrivateMenuItem key={path} root={root} path={path} {...other}>
<Link to={join(root, path)}>{title}</Link>
</PrivateMenuItem>
)
})
export const PrivateMenuItem: NamedExoticComponent<PrivateMenuItemProps> & {
Link: NamedExoticComponent<PrivateMenuItemLinkProps>
} = Object.assign(memo<PrivateMenuItemProps>(({ root, path, ...other }) =>
<Menu.Item key={path} hidden={!isURLAvailable(join(root, path))} {...other} />
), {
Link: PrivateMenuItemLink
})
export default PrivateMenuItem

View File

@ -1,30 +0,0 @@
import { join } from 'path'
import { memo, ReactNode } from 'react'
import { Navigate, Route, RouteProps } from 'react-router-dom'
import { getUserId, isURLAvailable } from '@utils'
export type PrivateRouteProps = RouteProps & {
root?: string
path: string
children?: ReactNode
redirect?: ReactNode
}
export const defaultRedirect = (
<Navigate to={getUserId() ? '/access_denied' : '/login'} />
)
export const PrivateRoute = memo<PrivateRouteProps>(({ root = '', path, children, redirect = defaultRedirect, ...other }) => {
const available = isURLAvailable(join(root, path))
return (
<Route
{...other}
path={path}
element={available ? children : redirect}
/>
)
})
export default PrivateRoute

View File

@ -1,67 +0,0 @@
import { join } from 'path'
import { Navigate, Route, Routes, RoutesProps } from 'react-router-dom'
import { Children, cloneElement, memo, ReactElement, ReactNode, useCallback, useMemo } from 'react'
import { useRootPath } from '@asb/context'
import { getUserId, isURLAvailable } from '@utils'
export type PrivateRoutesProps = RoutesProps & {
root?: string
redirect?: ReactNode
elseRedirect?: string | string[]
}
export const getDefaultRedirectPath = () => getUserId() ? '/access_denied' : '/login'
export const defaultRedirect = (
<Navigate to={getDefaultRedirectPath()} />
)
export const PrivateRoutes = memo<PrivateRoutesProps>(({ root, elseRedirect, redirect = defaultRedirect, children }) => {
const rootContext = useRootPath()
const rootPath = useMemo(() => root ?? rootContext ?? '', [root, rootContext])
const toAbsolute = useCallback((path: string) => path.startsWith('/') ? path : join(rootPath, path), [rootPath])
const items = useMemo(() => Children.map(children, (child) => {
const element = child as ReactElement
let key = element.key?.toString()
if (!key) return <></>
key = key.slice(key.lastIndexOf('$') + 1).replaceAll('=2', ':')
// Ключ автоматический преобразуется в "(.+)\$ключ"
// Все ":" в ключе заменяются на "=2"
// TODO: улучшить метод нормализации ключа
const path = toAbsolute(key)
return (
<Route
key={key}
path={path}
element={isURLAvailable(path) ? cloneElement(element) : redirect}
/>
)
}) ?? [], [children, redirect, toAbsolute])
const defaultRoute = useMemo(() => {
const routes: string[] = []
if (Array.isArray(elseRedirect))
routes.push(...elseRedirect)
else if(elseRedirect)
routes.push(elseRedirect)
routes.push(...items.map((elm) => elm?.props?.path))
const firstAvailableRoute = routes.find((path) => path && isURLAvailable(path))
return firstAvailableRoute ? toAbsolute(firstAvailableRoute) : getDefaultRedirectPath()
}, [items, elseRedirect, toAbsolute])
return (
<Routes>
{items}
<Route path={'/'} element={(
<Navigate to={defaultRoute} />
)}/>
</Routes>
)
})
export default PrivateRoutes

View File

@ -1,13 +0,0 @@
export { PrivateRoute, defaultRedirect } from './PrivateRoute'
export { PrivateContent } from './PrivateContent' // TODO: Remove
export { PrivateMenuItem, PrivateMenuItemLink } from './PrivateMenuItem' // TODO: Remove
export { PrivateDefaultRoute } from './PrivateDefaultRoute'
export { PrivateMenu, PrivateMenuLink } from './PrivateMenu'
export { PrivateRoutes } from './PrivateRoutes'
export type { PrivateRouteProps } from './PrivateRoute'
export type { PrivateContentProps } from './PrivateContent' // TODO: Remove
export type { PrivateMenuItemProps, PrivateMenuItemLinkProps } from './PrivateMenuItem' // TODO: Remove
export type { PrivateDefaultRouteProps } from './PrivateDefaultRoute'
export type { PrivateMenuProps, PrivateMenuLinkProps } from './PrivateMenu'
export type { PrivateRoutesProps } from './PrivateRoutes'

View File

@ -0,0 +1,95 @@
import { ItemType } from 'antd/lib/menu/hooks/useItems'
import { Menu, MenuProps } from 'antd'
import { memo, ReactNode, useMemo } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { join } from 'path'
import { hasPermission, Permission } from '@utils'
export type PrivateMenuItem = {
title: string
route: string
permissions: Permission | Permission[]
icon?: ReactNode
visible?: boolean
children?: PrivateMenuItem[]
}
const makeItems = (items: PrivateMenuItem[], parentRoute: string, pathParser?: (path: string, parent: string) => string): ItemType[] => {
return items.map((item) => {
if (item.visible === false || !(item.visible === true || hasPermission(item.permissions))) return null
let route = item.route
if (pathParser)
route = pathParser(item.route, parentRoute)
else if (!item.route.startsWith('/') && parentRoute)
route = join(parentRoute, item.route)
const out: ItemType = {
key: route,
icon: item.icon,
title: item.title,
label: <Link to={route}>{item.title}</Link>,
}
if (item.children && item.children.length > 0) {
return {
...out,
children: makeItems(item.children, route, pathParser)
}
}
return out
}).filter(Boolean)
}
const makeItemList = (items: PrivateMenuItem[], rootPath: string, variables: Record<string, number | string>): ItemType[] => {
const parser = (path: string, parent: string) => {
if (!path.startsWith('/'))
path = join(parent, path)
return Object.entries(variables).reduce((out, [key, value]) => out.replaceAll(`{${key}}`, String(value)), path)
}
return makeItems(items, rootPath, parser)
}
export const makeItem = (
title: string,
route: string,
permissions: Permission | Permission[],
icon?: ReactNode,
children?: PrivateMenuItem[],
visible?: boolean
): PrivateMenuItem => ({
title,
route,
icon,
permissions,
children,
visible,
})
export type PrivateMenuProps = Omit<MenuProps, 'items'> & {
variables?: Record<string, number | string>
items: PrivateMenuItem[]
rootPath?: string
}
export const PrivateMenu = memo<PrivateMenuProps>(({ variables, items, rootPath = '/', ...other }) => {
const location = useLocation()
const menuItems = useMemo(() => makeItemList(items, rootPath, variables || {}), [items, rootPath, variables])
const tabKeys = useMemo(() => {
const out = []
const rx = RegExp(/(?<!^)\//g)
let input = location.pathname
if (!input.endsWith('/')) input += '/'
let match: RegExpExecArray | null
while((match = rx.exec(input)) !== null)
out.push(input.slice(0, match.index))
return out
}, [location.pathname])
return <Menu items={menuItems} selectedKeys={tabKeys} {...other} />
})

View File

View File

@ -1,26 +1,53 @@
import { ReactNode } from 'react' import { Key, ReactNode } from 'react'
import { formatDate } from '@utils' import { makeColumn, ColumnProps, SorterMethod } from '.'
import { DatePickerWrapper, getObjectByDeepKey } from '..'
import makeColumn, { columnPropsOther } from '.'
import { DatePickerWrapper, makeDateSorter } from '..'
import { DatePickerWrapperProps } from '../DatePickerWrapper' import { DatePickerWrapperProps } from '../DatePickerWrapper'
import { formatDate, isRawDate } from '@utils'
export const makeDateColumn = ( /**
* Фабрика методов сортировки столбцов для данных типа **Дата**
* @param key Ключ столбца
* @returns Метод сортировки
*/
export const makeDateSorter = <T extends unknown>(key: Key): SorterMethod<T> => (a, b) => {
const vA = a ? getObjectByDeepKey(key, a) : null
const vB = b ? getObjectByDeepKey(key, b) : null
if (!isRawDate(vA) || !isRawDate(vB)) return 0
if (!isRawDate(vA)) return 1
if (!isRawDate(vB)) return -1
return (new Date(vA)).getTime() - (new Date(vB)).getTime()
}
/**
* Фабрика объектов-столбцов для компонента `Table` для работы с данными типа **Дата**
*
* @param title Название столбца
* @param key Ключ столбца
* @param utc Конвертировать ли дату в UTC
* @param format Формат отображения даты
* @param other Дополнительные опции столбца
* @param pickerOther Опции компонента селектора даты
* @returns Объект-столбец для работы с данными типа **Дата**
*/
export const makeDateColumn = <T extends unknown>(
title: ReactNode, title: ReactNode,
key: string, key: string,
utc?: boolean, utc?: boolean,
format?: string, format?: string,
other?: columnPropsOther, other?: ColumnProps<T>,
pickerOther?: DatePickerWrapperProps, pickerOther?: DatePickerWrapperProps,
) => makeColumn(title, key, { ) => makeColumn<T>(title, key, {
editable: true,
...other, ...other,
render: (date) => ( render: (date) => (
<div className={'text-align-r-container'}> <div className={'text-align-r-container'}>
<span>{formatDate(date, utc, format) ?? '-'}</span> <span>{formatDate(date, utc, format) ?? '-'}</span>
</div> </div>
), ),
sorter: makeDateSorter(key), sorter: makeDateSorter<T>(key),
input: <DatePickerWrapper {...pickerOther} />, input: <DatePickerWrapper {...pickerOther} />,
}) })

View File

@ -1,38 +1,27 @@
import { ReactNode } from 'react' import { Key, ReactNode } from 'react'
import { Rule } from 'antd/lib/form' import { Rule } from 'antd/lib/form'
import { ColumnProps } from 'antd/lib/table' import { ColumnType } from 'antd/lib/table'
import { RenderedCell } from 'rc-table/lib/interface'
export { makeDateColumn } from './date' import { DataSet } from '../Table'
export { makeTimeColumn } from './time' import { OmitExtends } from '@utils/types'
export {
RegExpIsFloat,
makeNumericRender,
makeNumericColumn,
makeNumericColumnOptions,
makeNumericColumnPlanFact,
makeNumericStartEnd,
makeNumericMinMax,
makeNumericDividedColumn,
makeNumericAvgRange
} from './numeric'
export { makeColumnsPlanFact } from './plan_fact'
export { makeSelectColumn } from './select'
export { makeTagColumn, makeTagInput } from './tag'
export { makeFilterTextMatch, makeTextColumn, makeDividedTextColumn } from './text'
export {
timezoneOptions,
TimezoneSelect,
makeTimezoneColumn,
makeTimezoneRenderer
} from './timezone'
export type { TagInputProps } from './tag' export * from './date'
export * from './time'
export * from './numeric'
export * from './select'
export * from './tag'
export * from './text'
export * from './timezone'
export type DataType<T = any> = Record<string, T> export type DataType<T = any> = Record<string, T>
export type RenderMethod<T = any> = (value: T, dataset?: DataType<T>, index?: number) => ReactNode export type RenderMethod<T = any, DT = DataSet<T>> = (value: T | undefined, dataset: DT, index: number) => ReactNode | RenderedCell<T> | undefined
export type SorterMethod<T = any> = (a?: DataType<T> | null, b?: DataType<T> | null) => number export type SorterMethod<T = any, DT = DataSet<T> | null | undefined> = (a: DT, b: DT) => number
export type FilterMethod<T = any, DT = DataSet<T>> = (value: string | number | T | undefined, record: DT) => boolean
export type columnPropsOther<T = any> = ColumnProps<T> & { export type FilterGenerator<T, DT = DataSet<T>> = (key: Key) => FilterMethod<T, DT>
export type ColumnProps<T = any> = OmitExtends<{
// редактируемая колонка // редактируемая колонка
editable?: boolean editable?: boolean
// react компонента редактора // react компонента редактора
@ -46,13 +35,16 @@ export type columnPropsOther<T = any> = ColumnProps<T> & {
// дефолтное значение при добавлении новой строки // дефолтное значение при добавлении новой строки
initialValue?: string | number initialValue?: string | number
onFilter?: FilterMethod<T>
sorter?: SorterMethod<T>
render?: RenderMethod<T> render?: RenderMethod<T>
} }, ColumnType<DataSet<T>>>
export const makeColumn = (title: ReactNode, key: string, other?: columnPropsOther) => ({ export const makeColumn = <T = any>(title: ReactNode, key: Key, other?: ColumnProps<T>) => ({
title: title, title: title,
key: key, key: key,
dataIndex: key, dataIndex: key,
render: (value: T) => value,
...other, ...other,
}) })

View File

@ -1,17 +1,25 @@
import { InputNumber } from 'antd' import { InputNumber } from 'antd'
import { ReactNode } from 'react' import { Key, ReactNode } from 'react'
import { makeNumericSorter } from '../sorters' import makeColumn, { ColumnProps, FilterGenerator, makeGroupColumn, RenderMethod, SorterMethod } from '.'
import { columnPropsOther, makeGroupColumn, RenderMethod } from '.' import { getObjectByDeepKey } from '../Table'
export const RegExpIsFloat = /^[-+]?\d+\.?\d*$/ export const RegExpIsFloat = /^[-+]?\d+\.?\d*$/
export const makeNumericRender = <T extends unknown>(fixed?: number): RenderMethod<T> => (value) => { export const makeNumericSorter = <T extends number = number>(key: Key): SorterMethod<T> => (a, b) => {
let val = '-' if (!a && !b) return 0
if ((value ?? null) !== null && Number.isFinite(+value)) { if (!a) return 1
if (!b) return -1
return Number(getObjectByDeepKey(key, a)) - Number(getObjectByDeepKey(key, b))
}
export const makeNumericRender = <T extends unknown>(fixed?: number, defaultValue: string = '-', precision: number = 5): RenderMethod<T> => (value) => {
let val = defaultValue
if (value !== undefined && value !== null && Number.isFinite(+value)) {
val = (fixed ?? null) !== null val = (fixed ?? null) !== null
? (+value).toFixed(fixed) ? (+value).toFixed(fixed)
: (+value).toPrecision(5) : (+value).toPrecision(precision)
} }
return ( return (
@ -21,107 +29,112 @@ export const makeNumericRender = <T extends unknown>(fixed?: number): RenderMeth
) )
} }
export const makeNumericColumnOptions = (fixed?: number, sorterKey?: string): columnPropsOther => ({ export const makeNumericColumnOptions = <T extends number>(fixed?: number, sorterKey?: string): ColumnProps<T> => ({
editable: true, editable: true,
initialValue: 0, initialValue: 0,
width: 100, width: 100,
sorter: sorterKey ? makeNumericSorter(sorterKey) : undefined, sorter: sorterKey ? makeNumericSorter<T>(sorterKey) : undefined,
formItemRules: [{ formItemRules: [{
required: true, required: true,
message: 'Введите число', message: 'Введите число',
pattern: RegExpIsFloat, pattern: RegExpIsFloat,
}], }],
render: makeNumericRender(fixed), render: makeNumericRender<T>(fixed),
}) })
export const makeNumericColumn = ( export const makeNumericColumn = <T extends number>(
title: ReactNode, title: ReactNode,
dataIndex: string, key: Key,
filters: object[], renderDelegate?: RenderMethod<T>,
filterDelegate: (key: string | number) => any, filterDelegate?: FilterGenerator<T>,
renderDelegate: (_: any, row: object) => any, width?: string | number,
width: string, other?: ColumnProps<T>,
other?: columnPropsOther ) => makeColumn(title, key, {
) => ({ editable: true,
title: title, onFilter: filterDelegate ? filterDelegate(key) : undefined,
dataIndex: dataIndex, sorter: makeNumericSorter(key),
key: dataIndex, width,
filters: filters, input: <InputNumber style={{ width: '100%' }} defaultValue={0} />,
onFilter: filterDelegate ? filterDelegate(dataIndex) : null, render: renderDelegate || makeNumericRender<T>(2),
sorter: makeNumericSorter(dataIndex),
width: width,
input: <InputNumber style={{ width: '100%' }} />,
render: renderDelegate ?? makeNumericRender(),
align: 'right', align: 'right',
...other ...other
}) })
export const makeNumericDividedColumn = ( export const makeNumericColumnPlanFact = <T extends number>(
title: ReactNode, title: ReactNode,
dataIndex: string, key: Key,
filters: object[], renderDelegate?: RenderMethod<T>,
width: string, filterDelegate?: FilterGenerator<T>,
other?: columnPropsOther width?: string | number,
) => ({ other?: ColumnProps<T>,
title: title, ) => {
dataIndex: dataIndex, return {
key: dataIndex, title,
filters: filters, children: [
width: width, makeNumericColumn<T>('План', `${key}.plan`, renderDelegate, filterDelegate, width, other),
align: 'right', makeNumericColumn<T>('Факт', `${key}.fact`, renderDelegate, filterDelegate, width, other),
...other ]
}) }
}
export const makeNumericColumnPlanFact = ( /**
* @deprecated Для значений типа план/факт появилась модель `PlanFactDto`, использование 2 полей с суффиксами неактуально
* @param title Заголовок столбца
* @param key Ключ столбца
* @param filters Список значений для фильтрации
* @param filterDelegate Метод фильтрации
* @param renderDelegate Render-метод отображения ячейки
* @param width Ширина столбца
* @param other Дополнительные опции
* @returns Объект-столбец для таблицы
*/
export const makeNumericColumnPlanFactOld = <T extends number>(
title: ReactNode, title: ReactNode,
dataIndex: string, key: Key,
filters: object[], renderDelegate?: RenderMethod<T>,
filterDelegate: (key: string | number) => any, filterDelegate?: FilterGenerator<T>,
renderDelegate: (_: any, row: object) => any, width?: string | number,
width: string other?: ColumnProps<T>,
) => makeGroupColumn(title, [ ) => makeGroupColumn(title, [
makeNumericColumn('п', dataIndex + 'Plan', filters, filterDelegate, renderDelegate, width), makeNumericColumn<T>('План', key + 'Plan', renderDelegate, filterDelegate, width, other),
makeNumericColumn('ф', dataIndex + 'Fact', filters, filterDelegate, renderDelegate, width), makeNumericColumn<T>('Факт', key + 'Fact', renderDelegate, filterDelegate, width, other),
]) ])
export const makeNumericStartEnd = ( export const makeNumericStartEnd = <T extends number>(
title: ReactNode, title: ReactNode,
dataIndex: string, key: Key,
fixed: number, fixed: number,
filters: object[], renderDelegate?: RenderMethod<T>,
filterDelegate: (key: string | number) => any, filterDelegate?: FilterGenerator<T>,
renderDelegate: (_: any, row: object) => any, width?: string | number,
width: string,
) => makeGroupColumn(title, [ ) => makeGroupColumn(title, [
makeNumericColumn('старт', dataIndex + 'Start', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Start')), makeNumericColumn<T>('старт', key + 'Start', renderDelegate, filterDelegate, width, makeNumericColumnOptions(fixed, key + 'Start')),
makeNumericColumn('конец', dataIndex + 'End', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'End')) makeNumericColumn<T>('конец', key + 'End', renderDelegate, filterDelegate, width, makeNumericColumnOptions(fixed, key + 'End'))
]) ])
export const makeNumericMinMax = ( export const makeNumericMinMax = <T extends number>(
title: ReactNode, title: ReactNode,
dataIndex: string, key: Key,
fixed: number, fixed: number,
filters: object[], renderDelegate?: RenderMethod<T>,
filterDelegate: (key: string | number) => any, filterDelegate?: FilterGenerator<T>,
renderDelegate: (_: any, row: object) => any, width?: string | number,
width: string,
) => makeGroupColumn(title, [ ) => makeGroupColumn(title, [
makeNumericColumn('мин', dataIndex + 'Min', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Min')), makeNumericColumn<T>('мин', key + 'Min', renderDelegate, filterDelegate, width, makeNumericColumnOptions(fixed, key + 'Min')),
makeNumericColumn('макс', dataIndex + 'Max', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Max')), makeNumericColumn<T>('макс', key + 'Max', renderDelegate, filterDelegate, width, makeNumericColumnOptions(fixed, key + 'Max')),
]) ])
export const makeNumericAvgRange = ( export const makeNumericAvgRange = <T extends number>(
title: ReactNode, title: ReactNode,
dataIndex: string, key: Key,
fixed: number, fixed: number,
filters: object[], renderDelegate?: RenderMethod<T>,
filterDelegate: (key: string | number) => any, filterDelegate?: FilterGenerator<T>,
renderDelegate: (_: any, row: object) => any, width?: string | number,
width: string
) => makeGroupColumn(title, [ ) => makeGroupColumn(title, [
makeNumericColumn('мин', dataIndex + 'Min', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Min')), makeNumericColumn<T>('мин', `${key}.min`, renderDelegate, filterDelegate, width, makeNumericColumnOptions(fixed, `${key}.min`)),
makeNumericColumn('сред', dataIndex + 'Avg', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Avg')), makeNumericColumn<T>('сред', `${key}.avg`, renderDelegate, filterDelegate, width, makeNumericColumnOptions(fixed, `${key}.avg`)),
makeNumericColumn('макс', dataIndex + 'Max', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Max')) makeNumericColumn<T>('макс', `${key}.max`, renderDelegate, filterDelegate, width, makeNumericColumnOptions(fixed, `${key}.max`)),
]) ])
export default makeNumericColumn export default makeNumericColumn

View File

@ -1,38 +0,0 @@
import { ReactNode } from 'react'
import { columnPropsOther, makeColumn } from '.'
export const makeColumnsPlanFact = (
title: string | ReactNode,
key: string | string[],
columsOther?: columnPropsOther | [columnPropsOther, columnPropsOther],
gruopOther?: any
) => {
let keyPlanLocal: string
let keyFactLocal: string
if (key instanceof Array) {
keyPlanLocal = key[0]
keyFactLocal = key[1]
} else {
keyPlanLocal = key + 'Plan'
keyFactLocal = key + 'Fact'
}
let columsOtherLocal : any[2]
if (columsOther instanceof Array)
columsOtherLocal = [columsOther[0], columsOther[1]]
else
columsOtherLocal = [columsOther, columsOther]
return {
title: title,
...gruopOther,
children: [
makeColumn('план', keyPlanLocal, columsOtherLocal[0]),
makeColumn('факт', keyFactLocal, columsOtherLocal[1]),
]
}
}
export default makeColumnsPlanFact

View File

@ -1,21 +1,31 @@
import { Select, SelectProps } from 'antd' import { Select, SelectProps } from 'antd'
import { DefaultOptionType, SelectValue } from 'antd/lib/select' import { DefaultOptionType, SelectValue } from 'antd/lib/select'
import { Key, ReactNode, useMemo } from 'react'
import { columnPropsOther, makeColumn } from '.' import { ColumnProps, makeColumn } from '.'
export const makeSelectColumn = <T extends unknown = string>( const findOption = <T extends DefaultOptionType>(value: any, options: T[] | undefined) =>
title: string, options?.find((option) => String(option?.value) === String(value))
dataIndex: string,
options: DefaultOptionType[], const SelectWrapper = ({ value, options, ...other }: SelectProps) => {
const selectValue = useMemo(() => findOption(value, options)?.label, [value, options])
return <Select value={selectValue} options={options} {...other} />
}
export const makeSelectColumn = <T extends DefaultOptionType>(
title: ReactNode,
key: Key,
options: T[],
defaultValue?: T, defaultValue?: T,
other?: columnPropsOther, other?: ColumnProps<T>,
selectOther?: SelectProps<SelectValue> selectOther?: SelectProps<SelectValue>
) => makeColumn(title, dataIndex, { ) => makeColumn(title, key, {
editable: true,
...other, ...other,
input: <Select options={options} {...selectOther}/>, input: <SelectWrapper options={options} {...selectOther}/>,
render: (value) => { render: (value, dataset, index) => {
const item = options?.find(option => String(option?.value) === String(value)) const item = findOption(value, options)
return other?.render?.(item?.value) ?? item?.label ?? defaultValue ?? value ?? '--' return other?.render?.(item, dataset, index) ?? item?.label ?? defaultValue?.label ?? value?.label ?? '--'
} }
}) })

View File

@ -4,7 +4,7 @@ import { Select, SelectProps, Tag } from 'antd'
import type { OmitExtends } from '@utils/types' import type { OmitExtends } from '@utils/types'
import { columnPropsOther, DataType, makeColumn } from '.' import { ColumnProps, DataType, makeColumn } from '.'
export type TagInputProps<T extends DataType> = OmitExtends<{ export type TagInputProps<T extends DataType> = OmitExtends<{
options: T[], options: T[],
@ -59,14 +59,15 @@ export const makeTagColumn = <T extends DataType>(
options: T[], options: T[],
value_key: keyof DataType, value_key: keyof DataType,
label_key: keyof DataType, label_key: keyof DataType,
other?: columnPropsOther, other?: ColumnProps,
tagOther?: TagInputProps<T> tagOther?: TagInputProps<T>
) => { ) => {
const InputComponent = makeTagInput<T>(value_key, label_key) const InputComponent = makeTagInput<T>(value_key, label_key)
return makeColumn(title, dataIndex, { return makeColumn(title, dataIndex, {
editable: true,
...other, ...other,
render: (item?: T[]) => item?.map((elm: T) => <Tag key={elm[label_key]} color={'blue'}>{other?.render?.(elm) ?? elm[label_key]}</Tag>) ?? '-', render: (item: T[] | undefined, dataset, index) => item?.map((elm: T) => <Tag key={elm[label_key]} color={'blue'}>{other?.render?.(elm, dataset, index) ?? elm[label_key]}</Tag>) ?? '-',
input: <InputComponent {...tagOther} options={options} />, input: <InputComponent {...tagOther} options={options} />,
}) })
} }

View File

@ -1,39 +1,49 @@
import { ReactNode } from 'react' import { Tooltip } from 'antd'
import { ColumnFilterItem } from 'antd/lib/table/interface'
import { Key, ReactNode } from 'react'
import { columnPropsOther, DataType, RenderMethod, SorterMethod } from '.' import { ColumnProps, makeColumn, DataType, RenderMethod, SorterMethod } from '.'
import { makeStringSorter } from '../sorters' import { getObjectByDeepKey } from '../Table'
export const makeStringSorter = <T extends string>(key: Key): SorterMethod<T> => (a, b) => {
const vA = a ? getObjectByDeepKey(key, a) : null
const vB = b ? getObjectByDeepKey(key, b) : null
if (!vA && !vB) return 0
if (!vA) return 1
if (!vB) return -1
return String(vA).localeCompare(String(vB))
}
export const makeTextRender = <T extends string>(def = '---', stringCutter?: (text: string) => string) => (value: T) => {
if (!value) return def
if (stringCutter) {
return (
<Tooltip title={value}>
{stringCutter(value)}
</Tooltip>
)
}
return value
}
export const makeFilterTextMatch = <T extends unknown>(key: keyof DataType<T>) => export const makeFilterTextMatch = <T extends unknown>(key: keyof DataType<T>) =>
(filterValue: T, dataItem: DataType<T>) => dataItem[key] === filterValue (filterValue: T, dataItem: DataType<T>) => dataItem[key] === filterValue
export const makeTextColumn = <T extends unknown = any>( export const makeTextColumn = <T extends unknown = any>(
title: ReactNode, title: ReactNode,
dataIndex: string, key: string,
filters: object[], filters?: ColumnFilterItem[],
sorter?: SorterMethod<T>, sorter?: SorterMethod<T>,
render?: RenderMethod<T>, render?: RenderMethod<T>,
other?: columnPropsOther other?: ColumnProps
) => ({ ) => makeColumn(title, key, {
title: title, editable: true,
dataIndex: dataIndex, filters,
key: dataIndex, onFilter: filters ? makeFilterTextMatch(key) : undefined,
filters: filters, sorter: sorter || makeStringSorter(key),
onFilter: filters ? makeFilterTextMatch(dataIndex) : null, render: render || makeTextRender(),
sorter: sorter ?? makeStringSorter(dataIndex),
render: render,
...other
})
export const makeDividedTextColumn = <T extends unknown = any>(
title: ReactNode,
dataIndex: string,
render?: RenderMethod<T>,
other?: columnPropsOther
) => ({
title: title,
dataIndex: dataIndex,
key: dataIndex,
render: render,
...other ...other
}) })

View File

@ -1,25 +1,37 @@
import { ReactNode } from 'react' import { Key, ReactNode } from 'react'
import { formatTime } from '@utils' import { makeColumn, ColumnProps, SorterMethod } from '.'
import { TimePickerWrapper, TimePickerWrapperProps, getObjectByDeepKey } from '..'
import { formatTime, timeToMoment } from '@utils'
import { TimeDto } from '@api'
import { makeColumn, columnPropsOther } from '.' export const makeTimeSorter = <T extends TimeDto>(key: Key): SorterMethod<T> => (a, b) => {
import { makeTimeSorter, TimePickerWrapper, TimePickerWrapperProps } from '..' const vA = a ? getObjectByDeepKey(key, a) : null
const vB = b? getObjectByDeepKey(key, b) : null
export const makeTimeColumn = ( if (!vA && !vB) return 0
if (!vA) return 1
if (!vB) return -1
return timeToMoment(vA).diff(timeToMoment(vB))
}
export const makeTimeColumn = <T extends TimeDto>(
title: ReactNode, title: ReactNode,
key: string, key: string,
utc?: boolean, utc?: boolean,
format?: string, format?: string,
other?: columnPropsOther, other?: ColumnProps,
pickerOther?: TimePickerWrapperProps, pickerOther?: TimePickerWrapperProps,
) => makeColumn(title, key, { ) => makeColumn<T>(title, key, {
editable: true,
...other, ...other,
render: (time) => ( render: (time) => (
<div className={'text-align-r-container'}> <div className={'text-align-r-container'}>
<span>{formatTime(time, utc, format) ?? '-'}</span> <span>{formatTime(time, utc, format) ?? '-'}</span>
</div> </div>
), ),
sorter: makeTimeSorter(key), sorter: makeTimeSorter<T>(key),
input: <TimePickerWrapper isUTC={utc} {...pickerOther} />, input: <TimePickerWrapper isUTC={utc} {...pickerOther} />,
}) })

View File

@ -1,11 +1,11 @@
import { memo, ReactNode, useCallback, useEffect, useState } from 'react' import { Key, memo, ReactNode, useCallback, useEffect, useState } from 'react'
import { Select, SelectProps } from 'antd' import { Select, SelectProps } from 'antd'
import { findTimezoneId, rawTimezones, TimezoneId } from '@utils' import { findTimezoneId, rawTimezones, TimezoneId } from '@utils'
import type { OmitExtends } from '@utils/types' import type { OmitExtends } from '@utils/types'
import { SimpleTimezoneDto } from '@api' import { SimpleTimezoneDto } from '@api'
import { columnPropsOther, makeColumn } from '.' import { ColumnProps, makeColumn } from '.'
const makeTimezoneLabel = (id?: string | null, hours?: number) => const makeTimezoneLabel = (id?: string | null, hours?: number) =>
`UTC${hours && hours > 0 ? '+':''}${hours ? ('0' + hours).slice(-2) : '~??'} :: ${id ?? 'Неизвестно'}` `UTC${hours && hours > 0 ? '+':''}${hours ? ('0' + hours).slice(-2) : '~??'} :: ${id ?? 'Неизвестно'}`
@ -18,7 +18,7 @@ export const timezoneOptions = Object
value: id, value: id,
})) }))
export const makeTimezoneRenderer = () => (timezone?: SimpleTimezoneDto) => { export const makeTimezoneRender = () => (timezone?: SimpleTimezoneDto) => {
if (!timezone) return 'UTC~?? :: Неизвестно' if (!timezone) return 'UTC~?? :: Неизвестно'
const { hours, timezoneId } = timezone const { hours, timezoneId } = timezone
return makeTimezoneLabel(timezoneId, hours) return makeTimezoneLabel(timezoneId, hours)
@ -46,17 +46,17 @@ export const TimezoneSelect = memo<TimezoneSelectProps>(({ onChange, value, defa
return (<Select {...other} onChange={onValueChanged} value={id} defaultValue={defaultTimezone} />) return (<Select {...other} onChange={onValueChanged} value={id} defaultValue={defaultTimezone} />)
}) })
export const makeTimezoneColumn = ( export const makeTimezoneColumn = <T extends SimpleTimezoneDto>(
title: ReactNode = 'Зона', title: ReactNode = 'Зона',
key: string = 'timezone', key: Key = 'timezone',
defaultValue?: SimpleTimezoneDto, defaultValue?: T,
allowClear: boolean = true, allowClear: boolean = true,
other?: columnPropsOther, other?: ColumnProps<T>,
selectOther?: TimezoneSelectProps selectOther?: TimezoneSelectProps
) => makeColumn(title, key, { ) => makeColumn(title, key, {
width: 100, width: 100,
editable: true, editable: true,
render: makeTimezoneRenderer(), render: makeTimezoneRender(),
input: ( input: (
<TimezoneSelect <TimezoneSelect
key={key} key={key}

View File

@ -6,8 +6,11 @@ import moment, { Moment } from 'moment'
import { defaultFormat } from '@utils' import { defaultFormat } from '@utils'
export type DatePickerWrapperProps = PickerDateProps<Moment> & { export type DatePickerWrapperProps = PickerDateProps<Moment> & {
/** Значение селектора */
value?: Moment, value?: Moment,
/** Метод вызывается при изменений даты */
onChange?: (date: Moment | null) => any onChange?: (date: Moment | null) => any
/** Конвертировать ли значение в UTC */
isUTC?: boolean isUTC?: boolean
} }

View File

@ -9,30 +9,41 @@ import { defaultFormat } from '@utils'
const { RangePicker } = DatePicker const { RangePicker } = DatePicker
export type DateRangeWrapperProps = RangePickerSharedProps<Moment> & { export type DateRangeWrapperProps = RangePickerSharedProps<Moment> & {
value?: RangeValue<Moment>, /** Значение селектора в виде массива из 2 элементов (от, до) */
value?: RangeValue<Moment>
/** Конвертировать ли значения в UTC */
isUTC?: boolean isUTC?: boolean
/** Разрешить сброс значения селектора */
allowClear?: boolean
} }
/**
* Подготавливает значения к передаче в селектор
*
* @param value Массиз из 2 дат
* @param isUTC Конвертировать ли значения в UTC
* @returns Подготовленные даты
*/
const normalizeDates = (value?: RangeValue<Moment>, isUTC?: boolean): RangeValue<Moment> => { const normalizeDates = (value?: RangeValue<Moment>, isUTC?: boolean): RangeValue<Moment> => {
if (!value) return [null, null] if (!value) return [null, null]
return [ return [
value[0] ? (isUTC ? moment.utc(value[0]).local() : moment(value[0])) : null, value[0] ? (isUTC ? moment.utc(value[0]).local() : moment(value[0])) : null,
value[1] ? (isUTC ? moment.utc(value[1]).local() : moment(value[1])) : null, value[1] ? (isUTC ? moment.utc(value[1]).local() : moment(value[1])) : null,
] ]
} }
export const DateRangeWrapper = memo<DateRangeWrapperProps>(({ value, isUTC, ...other }) => ( export const DateRangeWrapper = memo<DateRangeWrapperProps>(({ value, isUTC, allowClear, ...other }) => (
<RangePicker <RangePicker
showTime showTime
allowClear={false} allowClear={allowClear}
format={defaultFormat} format={defaultFormat}
defaultValue={[ defaultValue={[
moment().subtract(1, 'days').startOf('day'), moment().subtract(1, 'days').startOf('day'),
moment().startOf('day'), moment().startOf('day'),
]} ]}
value={normalizeDates(value)} value={normalizeDates(value, isUTC)}
{...other} {...other}
/> />
)) ))
export default DateRangeWrapper export default DateRangeWrapper

View File

@ -1,44 +1,54 @@
import { memo, ReactNode } from 'react' import { Key, memo, ReactNode, useMemo } from 'react'
import { Rule } from 'rc-field-form/lib/interface'
import { Form, Input } from 'antd' import { Form, Input } from 'antd'
import { NamePath, Rule } from 'rc-field-form/lib/interface'
type EditableCellProps = React.DetailedHTMLProps<React.TdHTMLAttributes<HTMLTableDataCellElement>, HTMLTableDataCellElement> & { export type EditableCellProps = React.DetailedHTMLProps<React.TdHTMLAttributes<HTMLTableDataCellElement>, HTMLTableDataCellElement> & {
editing?: boolean editing?: boolean
dataIndex?: NamePath dataIndex?: Key
input?: ReactNode input?: ReactNode
isRequired?: boolean isRequired?: boolean
title: string formItemClass?: string
formItemClass?: string formItemRules?: Rule[]
formItemRules?: Rule[] children: ReactNode
children: ReactNode initialValue: any
initialValue: any
} }
const itemStyle = { margin: 0 }
export const EditableCell = memo<EditableCellProps>(({ export const EditableCell = memo<EditableCellProps>(({
editing, editing,
dataIndex, dataIndex,
input, input,
isRequired, isRequired,
formItemClass, formItemClass,
formItemRules, formItemRules,
children, children,
initialValue, initialValue,
...other ...other
}) => ( }) => {
<td style={editing ? { padding: 0 } : undefined} {...other}> const rules = useMemo(() => formItemRules || [{
{!editing ? children : ( required: isRequired,
<Form.Item message: `Это обязательное поле!`,
name={dataIndex} }], [formItemRules, isRequired])
style={{ margin: 0 }}
className={formItemClass} const name = useMemo(() => dataIndex ? String(dataIndex).split('.') : undefined, [dataIndex])
rules={formItemRules ?? [{ const tdStyle = useMemo(() => editing ? { padding: 0 } : undefined, [editing])
required: isRequired,
message: `Это обязательное поле!`, const edititngItem = useMemo(() => (
}]} <Form.Item
initialValue={initialValue} name={name}
> style={itemStyle}
{input ?? <Input/>} className={formItemClass}
</Form.Item> rules={rules}
)} initialValue={initialValue}
</td> >
)) {input ?? <Input />}
</Form.Item>
), [name, rules, formItemClass, initialValue, input])
return (
<td style={tdStyle} {...other}>
{editing ? edititngItem : children}
</td>
)
})

View File

@ -221,9 +221,7 @@ export const EditableTable = memo(({
const mergedColumns = useMemo(() => [...columns.map(handleColumn), operationColumn], [columns, handleColumn, operationColumn]) const mergedColumns = useMemo(() => [...columns.map(handleColumn), operationColumn], [columns, handleColumn, operationColumn])
useEffect(() => { useEffect(() => setData(tryAddKeys(dataSource)), [dataSource])
setData(tryAddKeys(dataSource))
}, [dataSource])
return ( return (
<Form form={form}> <Form form={form}>

View File

@ -1,38 +1,96 @@
import { memo, useCallback, useEffect, useState } from 'react' import { Key, memo, useCallback, useEffect, useState } from 'react'
import { ColumnGroupType, ColumnType } from 'antd/lib/table' import { ColumnGroupType, ColumnType } from 'antd/lib/table'
import { Table as RawTable, TableProps } from 'antd' import { Table as RawTable, TableProps as RawTableProps } from 'antd'
import { RenderMethod } from './Columns'
import { tryAddKeys } from './EditableTable'
import TableSettingsChanger from './TableSettingsChanger'
import type { OmitExtends } from '@utils/types' import type { OmitExtends } from '@utils/types'
import { applyTableSettings, getTableSettings, setTableSettings, TableColumnSettings, TableSettings } from '@utils' import { applyTableSettings, getTableSettings, setTableSettings, TableColumnSettings, TableSettings } from '@utils'
import TableSettingsChanger from './TableSettingsChanger'
import { tryAddKeys } from './EditableTable'
import '@styles/index.css' import '@styles/index.css'
export type BaseTableColumn<T = any> = ColumnGroupType<T> | ColumnType<T> export type BaseTableColumn<T> = ColumnGroupType<T> | ColumnType<T>
export type TableColumns<T = any> = OmitExtends<BaseTableColumn<T>, TableColumnSettings>[] export type TableColumn<T> = OmitExtends<BaseTableColumn<T>, TableColumnSettings>
export type TableColumns<T> = TableColumn<T>[]
export type TableContainer = TableProps<any> & { export type TableProps<T> = RawTableProps<T> & {
columns: TableColumns /** Массив колонок таблицы с настройками (описаны в `TableColumnSettings`) */
dataSource: any[] columns: TableColumn<T>[]
/** Название таблицы для сохранения настроек */
tableName?: string tableName?: string
/** Отображать ли кнопку настроек */
showSettingsChanger?: boolean showSettingsChanger?: boolean
} }
export const Table = memo<TableContainer>(({ columns, dataSource, tableName, showSettingsChanger, ...other }) => { export interface DataSet<T, D = any> {
const [newColumns, setNewColumns] = useState<TableColumns>([]) [k: Key]: DataSet<T, D> | T | D
}
/**
* Получить значение из объекта по составному ключу
*
* Составной ключ имеет вид: `<поле 1>[.<поле 2>...]`
*
* @param key Составной ключ
* @param data Объект из которого будет полученно значение
* @returns Значение, найденное по ключу, либо `undefined`
*/
export const getObjectByDeepKey = <T,>(key: Key | undefined, data: DataSet<T>): T | undefined => {
if (!key) return undefined
const parts = String(key).split('.')
let out = data
for (let i = 0; i < parts.length && out; i++) {
const key = parts[i]
if (!(key in out)) return undefined // Если ключ не найдем, считаем значение null
out = out[key] as DataSet<T> // Углубляемся внутрь объекта
}
return out as T
}
/**
* Фабрика обёрток render-функций ячеек с поддержкой составных ключей
* @param key Составной ключ
* @param render Стандартная render-функция
* @returns Обёрнутая render-функция
*/
export const makeColumnRenderWrapper = <T extends DataSet<any>>(key: Key | undefined, render: RenderMethod<T, T> | undefined): RenderMethod<T, T> =>
(_: any, dataset: T, index: number) => {
const renderFunc: RenderMethod<T, T> = typeof render === 'function' ? render : (record) => String(record)
return renderFunc(getObjectByDeepKey<T>(key, dataset), dataset, index)
}
/**
* Применяет необходимые обёртки ко всем столбцам таблицы
* @param columns Исходные столбцы
* @returns Обёрнутые столбцы
*/
const applyColumnWrappers = <T extends DataSet<any>>(columns: TableColumns<T>): TableColumns<T> => columns.map((column) => {
if ('children' in column) {
return {
...column,
children: applyColumnWrappers(column.children),
}
}
return {
...column,
render: makeColumnRenderWrapper<T>(column.key, column.render),
}
})
function _Table<T extends DataSet<any>>({ columns, dataSource, tableName, showSettingsChanger, ...other }: TableProps<T>) {
const [newColumns, setNewColumns] = useState<TableColumn<T>[]>([])
const [settings, setSettings] = useState<TableSettings>({}) const [settings, setSettings] = useState<TableSettings>({})
const onSettingsChanged = useCallback((settings?: TableSettings | null) => { const onSettingsChanged = useCallback((settings?: TableSettings | null) => {
if (tableName) if (tableName)
setTableSettings(tableName, settings) setTableSettings(tableName, settings)
setSettings(settings ?? {}) setSettings(settings || {})
}, [tableName]) }, [tableName])
useEffect(() => setSettings(tableName ? getTableSettings(tableName) : {}), [tableName]) useEffect(() => setSettings(tableName ? getTableSettings(tableName) : {}), [tableName])
useEffect(() => setNewColumns(() => { useEffect(() => setNewColumns(() => {
const newColumns = applyTableSettings(columns, settings) const newColumns = applyTableSettings(applyColumnWrappers(columns), settings)
if (tableName && showSettingsChanger) { if (tableName && showSettingsChanger) {
const oldTitle = newColumns[0].title const oldTitle = newColumns[0].title
newColumns[0].title = (props) => ( newColumns[0].title = (props) => (
@ -52,6 +110,15 @@ export const Table = memo<TableContainer>(({ columns, dataSource, tableName, sho
{...other} {...other}
/> />
) )
}) }
/**
* Обёртка над компонентом таблицы AntD
*
* Особенности:
* * Поддержка составных ключей столбцов
* * Работа с настройками столбцов таблицы
*/
export const Table = memo(_Table) as typeof _Table
export default Table export default Table

View File

@ -1,13 +1,13 @@
import { memo, useCallback, useEffect, useState } from 'react' import { memo, useCallback, useEffect, useState } from 'react'
import { ColumnsType } from 'antd/lib/table' import { ColumnsType } from 'antd/lib/table'
import { Button, Modal, Switch, Table } from 'antd' import { Button, Modal, Switch } from 'antd'
import { SettingOutlined } from '@ant-design/icons' import { SettingOutlined } from '@ant-design/icons'
import { TableColumnSettings, makeTableSettings, mergeTableSettings, TableSettings } from '@utils' import { TableColumnSettings, makeTableSettings, mergeTableSettings, TableSettings } from '@utils'
import { TableColumns } from './Table' import { Table, TableColumns } from './Table'
import { makeColumn } from '.' import { makeColumn, makeTextColumn } from '.'
const parseSettings = (columns?: TableColumns, settings?: TableSettings | null): TableColumnSettings[] => { const parseSettings = <T extends object>(columns?: TableColumns<T>, settings?: TableSettings | null): TableColumnSettings[] => {
const newSettings = mergeTableSettings(makeTableSettings(columns ?? []), settings ?? {}) const newSettings = mergeTableSettings(makeTableSettings(columns ?? []), settings ?? {})
return Object.values(newSettings).map((set, i) => ({ ...set, key: i })) return Object.values(newSettings).map((set, i) => ({ ...set, key: i }))
} }
@ -15,14 +15,14 @@ const parseSettings = (columns?: TableColumns, settings?: TableSettings | null):
const unparseSettings = (columns: TableColumnSettings[]): TableSettings => const unparseSettings = (columns: TableColumnSettings[]): TableSettings =>
Object.fromEntries(columns.map((column) => [column.columnName, column])) Object.fromEntries(columns.map((column) => [column.columnName, column]))
export type TableSettingsChangerProps = { export type TableSettingsChangerProps<T extends object> = {
title?: string title?: string
columns?: TableColumns columns?: TableColumns<T>
settings?: TableSettings | null settings?: TableSettings | null
onChange: (settings: TableSettings | null) => void onChange: (settings: TableSettings | null) => void
} }
export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, columns, settings, onChange }) => { const _TableSettingsChanger = <T extends object>({ title, columns, settings, onChange }: TableSettingsChangerProps<T>) => {
const [visible, setVisible] = useState<boolean>(false) const [visible, setVisible] = useState<boolean>(false)
const [newSettings, setNewSettings] = useState<TableColumnSettings[]>(parseSettings(columns, settings)) const [newSettings, setNewSettings] = useState<TableColumnSettings[]>(parseSettings(columns, settings))
const [tableColumns, setTableColumns] = useState<ColumnsType<TableColumnSettings>>([]) const [tableColumns, setTableColumns] = useState<ColumnsType<TableColumnSettings>>([])
@ -36,30 +36,34 @@ export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, co
}, []) }, [])
const toogleAll = useCallback((show: boolean) => { const toogleAll = useCallback((show: boolean) => {
setNewSettings((oldSettings) => oldSettings.map((column) => { setNewSettings((oldSettings) =>
column.visible = show oldSettings.map((column) => {
return column column.visible = show
})) return column
})
)
}, []) }, [])
useEffect(() => { useEffect(() => {
setTableColumns([ setTableColumns([
makeColumn('Название', 'title'), makeTextColumn<string>('Название', 'title'),
makeColumn(null, 'visible', { makeColumn<any>(null, 'visible', {
title: () => ( title: () => (
<> <>
Показать Показать
<Button type={'link'} onClick={() => toogleAll(true)}>Показать все</Button> <Button type={'link'} onClick={() => toogleAll(true)}>
Показать все
</Button>
</> </>
), ),
render: (visible: boolean, _?: TableColumnSettings, index: number = NaN) => ( render: (visible, _, index = NaN) => (
<Switch <Switch
checked={visible} checked={visible}
checkedChildren={'Отображён'} checkedChildren={'Отображён'}
unCheckedChildren={'Скрыт'} unCheckedChildren={'Скрыт'}
onChange={(visible) => onVisibilityChange(index, visible)} onChange={(visible) => onVisibilityChange(index, visible)}
/> />
) ),
}), }),
]) ])
}, [toogleAll, onVisibilityChange]) }, [toogleAll, onVisibilityChange])
@ -80,7 +84,7 @@ export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, co
<> <>
<Modal <Modal
centered centered
visible={visible} open={visible}
onCancel={onModalCancel} onCancel={onModalCancel}
onOk={onModalOk} onOk={onModalOk}
title={title ?? 'Настройка отображения таблицы'} title={title ?? 'Настройка отображения таблицы'}
@ -88,9 +92,17 @@ export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, co
> >
<Table columns={tableColumns} dataSource={newSettings} /> <Table columns={tableColumns} dataSource={newSettings} />
</Modal> </Modal>
<Button size={'small'} style={{ position: 'absolute', left: 0, top: 0, opacity: .5 }} type={'link'} onClick={() => setVisible(true)} icon={<SettingOutlined />}/> <Button
size={'small'}
style={{ position: 'absolute', left: 0, top: 0, opacity: 0.5 }}
type={'link'}
onClick={() => setVisible(true)}
icon={<SettingOutlined />}
/>
</> </>
) )
}) }
export const TableSettingsChanger = memo(_TableSettingsChanger) as typeof _TableSettingsChanger
export default TableSettingsChanger export default TableSettingsChanger

View File

@ -6,8 +6,11 @@ import { defaultTimeFormat, momentToTime, timeToMoment } from '@utils'
import { TimeDto } from '@api' import { TimeDto } from '@api'
export type TimePickerWrapperProps = Omit<Omit<TimePickerProps, 'value'>, 'onChange'> & { export type TimePickerWrapperProps = Omit<Omit<TimePickerProps, 'value'>, 'onChange'> & {
/** Текущее значение */
value?: TimeDto, value?: TimeDto,
/** Метод вызывается при изменений времени */
onChange?: (date: TimeDto | null) => any onChange?: (date: TimeDto | null) => any
/** Конвертировать ли время в UTC */
isUTC?: boolean isUTC?: boolean
} }

View File

@ -1,61 +1,30 @@
export { makeDateSorter, makeNumericSorter, makeStringSorter, makeTimeSorter } from './sorters' export * from './EditableTable'
export { EditableTable, makeTableAction } from './EditableTable' export * from './DatePickerWrapper'
export { DatePickerWrapper } from './DatePickerWrapper' export * from './TimePickerWrapper'
export { TimePickerWrapper } from './TimePickerWrapper' export * from './DateRangeWrapper'
export { DateRangeWrapper } from './DateRangeWrapper' export * from './Table'
export { Table } from './Table' export * from './Columns'
export {
RegExpIsFloat,
timezoneOptions,
TimezoneSelect,
makeDateColumn,
makeTimeColumn,
makeGroupColumn,
makeColumn,
makeColumnsPlanFact,
makeFilterTextMatch,
makeNumericRender,
makeNumericColumn,
makeNumericDividedColumn,
makeNumericColumnOptions,
makeNumericColumnPlanFact,
makeNumericStartEnd,
makeNumericMinMax,
makeNumericAvgRange,
makeSelectColumn,
makeTagColumn,
makeTagInput,
makeTextColumn,
makeDividedTextColumn,
makeTimezoneColumn,
makeTimezoneRenderer,
} from './Columns'
export type {
DataType,
RenderMethod,
SorterMethod,
TagInputProps,
columnPropsOther,
} from './Columns'
export type { DateRangeWrapperProps } from './DateRangeWrapper'
export type { DatePickerWrapperProps } from './DatePickerWrapper'
export type { TimePickerWrapperProps } from './TimePickerWrapper'
export type { BaseTableColumn, TableColumns, TableContainer } from './Table'
export const defaultPagination = { export const defaultPagination = {
defaultPageSize: 14, defaultPageSize: 14,
showSizeChanger: true, showSizeChanger: true,
} }
type PaginationContainer = { export type PaginationContainer<T> = {
skip?: number skip?: number
take?: number take?: number
count?: number count?: number
items?: any[] | null items?: T[] | null
} }
export const makePaginationObject = (сontainer: PaginationContainer, ...other: any) => ({ /**
* Генерирует объект пагинации для компонента `Table` из данных от сервисов
*
* @param сontainer данные от сервиса
* @param other Дополнительные поля (передаются в объект напрямую в приоритете)
* @returns Объект пагинации
*/
export const makePaginationObject = <T, M extends object>(сontainer: PaginationContainer<T>, other: M) => ({
...other, ...other,
pageSize: сontainer.take, pageSize: сontainer.take,
total: сontainer.count ?? сontainer.items?.length ?? 0, total: сontainer.count ?? сontainer.items?.length ?? 0,

View File

@ -1,38 +0,0 @@
import { timeToMoment } from '@utils'
import { isRawDate } from '@utils'
import { TimeDto } from '@api'
import { DataType } from './Columns'
export const makeNumericSorter = <T extends unknown>(key: keyof DataType<T>) =>
(a: DataType<T>, b: DataType<T>) => Number(a[key]) - Number(b[key])
export const makeStringSorter = <T extends unknown>(key: keyof DataType<T>) => (a?: DataType<T> | null, b?: DataType<T> | null) => {
if (!a && !b) return 0
if (!a) return 1
if (!b) return -1
return String(a[key]).localeCompare(String(b[key]))
}
export const makeDateSorter = <T extends unknown>(key: keyof DataType<T>) => (a: DataType<T>, b: DataType<T>) => {
const adate = a[key]
const bdate = b[key]
if (!isRawDate(adate) || !isRawDate(bdate))
throw new Error('Date column contains not date formatted string(s)')
const date = new Date(adate)
return date.getTime() - new Date(bdate).getTime()
}
export const makeTimeSorter = <T extends TimeDto>(key: keyof DataType<T>) => (a: DataType<T>, b: DataType<T>) => {
const elma = a[key]
const elmb = b[key]
if (!elma && !elmb) return 0
if (!elma) return 1
if (!elmb) return -1
return timeToMoment(elma).diff(timeToMoment(elmb))
}

View File

@ -10,6 +10,7 @@ import { notify, upload } from './factory'
import { ErrorFetch } from './ErrorFetch' import { ErrorFetch } from './ErrorFetch'
export type UploadFormProps = { export type UploadFormProps = {
multiple?: boolean
url: string url: string
disabled?: boolean disabled?: boolean
accept?: string accept?: string
@ -22,7 +23,7 @@ export type UploadFormProps = {
onUploadError?: (error: unknown) => void onUploadError?: (error: unknown) => void
} }
export const UploadForm = memo<UploadFormProps>(({ url, disabled, style, formData, mimeTypes, onUploadStart, onUploadSuccess, onUploadComplete, onUploadError }) => { export const UploadForm = memo<UploadFormProps>(({ url, multiple, disabled, style, formData, mimeTypes, onUploadStart, onUploadSuccess, onUploadComplete, onUploadError }) => {
const [fileList, setfileList] = useState<UploadFile<any>[]>([]) const [fileList, setfileList] = useState<UploadFile<any>[]>([])
const checkMimeTypes = useCallback((file: RcFile) => { const checkMimeTypes = useCallback((file: RcFile) => {
@ -38,7 +39,7 @@ export const UploadForm = memo<UploadFormProps>(({ url, disabled, style, formDat
onUploadStart?.() onUploadStart?.()
try { try {
const formDataLocal = new FormData() const formDataLocal = new FormData()
fileList.forEach((val) => formDataLocal.append('files', val.originFileObj as Blob)) fileList.forEach((val) => formDataLocal.append(multiple ? 'files' : 'file', val.originFileObj as Blob))
if(formData) if(formData)
for(const propName in formData) for(const propName in formData)
@ -60,7 +61,7 @@ export const UploadForm = memo<UploadFormProps>(({ url, disabled, style, formDat
setfileList([]) setfileList([])
onUploadComplete?.() onUploadComplete?.()
} }
}, [fileList, formData, onUploadComplete, onUploadError, onUploadStart, onUploadSuccess, url]) }, [fileList, formData, onUploadComplete, onUploadError, onUploadStart, onUploadSuccess, url, multiple])
const isSendButtonEnabled = fileList.length > 0 const isSendButtonEnabled = fileList.length > 0
return( return(
@ -72,6 +73,7 @@ export const UploadForm = memo<UploadFormProps>(({ url, disabled, style, formDat
fileList={fileList} fileList={fileList}
onChange={(props) => setfileList(props.fileList)} onChange={(props) => setfileList(props.fileList)}
beforeUpload={checkMimeTypes} beforeUpload={checkMimeTypes}
maxCount={multiple ? undefined : 1}
> >
<Button disabled={disabled} icon={<UploadOutlined/>}>Загрузить файл</Button> <Button disabled={disabled} icon={<UploadOutlined/>}>Загрузить файл</Button>
</Upload> </Upload>

View File

@ -1,56 +1,110 @@
import { memo, MouseEventHandler, useCallback, useState } from 'react' import { memo, ReactNode, useCallback, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { Button, Dropdown, DropDownProps } from 'antd' import { Button, Collapse, Drawer, DrawerProps, Form, FormRule, Input, Popconfirm } from 'antd'
import { UserOutlined } from '@ant-design/icons' import { useForm } from 'antd/lib/form/Form'
import { getUserLogin, removeUser } from '@utils' import { useUser } from '@asb/context'
import { ChangePassword } from './ChangePassword' import { Grid, GridItem } from '@components/Grid'
import { PrivateMenu } from './Private' import LoaderPortal from '@components/LoaderPortal'
import { invokeWebApiWrapperAsync } from '@components/factory'
import { isURLAvailable, removeUser } from '@utils'
import { AuthService } from '@api'
import AdminPanel from '@pages/AdminPanel' import '@styles/components/user_menu.less'
type UserMenuProps = Omit<DropDownProps, 'overlay'> & { isAdmin?: boolean } export type UserMenuProps = DrawerProps & {
isAdmin?: boolean
additional?: ReactNode
}
export const UserMenu = memo<UserMenuProps>(({ isAdmin, ...other }) => { type ChangePasswordForm = {
const [isModalVisible, setIsModalVisible] = useState<boolean>(false) 'new-password': string
}
const newPasswordRules: FormRule[] = [{ required: true, message: 'Пожалуйста, введите новый пароль!' }]
const confirmPasswordRules: FormRule[] = [({ getFieldValue }) => ({ validator(_, value: string) {
if (value !== getFieldValue('new-password'))
return Promise.reject('Пароли не совпадают!')
return Promise.resolve()
}})]
export const UserMenu = memo<UserMenuProps>(({ isAdmin, additional, ...other }) => {
const [showLoader, setShowLoader] = useState<boolean>(false)
const [changeLoginForm] = useForm<ChangePasswordForm>()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const user = useUser()
const onChangePasswordClick: MouseEventHandler = useCallback((e) => { const navigateTo = useCallback((to: string) => navigate(to, { state: { from: location.pathname }}), [navigate, location.pathname])
setIsModalVisible(true)
e.preventDefault()
}, [])
const onChangePasswordOk = useCallback(() => { const onChangePasswordOk = useCallback(() => invokeWebApiWrapperAsync(
setIsModalVisible(false) async (values: any) => {
navigate('/login', { state: { from: location.pathname }}) await AuthService.changePassword(user.id ?? -1, `${values['new-password']}`)
}, [navigate, location]) removeUser()
navigateTo('/login')
},
setShowLoader,
`Не удалось сменить пароль пользователя ${user.login}`,
{ actionName: 'Смена пароля пользователя' },
), [navigateTo])
const logout = useCallback(() => {
removeUser()
navigateTo('/login')
}, [navigateTo])
return ( return (
<> <Drawer
<Dropdown closable
{...other} placement={'left'}
placement={'bottomRight'} className={'user-menu'}
overlay={( title={'Профиль пользователя'}
<PrivateMenu mode={'vertical'} style={{ textAlign: 'right' }}> {...other}
{isAdmin ? ( >
<PrivateMenu.Link visible key={'/'} path={'/'} title={'Вернуться на сайт'} /> <div className={'profile-links'}>
) : ( {isAdmin ? (
<PrivateMenu.Link path={'/admin'} content={AdminPanel} /> <Button onClick={() => navigateTo('/')}>Вернуться на сайт</Button>
)} ) : isURLAvailable('/admin') && (
<PrivateMenu.Link visible key={'change_password'} onClick={onChangePasswordClick} title={'Сменить пароль'} /> <Button onClick={() => navigateTo('/admin')}>Панель администратора</Button>
<PrivateMenu.Link visible key={'login'} path={'/login'} onClick={removeUser} title={'Выход'} />
</PrivateMenu>
)} )}
> <Button type={'ghost'} onClick={logout}>Выход</Button>
<Button icon={<UserOutlined/>}>{getUserLogin()}</Button> </div>
</Dropdown> <Collapse>
<ChangePassword <Collapse.Panel header={'Данные'} key={'summary'}>
visible={isModalVisible} <Grid>
onOk={onChangePasswordOk} <GridItem row={1} col={1}>Логин:</GridItem>
onCancel={() => setIsModalVisible(false)} <GridItem row={1} col={2}>{user.login}</GridItem>
/> <GridItem row={2} col={1}>Фамилия:</GridItem>
</> <GridItem row={2} col={2}>{user.surname}</GridItem>
<GridItem row={3} col={1}>Имя:</GridItem>
<GridItem row={3} col={2}>{user.name}</GridItem>
<GridItem row={4} col={1}>Отчество:</GridItem>
<GridItem row={4} col={2}>{user.patronymic}</GridItem>
<GridItem row={5} col={1}>E-mail:</GridItem>
<GridItem row={5} col={2}>{user.email}</GridItem>
</Grid>
</Collapse.Panel>
<Collapse.Panel header={'Смена пароля'} key={'change-password'}>
<LoaderPortal show={showLoader}>
<Form name={'change-password'} form={changeLoginForm} autoComplete={'off'} onFinish={onChangePasswordOk}>
<Form.Item name={'new-password'} label={'Новый пароль'} rules={newPasswordRules}>
<Input.Password placeholder={'Впишите новый пароль'} />
</Form.Item>
<Form.Item required name={'confirm-password'} rules={confirmPasswordRules} label={'Подтверждение пароля'}>
<Input.Password />
</Form.Item>
<Form.Item>
<Popconfirm title={'Вы уверены что хотите сменить пароль?'} onConfirm={changeLoginForm.submit} placement={'topRight'}>
<Button type={'primary'}>Сменить</Button>
</Popconfirm>
</Form.Item>
</Form>
</LoaderPortal>
</Collapse.Panel>
{additional}
</Collapse>
</Drawer>
) )
}) })

View File

@ -1,11 +1,10 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useElementSize } from 'usehooks-ts'
import { Property } from 'csstype' import { Property } from 'csstype'
import { Empty } from 'antd' import { Empty } from 'antd'
import * as d3 from 'd3' import * as d3 from 'd3'
import LoaderPortal from '@components/LoaderPortal' import LoaderPortal from '@components/LoaderPortal'
import { isDev, usePartialProps } from '@utils' import { isDev, useElementSize, usePartialProps } from '@utils'
import D3MouseZone from './D3MouseZone' import D3MouseZone from './D3MouseZone'
import { getChartClass } from './functions' import { getChartClass } from './functions'
@ -27,6 +26,7 @@ import {
D3TooltipSettings, D3TooltipSettings,
} from './plugins' } from './plugins'
import type { import type {
BaseDataType,
ChartAxis, ChartAxis,
ChartDataset, ChartDataset,
ChartDomain, ChartDomain,
@ -35,7 +35,7 @@ import type {
ChartTicks ChartTicks
} from './types' } from './types'
import '@styles/d3.less' import '@styles/components/d3.less'
const defaultOffsets: ChartOffset = { const defaultOffsets: ChartOffset = {
top: 10, top: 10,
@ -50,13 +50,13 @@ export const getByAccessor = <DataType extends Record<any, any>, R>(accessor: ke
return (d) => d[accessor] return (d) => d[accessor]
} }
const createAxis = <DataType,>(config: ChartAxis<DataType>) => { const createAxis = <DataType extends BaseDataType>(config: ChartAxis<DataType>) => {
if (config.type === 'time') if (config.type === 'time')
return d3.scaleTime() return d3.scaleTime()
return d3.scaleLinear() return d3.scaleLinear()
} }
export type D3ChartProps<DataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & { export type D3ChartProps<DataType extends BaseDataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
/** Параметры общей горизонтальной оси */ /** Параметры общей горизонтальной оси */
xAxis: ChartAxis<DataType> xAxis: ChartAxis<DataType>
/** Параметры графиков */ /** Параметры графиков */
@ -94,7 +94,7 @@ export type D3ChartProps<DataType> = React.DetailedHTMLProps<React.HTMLAttribute
} }
} }
const getDefaultXAxisConfig = <DataType,>(): ChartAxis<DataType> => ({ const getDefaultXAxisConfig = <DataType extends BaseDataType>(): ChartAxis<DataType> => ({
type: 'time', type: 'time',
accessor: (d: any) => new Date(d.date) accessor: (d: any) => new Date(d.date)
}) })
@ -130,7 +130,7 @@ const _D3Chart = <DataType extends Record<string, unknown>>({
const [charts, setCharts] = useState<ChartRegistry<DataType>[]>([]) const [charts, setCharts] = useState<ChartRegistry<DataType>[]>([])
const [rootRef, { width, height }] = useElementSize() const [rootRef, { width, height }] = useElementSize<HTMLDivElement>()
const xAxis = useMemo(() => { const xAxis = useMemo(() => {
if (!data) return if (!data) return

View File

@ -0,0 +1,104 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { Property } from 'csstype'
import * as d3 from 'd3'
import LoaderPortal from '@components/LoaderPortal'
import { useElementSize, usePartialProps } from '@utils'
import { ChartOffset } from './types'
import '@styles/components/d3.less'
export type PercentChartDataType = {
name: string
percent: number
color?: Property.Color
}
export type D3HorizontalChartProps = {
width?: Property.Width
height?: Property.Height
data: PercentChartDataType[]
offset?: Partial<ChartOffset>
afterDraw?: (d: d3.Selection<SVGRectElement, PercentChartDataType, SVGGElement, unknown>) => void
}
const defaultOffset = { top: 50, right: 100, bottom: 50, left: 100 }
export const D3HorizontalPercentChart = memo<D3HorizontalChartProps>(({
width: givenWidth = '100%',
height: givenHeight = '100%',
offset: givenOffset,
data,
afterDraw,
}) => {
const offset = usePartialProps<ChartOffset>(givenOffset, defaultOffset)
const [divRef, { width, height }] = useElementSize<HTMLDivElement>()
const rootRef = useRef<SVGGElement | null>(null)
const root = useCallback(() => rootRef.current ? d3.select(rootRef.current) : null, [rootRef.current])
const inlineWidth = useMemo(() => width - offset.left - offset.right, [width])
const inlineHeight = useMemo(() => height - offset.top - offset.bottom, [height])
const xScale = useMemo(() => d3.scaleLinear().domain([0, 100]).range([0, inlineWidth]), [inlineWidth])
const yScale = useMemo(() => d3.scaleBand().domain(data.map((d) => d.name)).range([0, inlineHeight]).padding(0.25), [data, inlineHeight])
useEffect(() => { /// Отрисовываем оси X сверху и снизу
const r = root()
if (width < 100 || height < 100 || !r) return
const xAxisTop = d3.axisTop(xScale).tickFormat((d) => `${d}%`).ticks(4).tickSize(-inlineHeight)
const xAxisBottom = d3.axisBottom(xScale).tickFormat((d) => `${d}%`).ticks(4)
r.selectChild<SVGGElement>('.axis.x.bottom').call(xAxisBottom)
r.selectChild<SVGGElement>('.axis.x.top').call(xAxisTop)
.selectAll('.tick')
.attr('class', 'tick grid-line')
}, [root, width, height, xScale, inlineHeight])
useEffect(() => { /// Отрисовываем ось Y слева
const r = root()
if (width < 100 || height < 100 || !r) return
r.selectChild<SVGGElement>('.axis.y.left').call(d3.axisLeft(yScale))
}, [root, width, height, yScale])
useEffect(() => {
const r = root()
if (width < 100 || height < 100 || !r) return
const delay = d3.transition().duration(500).ease(d3.easeLinear)
const rects = r.selectChild('.data').selectAll('rect').data(data)
rects.enter().append('rect')
rects.exit().remove()
const selectedRects = r.selectChild<SVGGElement>('.data')
.selectAll<SVGRectElement, PercentChartDataType>('rect')
selectedRects.attr('fill', (d) => d.color || 'black')
.attr('y', (d) => yScale(d.name) ?? null)
.attr('height', yScale.bandwidth())
.transition(delay)
.attr('width', (d) => d.percent > 0 ? xScale(d.percent) : 0)
afterDraw?.(selectedRects)
}, [data, width, height, root, yScale, xScale, afterDraw])
return (
<LoaderPortal show={false} style={{ width: givenWidth, height: givenHeight }}>
<div ref={divRef} style={{ width: '100%', height: '100%' }}>
<svg width={'100%'} height={'100%'}>
<g ref={rootRef} transform={`translate(${offset.left}, ${offset.top})`}>
<g className={'axis x top'}></g>
<g className={'axis x bottom'} transform={`translate(0, ${inlineHeight})`}></g>
<g className={'data'}></g>
<g className={'axis y left'}></g>
</g>
</svg>
</div>
</LoaderPortal>
)
})
export default D3HorizontalPercentChart

View File

@ -3,7 +3,7 @@ import * as d3 from 'd3'
import { ChartOffset } from './types' import { ChartOffset } from './types'
import '@styles/d3.less' import '@styles/components/d3.less'
export type D3MouseState = { export type D3MouseState = {
/** Позиция мыши по оси X */ /** Позиция мыши по оси X */

View File

@ -1,4 +1,2 @@
export * from './D3Chart' export * from './D3Chart'
export type { D3ChartProps } from './D3Chart'
export * from './types' export * from './types'

View File

@ -6,13 +6,14 @@ import { useD3MouseZone } from '@components/d3/D3MouseZone'
import { D3TooltipPosition } from '@components/d3/plugins/D3Tooltip' import { D3TooltipPosition } from '@components/d3/plugins/D3Tooltip'
import { getChartIcon, isDev, usePartialProps } from '@utils' import { getChartIcon, isDev, usePartialProps } from '@utils'
import { BaseDataType } from '../types'
import { ChartGroup, ChartSizes } from './D3MonitoringCharts' import { ChartGroup, ChartSizes } from './D3MonitoringCharts'
import '@styles/d3.less' import '@styles/components/d3.less'
type D3GroupRenderFunction<DataType> = (group: ChartGroup<DataType>, data: DataType[]) => ReactNode type D3GroupRenderFunction<DataType extends BaseDataType> = (group: ChartGroup<DataType>, data: DataType[], flowData: DataType[] | undefined) => ReactNode
export type D3HorizontalCursorSettings<DataType> = { export type D3HorizontalCursorSettings<DataType extends BaseDataType> = {
width?: number width?: number
height?: number height?: number
render?: D3GroupRenderFunction<DataType> render?: D3GroupRenderFunction<DataType>
@ -23,11 +24,13 @@ export type D3HorizontalCursorSettings<DataType> = {
lineStyle?: SVGProps<SVGLineElement> lineStyle?: SVGProps<SVGLineElement>
} }
export type D3HorizontalCursorProps<DataType> = D3HorizontalCursorSettings<DataType> & { export type D3HorizontalCursorProps<DataType extends BaseDataType> = D3HorizontalCursorSettings<DataType> & {
groups: ChartGroup<DataType>[] groups: ChartGroup<DataType>[]
data: DataType[] data: DataType[]
flowData: DataType[] | undefined
sizes: ChartSizes sizes: ChartSizes
yAxis?: d3.ScaleTime<number, number> yAxis?: d3.ScaleTime<number, number>
spaceBetweenGroups?: number
} }
const defaultLineStyle: SVGProps<SVGLineElement> = { const defaultLineStyle: SVGProps<SVGLineElement> = {
@ -36,7 +39,7 @@ const defaultLineStyle: SVGProps<SVGLineElement> = {
const offsetY = 5 const offsetY = 5
const makeDefaultRender = <DataType,>(): D3GroupRenderFunction<DataType> => (group, data) => ( const makeDefaultRender = <DataType extends BaseDataType>(): D3GroupRenderFunction<DataType> => (group, data, flowData) => (
<> <>
{data.length > 0 ? group.charts.map((chart) => { {data.length > 0 ? group.charts.map((chart) => {
const xFormat = (d: number | Date) => chart.xAxis.format?.(d) ?? `${(+d).toFixed(2)} ${chart.xAxis.unit ?? ''}` const xFormat = (d: number | Date) => chart.xAxis.format?.(d) ?? `${(+d).toFixed(2)} ${chart.xAxis.unit ?? ''}`
@ -61,8 +64,8 @@ const makeDefaultRender = <DataType,>(): D3GroupRenderFunction<DataType> => (gro
</> </>
) )
const _D3HorizontalCursor = <DataType,>({ const _D3HorizontalCursor = <DataType extends BaseDataType>({
width = 220, spaceBetweenGroups = 30,
height = 200, height = 200,
render = makeDefaultRender<DataType>(), render = makeDefaultRender<DataType>(),
position: _position = 'bottom', position: _position = 'bottom',
@ -72,6 +75,7 @@ const _D3HorizontalCursor = <DataType,>({
lineStyle: _lineStyle, lineStyle: _lineStyle,
data, data,
flowData,
groups, groups,
sizes, sizes,
yAxis, yAxis,
@ -165,7 +169,7 @@ const _D3HorizontalCursor = <DataType,>({
return (date >= currentDate - limitInS) && (date <= currentDate + limitInS) return (date >= currentDate - limitInS) && (date <= currentDate + limitInS)
}) })
const bodies = groups.map((group) => render(group, chartData)) const bodies = groups.map((group) => render(group, chartData, flowData))
setTooltipBodies(bodies) setTooltipBodies(bodies)
}, [groups, data, yAxis, lineY, fixed, mouseState.visible]) }, [groups, data, yAxis, lineY, fixed, mouseState.visible])
@ -178,19 +182,21 @@ const _D3HorizontalCursor = <DataType,>({
{groups.map((_, i) => ( {groups.map((_, i) => (
<foreignObject <foreignObject
key={`${i}`} key={`${i}`}
width={width} width={sizes.groupWidth + spaceBetweenGroups}
height={height} height={height}
x={sizes.groupLeft(i) + (sizes.groupWidth - width) / 2} x={sizes.groupLeft(i) - spaceBetweenGroups / 2}
y={tooltipY} y={tooltipY}
opacity={fixed || mouseState.visible ? 1 : 0} opacity={fixed || mouseState.visible ? 1 : 0}
pointerEvents={fixed ? 'all' : 'none'} pointerEvents={fixed ? 'all' : 'none'}
style={{ transition: 'opacity .1s ease-out', userSelect: fixed ? 'auto' : 'none' }} style={{ transition: 'opacity .1s ease-out', userSelect: fixed ? 'auto' : 'none' }}
> >
<div className={`tooltip ${position} ${className}`} <div className={'tooltip-wrapper'}>
style={{height: 'auto', bottom: `${position === 'bottom' ? '0' : ''}`}} <div className={`adaptive-tooltip tooltip ${position} ${className}`}
> style={{height: 'auto', bottom: `${position === 'bottom' ? '0' : ''}`}}
<div className={'tooltip-content'}> >
{tooltipBodies[i]} <div className={'tooltip-content'}>
{tooltipBodies[i]}
</div>
</div> </div>
</div> </div>
</foreignObject> </foreignObject>

View File

@ -1,7 +1,7 @@
import { Button, Checkbox, Form, FormItemProps, Input, InputNumber, Select, Tooltip } from 'antd' import { Button, Checkbox, Form, FormItemProps, Input, InputNumber, Select, Tooltip } from 'antd'
import { memo, useCallback, useEffect, useMemo } from 'react' import { memo, useCallback, useEffect, useMemo } from 'react'
import { MinMax } from '@components/d3/types' import { BaseDataType, MinMax } from '@components/d3/types'
import { ColorPicker, Color } from '@components/ColorPicker' import { ColorPicker, Color } from '@components/ColorPicker'
import { ExtendedChartDataset } from './D3MonitoringCharts' import { ExtendedChartDataset } from './D3MonitoringCharts'
@ -18,13 +18,13 @@ const lineTypes = [
{ value: 'needle', label: 'Иглы' }, { value: 'needle', label: 'Иглы' },
] ]
export type D3MonitoringChartEditorProps<DataType> = { export type D3MonitoringChartEditorProps<DataType extends BaseDataType> = {
group: ExtendedChartDataset<DataType>[] group: ExtendedChartDataset<DataType>[]
chart: ExtendedChartDataset<DataType> chart: ExtendedChartDataset<DataType>
onChange: (value: ExtendedChartDataset<DataType>) => boolean onChange: (value: ExtendedChartDataset<DataType>) => boolean
} }
const _D3MonitoringChartEditor = <DataType,>({ const _D3MonitoringChartEditor = <DataType extends BaseDataType>({
group, group,
chart: value, chart: value,
onChange, onChange,
@ -93,8 +93,8 @@ const _D3MonitoringChartEditor = <DataType,>({
</Item> </Item>
<Item label={'Диапазон'}> <Item label={'Диапазон'}>
<Input.Group compact> <Input.Group compact>
<InputNumber disabled={!!value.linkedTo} value={value.xDomain?.min} onChange={(min) => onDomainChange({ min })} placeholder={'Мин'} /> <InputNumber disabled={!!value.linkedTo} value={value.xDomain?.min} onChange={(min) => onDomainChange({ min: min ?? undefined })} placeholder={'Мин'} />
<InputNumber disabled={!!value.linkedTo} value={value.xDomain?.max} onChange={(max) => onDomainChange({ max })} placeholder={'Макс'} /> <InputNumber disabled={!!value.linkedTo} value={value.xDomain?.max} onChange={(max) => onDomainChange({ max: max ?? undefined })} placeholder={'Макс'} />
<Button <Button
disabled={!!value.linkedTo || (!Number.isFinite(value.xDomain?.min) && !Number.isFinite(value.xDomain?.max))} disabled={!!value.linkedTo || (!Number.isFinite(value.xDomain?.min) && !Number.isFinite(value.xDomain?.max))}
onClick={() => onDomainChange({ min: undefined, max: undefined })} onClick={() => onDomainChange({ min: undefined, max: undefined })}

View File

@ -1,13 +1,13 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useElementSize } from 'usehooks-ts'
import { Property } from 'csstype' import { Property } from 'csstype'
import { Empty } from 'antd' import { Empty } from 'antd'
import * as d3 from 'd3' import * as d3 from 'd3'
import LoaderPortal from '@components/LoaderPortal' import LoaderPortal from '@components/LoaderPortal'
import { isDev, usePartialProps, useUserSettings } from '@utils' import { isDev, useElementSize, usePartialProps, useUserSettings } from '@utils'
import { import {
BaseDataType,
ChartAxis, ChartAxis,
ChartDataset, ChartDataset,
ChartOffset, ChartOffset,
@ -34,13 +34,14 @@ const roundTo = (v: number, to: number = 50) => {
return (v > 0 ? Math.ceil : Math.round)(v / to) * to return (v > 0 ? Math.ceil : Math.round)(v / to) * to
} }
const getNear = (n: number) => {
let k = 0
for (let c = Math.abs(n); c >= 1; c /= 10) k++
return Math.pow(10, k) * Math.sign(n)
}
const calculateDomain = (mm: MinMax): Required<MinMax> => { const calculateDomain = (mm: MinMax): Required<MinMax> => {
let round = Math.abs((mm.max ?? 0) - (mm.min ?? 0)) const round = getNear(Math.abs((mm.max ?? 0) - (mm.min ?? 0))) || 10
if (round < 10) round = 10
else if (round < 100) round = roundTo(round, 10)
else if (round < 1000) round = roundTo(round, 100)
else if (round < 10000) round = roundTo(round, 1000)
else round = 0
let min = roundTo(mm.min ?? 0, round) let min = roundTo(mm.min ?? 0, round)
let max = roundTo(mm.max ?? round, round) let max = roundTo(mm.max ?? round, round)
if (round && Math.abs(min - max) < round) { if (round && Math.abs(min - max) < round) {
@ -51,7 +52,7 @@ const calculateDomain = (mm: MinMax): Required<MinMax> => {
return { min, max } return { min, max }
} }
export type ExtendedChartDataset<DataType> = ChartDataset<DataType> & { export type ExtendedChartDataset<DataType extends BaseDataType> = ChartDataset<DataType> & {
/** Диапазон отображаемых значений по горизонтальной оси */ /** Диапазон отображаемых значений по горизонтальной оси */
xDomain: MinMax xDomain: MinMax
/** Скрыть отображение шкалы графика */ /** Скрыть отображение шкалы графика */
@ -60,9 +61,9 @@ export type ExtendedChartDataset<DataType> = ChartDataset<DataType> & {
showCurrentValue?: boolean showCurrentValue?: boolean
} }
export type ExtendedChartRegistry<DataType> = ChartRegistry<DataType> & ExtendedChartDataset<DataType> export type ExtendedChartRegistry<DataType extends BaseDataType> = ChartRegistry<DataType> & ExtendedChartDataset<DataType>
export type ChartGroup<DataType> = { export type ChartGroup<DataType extends BaseDataType> = {
/** Получить D3 выборку, содержащую корневой G-элемент группы */ /** Получить D3 выборку, содержащую корневой G-элемент группы */
(): d3.Selection<SVGGElement, any, any, any> (): d3.Selection<SVGGElement, any, any, any>
/** Уникальный ключ группы (индекс) */ /** Уникальный ключ группы (индекс) */
@ -72,8 +73,8 @@ export type ChartGroup<DataType> = {
} }
const defaultOffsets: ChartOffset = { const defaultOffsets: ChartOffset = {
top: 10, top: 0,
bottom: 10, bottom: 0,
left: 100, left: 100,
right: 20, right: 20,
} }
@ -86,12 +87,12 @@ const defaultRegulators: TelemetryRegulators = {
5: { color: '#007070', label: 'Расход' }, 5: { color: '#007070', label: 'Расход' },
} }
const getDefaultYAxisConfig = <DataType,>(): ChartAxis<DataType> => ({ const getDefaultYAxisConfig = <DataType extends BaseDataType>(): ChartAxis<DataType> => ({
type: 'time', type: 'time',
accessor: (d: any) => new Date(d.date) accessor: (d: any) => new Date(d.date)
}) })
const getDefaultYTicks = <DataType,>(): Required<ChartTick<DataType>> => ({ const getDefaultYTicks = <DataType extends BaseDataType>(): Required<ChartTick<DataType>> => ({
visible: false, visible: false,
format: (d: d3.NumberValue, idx: number, data?: DataType) => String(d), format: (d: d3.NumberValue, idx: number, data?: DataType) => String(d),
color: 'lightgray', color: 'lightgray',
@ -101,7 +102,7 @@ const getDefaultYTicks = <DataType,>(): Required<ChartTick<DataType>> => ({
/** /**
* @template DataType тип данных отображаемых записей * @template DataType тип данных отображаемых записей
*/ */
export type D3MonitoringChartsProps<DataType extends Record<string, any>> = Omit<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, 'ref'> & { export type D3MonitoringChartsProps<DataType extends BaseDataType> = Omit<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, 'ref'> & {
/** Двумерный массив датасетов (группа-график) */ /** Двумерный массив датасетов (группа-график) */
datasetGroups: ExtendedChartDataset<DataType>[][] datasetGroups: ExtendedChartDataset<DataType>[][]
/** Ширина графика числом пикселей или CSS-значением (px/%/em/rem) */ /** Ширина графика числом пикселей или CSS-значением (px/%/em/rem) */
@ -114,6 +115,8 @@ export type D3MonitoringChartsProps<DataType extends Record<string, any>> = Omit
loading?: boolean loading?: boolean
/** Массив отображаемых данных */ /** Массив отображаемых данных */
data?: DataType[] data?: DataType[]
/** Массив данных для прямоугольников */
flowData?: DataType[]
/** Отступы графика от края SVG */ /** Отступы графика от края SVG */
offset?: Partial<ChartOffset> offset?: Partial<ChartOffset>
/** Цвет фона в формате CSS-значения */ /** Цвет фона в формате CSS-значения */
@ -179,6 +182,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
loading = false, loading = false,
datasetGroups, datasetGroups,
data, data,
flowData,
plugins, plugins,
offset: _offset, offset: _offset,
yAxis: _yAxisConfig, yAxis: _yAxisConfig,
@ -192,6 +196,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
methods, methods,
className = '', className = '',
style,
...other ...other
}: D3MonitoringChartsProps<DataType>) => { }: D3MonitoringChartsProps<DataType>) => {
const [datasets, setDatasets, resetDatasets] = useUserSettings(chartName, datasetGroups) const [datasets, setDatasets, resetDatasets] = useUserSettings(chartName, datasetGroups)
@ -207,7 +212,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
const yTicks = usePartialProps<Required<ChartTick<DataType>>>(_yTicks, getDefaultYTicks) const yTicks = usePartialProps<Required<ChartTick<DataType>>>(_yTicks, getDefaultYTicks)
const yAxisConfig = usePartialProps<ChartAxis<DataType>>(_yAxisConfig, getDefaultYAxisConfig) const yAxisConfig = usePartialProps<ChartAxis<DataType>>(_yAxisConfig, getDefaultYAxisConfig)
const [rootRef, { width, height }] = useElementSize() const [rootRef, { width, height }] = useElementSize<HTMLDivElement>()
const yAxisArea = useCallback(() => d3.select(yAxisRef), [yAxisRef]) const yAxisArea = useCallback(() => d3.select(yAxisRef), [yAxisRef])
const axesArea = useCallback(() => d3.select(axesAreaRef), [axesAreaRef]) const axesArea = useCallback(() => d3.select(axesAreaRef), [axesAreaRef])
@ -240,11 +245,11 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
if (!data) return if (!data) return
const yAxis = d3.scaleTime() const yAxis = d3.scaleTime()
.domain([yDomain?.min ?? 0, yDomain?.max ?? 0]) .domain([yDomain?.min || 0, yDomain?.max || 0])
.range([0, sizes.chartsHeight]) .range([0, sizes.chartsHeight])
return yAxis return yAxis
}, [groups, data, yDomain, sizes.chartsHeight]) }, [groups, data, yDomain, sizes])
const chartDomains = useMemo(() => groups.map((group) => { const chartDomains = useMemo(() => groups.map((group) => {
const out: [string | number, ChartDomain][] = group.charts.map((chart) => { const out: [string | number, ChartDomain][] = group.charts.map((chart) => {
@ -350,10 +355,10 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
x: getByAccessor(dataset.xAxis?.accessor), x: getByAccessor(dataset.xAxis?.accessor),
} }
) )
if (newChart.type === 'line') if (newChart.type === 'line')
newChart.optimization = false newChart.optimization = false
// Если у графика нет группы создаём её // Если у графика нет группы создаём её
if (newChart().empty()) if (newChart().empty())
group().append('g') group().append('g')
@ -451,7 +456,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
.tickSize(yTicks.visible ? -width + offset.left + offset.right : 0) .tickSize(yTicks.visible ? -width + offset.left + offset.right : 0)
.ticks(yTicks.count) as any // TODO: Исправить тип .ticks(yTicks.count) as any // TODO: Исправить тип
) )
yAxisArea().selectAll('.tick line').attr('stroke', yTicks.color) yAxisArea().selectAll('.tick line').attr('stroke', yTicks.color)
}, [yAxisArea, yAxis, animDurationMs, width, offset, yTicks]) }, [yAxisArea, yAxis, animDurationMs, width, offset, yTicks])
@ -461,8 +466,8 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
groups.forEach((group, i) => { groups.forEach((group, i) => {
group() group()
.attr('transform', `translate(${sizes.groupLeft(group.key)}, 0)`) .attr('transform', `translate(${sizes.groupLeft(group.key)}, 0)`)
.attr('clip-path', `url(#chart-clip)`) .attr('clip-path', `url(#chart-group-clip)`)
group.charts.forEach((chart) => { group.charts.forEach((chart) => {
chart() chart()
.attr('color', chart.color || null) .attr('color', chart.color || null)
@ -490,21 +495,21 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
chartData = renderArea<DataType>(xAxis, yAxis, chart, chartData) chartData = renderArea<DataType>(xAxis, yAxis, chart, chartData)
break break
case 'rect_area': case 'rect_area':
renderRectArea<DataType>(xAxis, yAxis, chart) renderRectArea<DataType>(xAxis, yAxis, chart, flowData)
break break
default: default:
break break
} }
if (chart.point) if (chart.point)
renderPoint<DataType>(xAxis, yAxis, chart, chartData, true) renderPoint<DataType>(xAxis, yAxis, chart, chartData, true)
if (dash) chart().attr('stroke-dasharray', dash) if (dash) chart().attr('stroke-dasharray', dash)
chart.afterDraw?.(chart) chart.afterDraw?.(chart)
}) })
}) })
}, [data, groups, height, offset, sizes, chartDomains]) }, [data, flowData, groups, height, offset, sizes, chartDomains, yAxis])
return ( return (
<LoaderPortal <LoaderPortal
@ -512,6 +517,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
style={{ style={{
width: givenWidth, width: givenWidth,
height: givenHeight, height: givenHeight,
...style,
}} }}
> >
<div <div
@ -527,7 +533,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
> >
<svg ref={setSvgRef} width={'100%'} height={'100%'}> <svg ref={setSvgRef} width={'100%'} height={'100%'}>
<defs> <defs>
<clipPath id={`chart-clip`}> <clipPath id={`chart-group-clip`}>
{/* Сдвиг во все стороны на 1 px чтобы линии на краях было видно */} {/* Сдвиг во все стороны на 1 px чтобы линии на краях было видно */}
<rect x={-1} y={-1} width={sizes.groupWidth + 2} height={sizes.chartsHeight + 2} /> <rect x={-1} y={-1} width={sizes.groupWidth + 2} height={sizes.chartsHeight + 2} />
</clipPath> </clipPath>
@ -558,7 +564,6 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
<D3MonitoringCurrentValues<DataType> <D3MonitoringCurrentValues<DataType>
groups={groups} groups={groups}
data={data} data={data}
left={offset.left}
sizes={sizes} sizes={sizes}
/> />
<D3MouseZone width={width} height={height} offset={{ ...offset, top: sizes.chartsTop }}> <D3MouseZone width={width} height={height} offset={{ ...offset, top: sizes.chartsTop }}>
@ -567,7 +572,9 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
yAxis={yAxis} yAxis={yAxis}
groups={groups} groups={groups}
sizes={sizes} sizes={sizes}
spaceBetweenGroups={spaceBetweenGroups}
data={data} data={data}
flowData={flowData}
height={height} height={height}
/> />
</D3MouseZone> </D3MouseZone>

View File

@ -1,23 +1,28 @@
import { memo } from 'react' import { memo } from 'react'
import { BaseDataType } from '@components/d3/types'
import { ChartGroup, ChartSizes } from '@components/d3/monitoring/D3MonitoringCharts' import { ChartGroup, ChartSizes } from '@components/d3/monitoring/D3MonitoringCharts'
import { makeDisplayValue } from '@utils' import { makeDisplayValue } from '@utils'
export type D3MonitoringCurrentValuesProps<DataType> = { export type D3MonitoringCurrentValuesProps<DataType extends BaseDataType> = {
/** Группы графиков */
groups: ChartGroup<DataType>[] groups: ChartGroup<DataType>[]
/** Массив данных графика */
data: DataType[] data: DataType[]
left: number /** Объект, хранящий полезные размеры и отступы графика (нужен только groupWidth, chartsTop и groupLeft) */
sizes: ChartSizes sizes: ChartSizes
} }
const display = makeDisplayValue({ def: '---', fixed: 2 }) const display = makeDisplayValue({ def: '---', fixed: 2 })
const _D3MonitoringCurrentValues = <DataType,>({ groups, data, left, sizes }: D3MonitoringCurrentValuesProps<DataType>) => ( /// `Array.at` вместе с `??` возвращает странный тип, поэтому его пока пришлось пометить как `any`
<g transform={`translate(${left}, ${sizes.chartsTop})`} pointerEvents={'none'}> /// TODO: Исправить тип
const _D3MonitoringCurrentValues = <DataType extends BaseDataType>({ groups, data, sizes }: D3MonitoringCurrentValuesProps<DataType>) => (
<g transform={`translate(${sizes.left}, ${sizes.chartsTop})`} pointerEvents={'none'}>
{groups.map((group) => ( {groups.map((group) => (
<g key={group.key} transform={`translate(${sizes.groupLeft(group.key)}, 0)`}> <g key={group.key} transform={`translate(${sizes.groupLeft(group.key)}, 0)`}>
{group.charts.filter((chart) => chart.showCurrentValue).map((chart, i) => ( {group.charts.filter((chart) => chart.showCurrentValue).map((chart, i) => (
<g key={chart.key} stroke={'white'} fill={chart.color} strokeWidth={3} paintOrder={'stroke'}> <g key={chart.key} stroke={'white'} fill={chart.color} strokeWidth={4} paintOrder={'stroke'} style={{ fontWeight: 600 }}>
<text x={sizes.groupWidth / 2 - 10} textAnchor={'end'} y={15 + i * 20}>{chart.shortLabel ?? chart.label}:</text> <text x={sizes.groupWidth / 2 - 10} textAnchor={'end'} y={15 + i * 20}>{chart.shortLabel ?? chart.label}:</text>
<text x={sizes.groupWidth / 2 + 10} textAnchor={'start'} y={15 + i * 20}>{display(chart.x((data.at(-1) ?? {}) as any))}</text> <text x={sizes.groupWidth / 2 + 10} textAnchor={'start'} y={15 + i * 20}>{display(chart.x((data.at(-1) ?? {}) as any))}</text>
</g> </g>
@ -27,6 +32,15 @@ const _D3MonitoringCurrentValues = <DataType,>({ groups, data, left, sizes }: D3
</g> </g>
) )
/**
* Отрисовывает последние значения графиков
*
* @typeParam DataType - тип данных для отрисовки графиков
*
* @param groups - Массив групп графиков
* @param data - Массив данных графиков
* @param sizes - Объект с полезными размерами и отступами внутри svg
*/
export const D3MonitoringCurrentValues = memo(_D3MonitoringCurrentValues) as typeof _D3MonitoringCurrentValues export const D3MonitoringCurrentValues = memo(_D3MonitoringCurrentValues) as typeof _D3MonitoringCurrentValues
export default D3MonitoringCurrentValues export default D3MonitoringCurrentValues

View File

@ -1,17 +1,18 @@
import { CSSProperties, Key, memo, useCallback, useEffect, useMemo, useState } from 'react' import { CSSProperties, Key, memo, useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Divider, Empty, Modal, Popconfirm, Tooltip, Tree } from 'antd' import { Button, Divider, Empty, Modal, Popconfirm, Tooltip, Tree, TreeDataNode } from 'antd'
import { UndoOutlined } from '@ant-design/icons' import { UndoOutlined } from '@ant-design/icons'
import { EventDataNode } from 'antd/lib/tree' import { EventDataNode } from 'antd/lib/tree'
import { notify } from '@components/factory' import { notify } from '@components/factory'
import { getChartIcon } from '@utils' import { getChartIcon } from '@utils'
import { BaseDataType } from '../types'
import { ExtendedChartDataset } from './D3MonitoringCharts' import { ExtendedChartDataset } from './D3MonitoringCharts'
import { TelemetryRegulators } from './D3MonitoringLimitChart' import { TelemetryRegulators } from './D3MonitoringLimitChart'
import D3MonitoringChartEditor from './D3MonitoringChartEditor' import D3MonitoringChartEditor from './D3MonitoringChartEditor'
import D3MonitoringLimitEditor from './D3MonitoringLimitEditor' import D3MonitoringLimitEditor from './D3MonitoringLimitEditor'
export type D3MonitoringGroupsEditorProps<DataType> = { export type D3MonitoringGroupsEditorProps<DataType extends BaseDataType> = {
visible?: boolean visible?: boolean
groups: ExtendedChartDataset<DataType>[][] groups: ExtendedChartDataset<DataType>[][]
regulators: TelemetryRegulators regulators: TelemetryRegulators
@ -20,7 +21,7 @@ export type D3MonitoringGroupsEditorProps<DataType> = {
onReset: () => void onReset: () => void
} }
const getChartLabel = <DataType,>(chart: ExtendedChartDataset<DataType>) => ( const getChartLabel = <DataType extends BaseDataType>(chart: ExtendedChartDataset<DataType>) => (
<Tooltip title={chart.label}> <Tooltip title={chart.label}>
{getChartIcon(chart)} {chart.label} {getChartIcon(chart)} {chart.label}
</Tooltip> </Tooltip>
@ -34,14 +35,14 @@ const divStyle: CSSProperties = {
flexGrow: 1, flexGrow: 1,
} }
const getNodePos = (node: EventDataNode): { group: number, chart?: number } => { const getNodePos = (node: EventDataNode<TreeDataNode>): { group: number, chart?: number } => {
const out = node.pos.split('-').map(Number) const out = node.pos.split('-').map(Number)
return { group: out[1], chart: out[2] } return { group: out[1], chart: out[2] }
} }
type EditingMode = null | 'limit' | 'chart' type EditingMode = null | 'limit' | 'chart'
const _D3MonitoringEditor = <DataType,>({ const _D3MonitoringEditor = <DataType extends BaseDataType>({
visible, visible,
groups: oldGroups, groups: oldGroups,
regulators: oldRegulators, regulators: oldRegulators,
@ -61,8 +62,8 @@ const _D3MonitoringEditor = <DataType,>({
const onModalOk = useCallback(() => onChange(groups, regulators), [groups, regulators]) const onModalOk = useCallback(() => onChange(groups, regulators), [groups, regulators])
const onDrop = useCallback((info: { const onDrop = useCallback((info: {
node: EventDataNode node: EventDataNode<TreeDataNode>
dragNode: EventDataNode dragNode: EventDataNode<TreeDataNode>
dropPosition: number dropPosition: number
}) => { }) => {
const { dragNode, dropPosition, node } = info const { dragNode, dropPosition, node } = info
@ -134,7 +135,7 @@ const _D3MonitoringEditor = <DataType,>({
<Modal <Modal
centered centered
width={800} width={800}
visible={visible} open={visible}
title={'Настройка групп графиков'} title={'Настройка групп графиков'}
onCancel={onCancel} onCancel={onCancel}
footer={( footer={(
@ -152,18 +153,18 @@ const _D3MonitoringEditor = <DataType,>({
<Tree <Tree
draggable draggable
selectable selectable
onExpand={(keys) => setExpand(keys)} onExpand={(keys: Key[]) => setExpand(keys)}
expandedKeys={expand} expandedKeys={expand}
selectedKeys={selected} selectedKeys={selected}
treeData={treeItems} treeData={treeItems}
onDrop={onDrop} onDrop={onDrop}
onSelect={(value) => { onSelect={(value: Key[]) => {
setSelected(value) setSelected(value)
setMode('chart') setMode('chart')
}} }}
height={250} height={250}
/> />
<Button onClick={() => setMode('limit')}>Ограничение подачи</Button> {/* <Button onClick={() => setMode('limit')}>Ограничение подачи</Button> */}
</div> </div>
<Divider type={'vertical'} style={{ height: '100%', padding: '0 5px' }} /> <Divider type={'vertical'} style={{ height: '100%', padding: '0 5px' }} />
<div style={divStyle}> <div style={divStyle}>

View File

@ -92,6 +92,15 @@ const _D3MonitoringLimitChart = <DataType extends TelemetryDataSaubDto>({
const [ref, setRef] = useState<SVGGElement | null>(null) const [ref, setRef] = useState<SVGGElement | null>(null)
const [selected, setSelected] = useState<LimitChartData & { x: number, y: number, visible: boolean }>() const [selected, setSelected] = useState<LimitChartData & { x: number, y: number, visible: boolean }>()
const selectedRegulator = useMemo(() => {
if (!selected) return null
const out = regulators[selected.id] || {
label: `ID = ${selected.id}`,
color: 'black',
}
return out
}, [selected, regulators])
const data = useMemo(() => calcualteData(chartData), [chartData]) const data = useMemo(() => calcualteData(chartData), [chartData])
useEffect(() => { useEffect(() => {
@ -105,7 +114,7 @@ const _D3MonitoringLimitChart = <DataType extends TelemetryDataSaubDto>({
.attr('width', width) .attr('width', width)
.attr('height', (d) => Math.max(yAxis(d.dateEnd) - yAxis(d.dateStart), 1)) .attr('height', (d) => Math.max(yAxis(d.dateEnd) - yAxis(d.dateStart), 1))
.attr('y', (d) => yAxis(d.dateStart)) .attr('y', (d) => yAxis(d.dateStart))
.attr('fill', (d) => regulators[d.id].color) .attr('fill', (d) => regulators[d.id]?.color || 'black')
.on('mouseover', (_, d) => { .on('mouseover', (_, d) => {
const y = yAxis(d.dateStart) - tooltipHeight const y = yAxis(d.dateStart) - tooltipHeight
setSelected({ ...d, y, x: -tooltipWidth - 10, visible: true }) setSelected({ ...d, y, x: -tooltipWidth - 10, visible: true })
@ -130,14 +139,24 @@ const _D3MonitoringLimitChart = <DataType extends TelemetryDataSaubDto>({
return ( return (
<g transform={`translate(${left}, ${top})`} stroke={'#333'} strokeWidth={1} fill={'none'}> <g transform={`translate(${left}, ${top})`} stroke={'#333'} strokeWidth={1} fill={'none'}>
<g ref={setRef} > <defs>
<g className={'bars'} strokeWidth={0} /> <clipPath id={`chart-limit-clip`}>
{/* Сдвиг во все стороны на 1 px чтобы линии на краях было видно */}
<rect x={0} y={0} width={width} height={height} />
</clipPath>
<clipPath id={`chart-limit-fill-clip`}>
<rect x={-zoneWidth} y={0} width={zoneWidth} height={height} />
</clipPath>
</defs>
<g ref={setRef}>
<g className={'bars'} strokeWidth={0} clipPath={`url(#chart-limit-clip)`} />
{selected && ( {selected && (
<g <g
style={opacityStyle} style={opacityStyle}
pointerEvents={'none'} pointerEvents={'none'}
strokeOpacity={0.4} strokeOpacity={0.4}
stroke={regulators[selected.id].color} stroke={selectedRegulator?.color}
clipPath={`url(#chart-limit-fill-clip)`}
> >
<line x1={-zoneWidth} x2={0} y1={zoneY1} y2={zoneY1} /> <line x1={-zoneWidth} x2={0} y1={zoneY1} y2={zoneY1} />
<line x1={-zoneWidth} x2={0} y1={zoneY2} y2={zoneY2} /> <line x1={-zoneWidth} x2={0} y1={zoneY2} y2={zoneY2} />
@ -148,7 +167,7 @@ const _D3MonitoringLimitChart = <DataType extends TelemetryDataSaubDto>({
y={zoneY1} y={zoneY1}
width={zoneWidth} width={zoneWidth}
height={zoneY2 - zoneY1} height={zoneY2 - zoneY1}
fill={regulators[selected.id].color} fill={selectedRegulator?.color}
/> />
</g> </g>
)} )}
@ -158,7 +177,7 @@ const _D3MonitoringLimitChart = <DataType extends TelemetryDataSaubDto>({
<foreignObject width={tooltipWidth} height={tooltipHeight} x={selected.x} y={selected.y} pointerEvents={'none'}> <foreignObject width={tooltipWidth} height={tooltipHeight} x={selected.x} y={selected.y} pointerEvents={'none'}>
<div className={'tooltip bottom'} style={tooltipStyle}> <div className={'tooltip bottom'} style={tooltipStyle}>
<span>Ограничивающий параметр</span> <span>Ограничивающий параметр</span>
<span>{regulators[selected.id].label}</span> <span>{selectedRegulator?.label}</span>
<Grid style={{ margin: 0, padding: 0 }}> <Grid style={{ margin: 0, padding: 0 }}>
<GridItem row={1} col={1}>Начало:</GridItem> <GridItem row={1} col={1}>Начало:</GridItem>
<GridItem row={1} col={2}>{formatDate(selected.dateStart)}</GridItem> <GridItem row={1} col={2}>{formatDate(selected.dateStart)}</GridItem>

View File

@ -6,7 +6,7 @@ import { usePartialProps } from '@utils'
import { wrapPlugin } from './base' import { wrapPlugin } from './base'
import '@styles/d3.less' import '@styles/components/d3.less'
export type D3CursorSettings = { export type D3CursorSettings = {
/** Параметры стиля линии */ /** Параметры стиля линии */

View File

@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef } from 'react'
import { Property } from 'csstype' import { Property } from 'csstype'
import * as d3 from 'd3' import * as d3 from 'd3'
import { ChartRegistry } from '@components/d3/types' import { BaseDataType, ChartRegistry } from '@components/d3/types'
import { useD3MouseZone } from '@components/d3/D3MouseZone' import { useD3MouseZone } from '@components/d3/D3MouseZone'
import { usePartialProps } from '@utils' import { usePartialProps } from '@utils'
@ -32,12 +32,12 @@ export type D3LegendSettings = {
const defaultOffset = { x: 10, y: 10 } const defaultOffset = { x: 10, y: 10 }
export type D3LegendProps<DataType> = D3LegendSettings & { export type D3LegendProps<DataType extends BaseDataType> = D3LegendSettings & {
/** Массив графиков */ /** Массив графиков */
charts: ChartRegistry<DataType>[] charts: ChartRegistry<DataType>[]
} }
const _D3Legend = <DataType,>({ const _D3Legend = <DataType extends BaseDataType>({
charts, charts,
width, width,
height, height,

View File

@ -4,15 +4,15 @@ import * as d3 from 'd3'
import { isDev } from '@utils' import { isDev } from '@utils'
import { ChartRegistry } from '@components/d3/types' import { BaseDataType, ChartRegistry } from '@components/d3/types'
import { D3MouseState, useD3MouseZone } from '@components/d3/D3MouseZone' import { D3MouseState, useD3MouseZone } from '@components/d3/D3MouseZone'
import { getTouchedElements, wrapPlugin } from './base' import { getTouchedElements, wrapPlugin } from './base'
import '@styles/d3.less' import '@styles/components/d3.less'
export type D3TooltipPosition = 'bottom' | 'top' | 'left' | 'right' | 'none' export type D3TooltipPosition = 'bottom' | 'top' | 'left' | 'right' | 'none'
export type D3RenderData<DataType> = { export type D3RenderData<DataType extends BaseDataType> = {
/** Параметры графика */ /** Параметры графика */
chart: ChartRegistry<DataType> chart: ChartRegistry<DataType>
/** Данные графика */ /** Данные графика */
@ -21,9 +21,9 @@ export type D3RenderData<DataType> = {
selection?: d3.Selection<any, DataType, any, any> selection?: d3.Selection<any, DataType, any, any>
} }
export type D3RenderFunction<DataType> = (data: D3RenderData<DataType>[], mouseState: D3MouseState) => ReactNode export type D3RenderFunction<DataType extends BaseDataType> = (data: D3RenderData<DataType>[], mouseState: D3MouseState) => ReactNode
export type D3TooltipSettings<DataType> = { export type D3TooltipSettings<DataType extends BaseDataType> = {
/** Функция отрисоки тултипа */ /** Функция отрисоки тултипа */
render?: D3RenderFunction<DataType> render?: D3RenderFunction<DataType>
/** Ширина тултипа */ /** Ширина тултипа */
@ -39,7 +39,7 @@ export type D3TooltipSettings<DataType> = {
limit?: number limit?: number
} }
export const makeDefaultRender = <DataType,>(): D3RenderFunction<DataType> => (data, mouseState) => ( export const makeDefaultRender = <DataType extends BaseDataType>(): D3RenderFunction<DataType> => (data, mouseState) => (
<> <>
{data.length > 0 ? data.map(({ chart, data }) => { {data.length > 0 ? data.map(({ chart, data }) => {
let Icon let Icon
@ -74,11 +74,11 @@ export const makeDefaultRender = <DataType,>(): D3RenderFunction<DataType> => (d
</> </>
) )
export type D3TooltipProps<DataType> = Partial<D3TooltipSettings<DataType>> & { export type D3TooltipProps<DataType extends BaseDataType> = Partial<D3TooltipSettings<DataType>> & {
charts: ChartRegistry<DataType>[], charts: ChartRegistry<DataType>[],
} }
function _D3Tooltip<DataType extends Record<string, unknown>>({ function _D3Tooltip<DataType extends BaseDataType>({
width = 200, width = 200,
height = 120, height = 120,
render = makeDefaultRender<DataType>(), render = makeDefaultRender<DataType>(),

View File

@ -3,7 +3,7 @@ import * as d3 from 'd3'
import { getDistance, TouchType } from '@utils' import { getDistance, TouchType } from '@utils'
import { ChartRegistry } from '../types' import { BaseDataType, ChartRegistry } from '../types'
export type BasePluginSettings = { export type BasePluginSettings = {
enabled?: boolean enabled?: boolean
@ -16,7 +16,7 @@ export const wrapPlugin = <TProps,>(
const wrappedComponent = ({ enabled, ...props }: TProps & BasePluginSettings) => { const wrappedComponent = ({ enabled, ...props }: TProps & BasePluginSettings) => {
if (!(enabled ?? defaultEnabled)) return <></> if (!(enabled ?? defaultEnabled)) return <></>
return <Component {...(props as TProps)} /> return <Component {...(props as (TProps & JSX.IntrinsicAttributes))} /> // IntrinsicAttributes добавлено как необходимое ограничение
} }
return wrappedComponent return wrappedComponent
@ -89,7 +89,7 @@ const makeIsRectTouched = (x: number, y: number, limit: number, type: TouchType
} }
} }
export const getTouchedElements = <DataType,>( export const getTouchedElements = <DataType extends BaseDataType>(
chart: ChartRegistry<DataType>, chart: ChartRegistry<DataType>,
x: number, x: number,
y: number, y: number,

View File

@ -1,8 +1,8 @@
import * as d3 from 'd3' import * as d3 from 'd3'
import { ChartRegistry } from '../types' import { BaseDataType, ChartRegistry } from '../types'
export const appendTransition = <DataType, BaseType extends d3.BaseType, Datum, PElement extends d3.BaseType, PDatum>( export const appendTransition = <DataType extends BaseDataType, BaseType extends d3.BaseType, Datum, PElement extends d3.BaseType, PDatum>(
elms: d3.Selection<BaseType, Datum, PElement, PDatum>, elms: d3.Selection<BaseType, Datum, PElement, PDatum>,
chart: ChartRegistry<DataType> chart: ChartRegistry<DataType>
): d3.Selection<BaseType, Datum, PElement, PDatum> => { ): d3.Selection<BaseType, Datum, PElement, PDatum> => {

View File

@ -1,4 +1,4 @@
import { ChartRegistry, PointChartDataset } from '@components/d3/types' import { BaseDataType, ChartRegistry, PointChartDataset } from '@components/d3/types'
import { appendTransition } from './base' import { appendTransition } from './base'
@ -12,7 +12,7 @@ const defaultConfig: Required<Omit<PointChartDataset, 'type'>> = {
fillOpacity: 1, fillOpacity: 1,
} }
const getPointsRoot = <DataType,>(chart: ChartRegistry<DataType>, embeded?: boolean): d3.Selection<SVGGElement, any, any, any> => { const getPointsRoot = <DataType extends BaseDataType>(chart: ChartRegistry<DataType>, embeded?: boolean): d3.Selection<SVGGElement, any, any, any> => {
const root = chart() const root = chart()
if (!embeded) return root if (!embeded) return root
if (root.select('.points').empty()) if (root.select('.points').empty())

View File

@ -1,12 +1,13 @@
import { getByAccessor } from '@components/d3/functions' import { getByAccessor } from '@components/d3/functions'
import { ChartRegistry } from '@components/d3/types' import { BaseDataType, ChartRegistry } from '@components/d3/types'
import { appendTransition } from './base' import { appendTransition } from './base'
export const renderRectArea = <DataType extends Record<string, any>>( export const renderRectArea = <DataType extends BaseDataType>(
xAxis: (value: d3.NumberValue) => number, xAxis: (value: d3.NumberValue) => number,
yAxis: (value: d3.NumberValue) => number, yAxis: (value: d3.NumberValue) => number,
chart: ChartRegistry<DataType> chart: ChartRegistry<DataType>,
data: DataType[] | undefined,
) => { ) => {
if ( if (
chart.type !== 'rect_area' || chart.type !== 'rect_area' ||
@ -14,25 +15,27 @@ export const renderRectArea = <DataType extends Record<string, any>>(
!chart.maxXAccessor || !chart.maxXAccessor ||
!chart.minYAccessor || !chart.minYAccessor ||
!chart.maxYAccessor || !chart.maxYAccessor ||
!chart.data !data
) return ) return
const data = chart.data
const xMin = getByAccessor(chart.minXAccessor) const xMin = getByAccessor(chart.minXAccessor)
const xMax = getByAccessor(chart.maxXAccessor) const xMax = getByAccessor(chart.maxXAccessor)
const yMin = getByAccessor(chart.minYAccessor) const yMin = getByAccessor(chart.minYAccessor)
const yMax = getByAccessor(chart.maxYAccessor) const yMax = getByAccessor(chart.maxYAccessor)
chart().attr('fill', 'currentColor') chart()
.attr('fill', 'currentColor')
.attr('fill-opacity', '0.15')
.attr('stroke-opacity', '0.3')
const rects = chart().selectAll<SVGRectElement, null>('rect').data(data) const rects = chart().selectAll<SVGRectElement, null>('rect').data(data)
rects.exit().remove() rects.exit().remove()
rects.enter().append('rect') rects.enter().append('rect')
appendTransition(chart().selectAll<SVGRectElement, Record<string, any>>('rect'), chart) appendTransition(chart().selectAll<SVGRectElement, Record<string, any>>('rect'), chart)
.attr('x1', (d) => xAxis(xMin(d))) .attr('x', (d) => xAxis(xMin(d)))
.attr('x2', (d) => xAxis(xMax(d))) .attr('y', (d) => yAxis(yMin(d)))
.attr('y1', (d) => yAxis(yMin(d))) .attr('width', (d) => xAxis(xMax(d)) - xAxis(xMin(d)))
.attr('y2', (d) => yAxis(yMax(d))) .attr('height', (d) => yAxis(yMax(d)) - yAxis(yMin(d)))
} }

View File

@ -3,9 +3,11 @@ import { Property } from 'csstype'
import { D3TooltipSettings } from './plugins' import { D3TooltipSettings } from './plugins'
export type AxisAccessor<DataType extends Record<string, any>> = keyof DataType | ((d: DataType) => any) export type BaseDataType = Record<string, any>
export type ChartAxis<DataType> = { export type AxisAccessor<DataType extends BaseDataType> = keyof DataType | ((d: DataType) => any)
export type ChartAxis<DataType extends BaseDataType> = {
/** Тип шкалы */ /** Тип шкалы */
type: 'linear' | 'time', type: 'linear' | 'time',
/** Ключ записи или метод по которому будет извлекаться значение оси из массива данных */ /** Ключ записи или метод по которому будет извлекаться значение оси из массива данных */
@ -34,7 +36,7 @@ export type PointChartDataset = {
fillOpacity?: number fillOpacity?: number
} }
export type BaseChartDataset<DataType> = { export type BaseChartDataset<DataType extends BaseDataType> = {
/** Уникальный ключ графика */ /** Уникальный ключ графика */
key: string | number key: string | number
/** Параметры вертикальной оси */ /** Параметры вертикальной оси */
@ -101,7 +103,7 @@ export type NeedleChartDataset = {
type: 'needle' type: 'needle'
} }
export type ChartDataset<DataType> = BaseChartDataset<DataType> & ( export type ChartDataset<DataType extends BaseDataType> = BaseChartDataset<DataType> & (
AreaChartDataset | AreaChartDataset |
LineChartDataset | LineChartDataset |
NeedleChartDataset | NeedleChartDataset |
@ -154,7 +156,7 @@ export type ChartTicks<DataType> = {
y?: ChartTick<DataType> y?: ChartTick<DataType>
} }
export type ChartRegistry<DataType> = ChartDataset<DataType> & { export type ChartRegistry<DataType extends BaseDataType> = ChartDataset<DataType> & {
/** Получить D3 выборку, содержащую корневой G-элемент графика */ /** Получить D3 выборку, содержащую корневой G-элемент графика */
(): d3.Selection<SVGGElement, DataType, any, any> (): d3.Selection<SVGGElement, DataType, any, any>
/** Получить значение по вертикальной оси из предоставленой записи */ /** Получить значение по вертикальной оси из предоставленой записи */

View File

@ -3,8 +3,7 @@ import { ArgsProps } from 'antd/lib/notification'
import { Dispatch, ReactNode, SetStateAction } from 'react' import { Dispatch, ReactNode, SetStateAction } from 'react'
import { WellView } from '@components/views' import { WellView } from '@components/views'
import { getUserToken } from '@utils' import { FunctionalValue, getFunctionalValue, getUser, isDev } from '@utils'
import { FunctionalValue, getFunctionalValue, isDev } from '@utils'
import { ApiError, FileInfoDto, WellDto } from '@api' import { ApiError, FileInfoDto, WellDto } from '@api'
export type NotifyType = 'error' | 'warning' | 'info' export type NotifyType = 'error' | 'warning' | 'info'
@ -30,7 +29,7 @@ export const notify = (body?: ReactNode, notifyType: NotifyType = 'info', well?:
const message = ( const message = (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{instance.message}</span> <span>{instance.message}</span>
<WellView well={well} /> <WellView placement={'leftBottom'} well={well} />
</div> </div>
) )
@ -97,7 +96,7 @@ export const invokeWebApiWrapperAsync = async (
export const download = async (url: string, fileName?: string) => { export const download = async (url: string, fileName?: string) => {
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
Authorization: `Bearer ${getUserToken()}` Authorization: `Bearer ${getUser().token}`
}, },
method: 'Get' method: 'Get'
}) })
@ -125,7 +124,7 @@ export const download = async (url: string, fileName?: string) => {
export const upload = async (url: string, formData: FormData) => { export const upload = async (url: string, formData: FormData) => {
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
Authorization: `Bearer ${getUserToken()}` Authorization: `Bearer ${getUser().token}`
}, },
method: 'Post', method: 'Post',
body: formData, body: formData,

View File

@ -1,6 +1,3 @@
export type { PointerIconColors, PointerIconProps } from './PointerIcon' export * from './PointerIcon'
export type { WellIconColors, WellIconProps, WellIconState } from './WellIcon' export * from './WellIcon'
export * from './Loader'
export { PointerIcon } from './PointerIcon'
export { WellIcon } from './WellIcon'
export { Loader } from './Loader'

View File

@ -0,0 +1,35 @@
import { memo, useEffect, useState } from 'react'
import { Outlet } from 'react-router-dom'
import { DepositListContext } from '@asb/context'
import LoaderPortal from '@components/LoaderPortal'
import { invokeWebApiWrapperAsync } from '@components/factory'
import { DepositDto, DepositService } from '@api'
import { arrayOrDefault } from '@utils'
export const DepositsOutlet = memo(() => {
const [deposits, setDeposits] = useState<DepositDto[]>([])
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
invokeWebApiWrapperAsync(
async () => {
const deposits = await DepositService.getDeposits()
setDeposits(arrayOrDefault(deposits))
},
setIsLoading,
`Не удалось загрузить список кустов`,
{ actionName: 'Получить список кустов' }
)
}, [])
return (
<DepositListContext.Provider value={deposits}>
<LoaderPortal show={isLoading}>
<Outlet />
</LoaderPortal>
</DepositListContext.Provider>
)
})
export default DepositsOutlet

View File

@ -0,0 +1,30 @@
import { memo, useEffect, useState } from 'react'
import { Outlet } from 'react-router-dom'
import { UserContext } from '@asb/context'
import { invokeWebApiWrapperAsync } from '@components/factory'
import { getUser, setUser as setStorageUser } from '@utils'
import { AuthService, UserTokenDto } from '@api'
export const UserOutlet = memo(() => {
const [user, setUser] = useState<UserTokenDto>({})
useEffect(() => {
invokeWebApiWrapperAsync(async () => {
let user = getUser()
if (!user.id) {
user = await AuthService.refresh()
setStorageUser(user)
}
setUser(user)
})
}, [])
return (
<UserContext.Provider value={user}>
<Outlet />
</UserContext.Provider>
)
})
export default UserOutlet

View File

@ -0,0 +1,2 @@
export * from './DepositsOutlet'
export * from './UserOutlet'

View File

@ -39,8 +39,8 @@ export const Poprompt = memo<PopromptProps>(({ formProps, buttonProps, footer, c
)} )}
trigger={'click'} trigger={'click'}
{...other} {...other}
visible={visible} open={visible}
onVisibleChange={(visible) => setVisible(visible)} onOpenChange={(visible) => setVisible(visible)}
> >
<Button {...buttonProps}>{text}</Button> <Button {...buttonProps}>{text}</Button>
</Popover> </Popover>

View File

@ -1,12 +1,11 @@
import { Tag, TreeSelect } from 'antd' import { Tag, TreeSelect } from 'antd'
import { memo, useEffect, useState } from 'react' import { memo, useEffect, useState } from 'react'
import { useDepositList } from '@asb/context'
import { invokeWebApiWrapperAsync } from '@components/factory' import { invokeWebApiWrapperAsync } from '@components/factory'
import { hasPermission } from '@utils' import { hasPermission } from '@utils'
import { DepositService } from '@api'
export const getTreeData = async () => { export const getTreeData = async (deposits) => {
const deposits = await DepositService.getDeposits()
const wellsTree = deposits.map((deposit, dIdx) => ({ const wellsTree = deposits.map((deposit, dIdx) => ({
title: deposit.caption, title: deposit.caption,
key: `0-${dIdx}`, key: `0-${dIdx}`,
@ -40,10 +39,12 @@ export const WellSelector = memo(({ value, onChange, treeData, treeLabels, ...ot
const [wellsTree, setWellsTree] = useState([]) const [wellsTree, setWellsTree] = useState([])
const [wellLabels, setWellLabels] = useState([]) const [wellLabels, setWellLabels] = useState([])
const deposits = useDepositList()
useEffect(() => { useEffect(() => {
invokeWebApiWrapperAsync( invokeWebApiWrapperAsync(
async () => { async () => {
const wellsTree = treeData ?? await getTreeData() const wellsTree = treeData ?? await getTreeData(deposits)
const labels = treeLabels ?? getTreeLabels(wellsTree) const labels = treeLabels ?? getTreeLabels(wellsTree)
setWellsTree(wellsTree) setWellsTree(wellsTree)
setWellLabels(labels) setWellLabels(labels)
@ -52,12 +53,13 @@ export const WellSelector = memo(({ value, onChange, treeData, treeLabels, ...ot
'Не удалось загрузить список скважин', 'Не удалось загрузить список скважин',
{ actionName: 'Получение списка скважин' } { actionName: 'Получение списка скважин' }
) )
}, [treeData, treeLabels]) }, [deposits, treeData, treeLabels])
return ( return (
<TreeSelect <TreeSelect
multiple multiple
treeCheckable treeCheckable
maxTagCount={'responsive'}
showCheckedStrategy={TreeSelect.SHOW_CHILD} showCheckedStrategy={TreeSelect.SHOW_CHILD}
treeDefaultExpandAll treeDefaultExpandAll
treeData={wellsTree} treeData={wellsTree}

View File

@ -1,61 +1,62 @@
import { Button, Drawer, Skeleton, Tree, TreeProps, Typography } from 'antd' import { Drawer, Tree, TreeDataNode, TreeProps } from 'antd'
import { DefaultValueType } from 'rc-tree-select/lib/interface' import { useState, useEffect, useCallback, memo, Key, useMemo } from 'react'
import { useState, useEffect, ReactNode, useCallback, memo, Key } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { useDepositList } from '@asb/context'
import { WellIcon, WellIconState } from '@components/icons' import { WellIcon, WellIconState } from '@components/icons'
import { invokeWebApiWrapperAsync } from '@components/factory' import { DepositDto, WellDto } from '@api'
import { DepositService, DepositDto, WellDto } from '@api'
import { isRawDate } from '@utils' import { isRawDate } from '@utils'
import { ReactComponent as DepositIcon } from '@images/DepositIcon.svg' import { ReactComponent as DepositIcon } from '@images/DepositIcon.svg'
import { ReactComponent as ClusterIcon } from '@images/ClusterIcon.svg' import { ReactComponent as ClusterIcon } from '@images/ClusterIcon.svg'
import '@styles/wellTreeSelect.css' import '@styles/components/well_tree_select.css'
/**
* Для поиска в URL текущего раздела по шаблону `/{type}/{id}`
*
* Если найдено совпадение может вернуть 1 или 2 группы соответственно
*/
const URL_REGEX = /^\/([^\/?#]+)(?:\/([^\/?#]+))?/
export const getWellState = (idState?: number): WellIconState => idState === 1 ? 'active' : 'unknown' export const getWellState = (idState?: number): WellIconState => idState === 1 ? 'active' : 'unknown'
export const checkIsWellOnline = (lastTelemetryDate: unknown): boolean => export const checkIsWellOnline = (lastTelemetryDate: unknown): boolean =>
isRawDate(lastTelemetryDate) && (Date.now() - +new Date(lastTelemetryDate) < 600_000) isRawDate(lastTelemetryDate) && (Date.now() - +new Date(lastTelemetryDate) < 600_000)
export type TreeNodeData = { const getKeyByUrl = (url?: string): [Key | null, string | null, number | null] => {
title?: string | null const result = url?.match(URL_REGEX) // pattern "/:type/:id"
key?: string if (!result) return [null, null, null]
value?: DefaultValueType return [result[0], result[1], result[2] && result[2] !== 'null' ? Number(result[2]) : null]
icon?: ReactNode
children?: TreeNodeData[]
} }
const getKeyByUrl = (url?: string): [Key | null, string | null] => { const getLabel = (wellsTree: TreeDataNode[], value?: string): string | undefined => {
const result = url?.match(/^\/([^\/]+)\/([^\/?]+)/) // pattern "/:type/:id" const [url, type, key] = getKeyByUrl(value)
if (!result) return [null, null]
return [result[0], result[1]]
}
const getLabel = (wellsTree: TreeNodeData[], value?: string): string | undefined => {
const [url, type] = getKeyByUrl(value)
if (!url) return if (!url) return
let deposit: TreeNodeData | undefined let deposit: TreeDataNode | undefined
let cluster: TreeNodeData | undefined let cluster: TreeDataNode | undefined
let well: TreeNodeData | undefined let well: TreeDataNode | undefined
switch (type) { switch (type) {
case 'deposit': case 'deposit':
if (key === null) return 'Месторождение не выбрано'
deposit = wellsTree.find((deposit) => deposit.key === url) deposit = wellsTree.find((deposit) => deposit.key === url)
if (deposit) if (deposit)
return `${deposit.title}` return `${deposit.title}`
return 'Ошибка! Месторождение не найдено!' return 'Ошибка! Месторождение не найдено!'
case 'cluster': case 'cluster':
if (key === null) return 'Куст не выбран'
deposit = wellsTree.find((deposit) => ( deposit = wellsTree.find((deposit) => (
cluster = deposit.children?.find((cluster: TreeNodeData) => cluster.key === url) cluster = deposit.children?.find((cluster: TreeDataNode) => cluster.key === url)
)) ))
if (deposit && cluster) if (deposit && cluster)
return `${deposit.title} / ${cluster.title}` return `${deposit.title} / ${cluster.title}`
return 'Ошибка! Куст не найден!' return 'Ошибка! Куст не найден!'
case 'well': case 'well':
if (key === null) return 'Скважина не выбрана'
deposit = wellsTree.find((deposit) => ( deposit = wellsTree.find((deposit) => (
cluster = deposit.children?.find((cluster: TreeNodeData) => ( cluster = deposit.children?.find((cluster: TreeDataNode) => (
well = cluster.children?.find((well: TreeNodeData) => well.key === url) well = cluster.children?.find((well: TreeDataNode) => well.key === url)
)) ))
)) ))
if (deposit && cluster && well) if (deposit && cluster && well)
@ -79,103 +80,109 @@ const sortWellsByActive = (a: WellDto, b: WellDto): number => {
return (a.caption || '')?.localeCompare(b.caption || '') return (a.caption || '')?.localeCompare(b.caption || '')
} }
export const WellTreeSelector = memo(({ show, ...other }: TreeProps<TreeNodeData> & { show?: boolean }) => { export type WellTreeSelectorProps = TreeProps<TreeDataNode> & {
const [wellsTree, setWellsTree] = useState<TreeNodeData[]>([]) show?: boolean
const [showLoader, setShowLoader] = useState<boolean>(false) expand?: boolean | Key[]
const [visible, setVisible] = useState<boolean>(false) current?: Key
onClose?: () => void
onChange?: (value: string | undefined) => void
open?: boolean
}
const getExpandKeys = (treeData: TreeDataNode[], depositKeys?: Key[] | boolean): Key[] => {
const out: Key[] = []
treeData.forEach((deposit) => {
if (Array.isArray(depositKeys) && !depositKeys.includes(deposit.key)) return
if (deposit.key) out.push(deposit.key)
deposit.children?.forEach((cluster) => {
if (cluster.key) out.push(cluster.key)
})
})
return out
}
const makeWellsTreeData = (deposits: DepositDto[]): TreeDataNode[] => deposits.map(deposit =>({
title: deposit.caption,
key: `/deposit/${deposit.id}`,
value: `/deposit/${deposit.id}`,
icon: <DepositIcon width={24} height={24}/>,
children: deposit.clusters?.map(cluster => {
const wells = cluster.wells ? cluster.wells.slice() : []
wells.sort(sortWellsByActive)
return {
title: cluster.caption,
key: `/cluster/${cluster.id}`,
value: `/cluster/${cluster.id}`,
icon: <ClusterIcon width={24} height={24}/>,
children: wells.map(well => ({
title: well.caption,
key: `/well/${well.id}`,
value: `/well/${well.id}`,
icon: <WellIcon
width={24}
height={24}
state={getWellState(well.idState)}
online={checkIsWellOnline(well.lastTelemetryDate)}
/>
})),
}
}),
}))
export const WellTreeSelector = memo<WellTreeSelectorProps>(({ expand, current, onChange, onClose, open, ...other }) => {
const [expanded, setExpanded] = useState<Key[]>([]) const [expanded, setExpanded] = useState<Key[]>([])
const [selected, setSelected] = useState<Key[]>([]) const [selected, setSelected] = useState<Key[]>([])
const [value, setValue] = useState<string>()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const deposits = useDepositList()
useEffect(() => { const wellsTree = useMemo(() => makeWellsTreeData(deposits), [deposits])
setVisible((prev) => show ?? prev)
setExpanded((prev) => {
if (typeof show === 'undefined') return prev
if (!show) return []
const out: Key[] = []
wellsTree.forEach((deposit) => {
if (deposit.key) out.push(deposit.key)
deposit.children?.forEach((cluster) => {
if (cluster.key) out.push(cluster.key)
})
})
return out
})
}, [wellsTree, show])
useEffect(() => { const onValueChange = useCallback((value?: string): void => {
invokeWebApiWrapperAsync(
async () => {
const deposits: Array<DepositDto> = await DepositService.getDeposits()
const wellsTree: TreeNodeData[] = deposits.map(deposit =>({
title: deposit.caption,
key: `/deposit/${deposit.id}`,
value: `/deposit/${deposit.id}`,
icon: <DepositIcon width={24} height={24}/>,
children: deposit.clusters?.map(cluster => {
const wells = cluster.wells ? cluster.wells.slice() : []
wells.sort(sortWellsByActive)
return {
title: cluster.caption,
key: `/cluster/${cluster.id}`,
value: `/cluster/${cluster.id}`,
icon: <ClusterIcon width={24} height={24}/>,
children: wells.map(well => ({
title: well.caption,
key: `/well/${well.id}`,
value: `/well/${well.id}`,
icon: <WellIcon
width={24}
height={24}
state={getWellState(well.idState)}
online={checkIsWellOnline(well.lastTelemetryDate)}
/>
})),
}
}),
}))
setWellsTree(wellsTree)
},
setShowLoader,
`Не удалось загрузить список скважин`,
{ actionName: 'Получить список скважин' }
)
}, [])
const onChange = useCallback((value?: string): void => {
const key = getKeyByUrl(value)[0] const key = getKeyByUrl(value)[0]
setSelected(key ? [key] : []) setSelected(key ? [key] : [])
setValue(getLabel(wellsTree, value)) onChange?.(getLabel(wellsTree, value))
}, [wellsTree]) }, [wellsTree])
const onSelect = useCallback((value: Key[]): void => { const onSelect = useCallback((value: Key[]): void => {
navigate(String(value), { state: { from: location.pathname }}) const newRoot = URL_REGEX.exec(String(value))
const oldRoot = URL_REGEX.exec(location.pathname)
if (!newRoot || !oldRoot) return
let newPath = newRoot[0]
if (oldRoot[1] === newRoot[1]) {
/// Если типы страницы одинаковые (deposit, cluster, well), добавляем остаток старого пути
const url = location.pathname.substring(oldRoot[0].length)
newPath = newPath + url
}
navigate(newPath, { state: { from: location.pathname }})
}, [navigate, location]) }, [navigate, location])
useEffect(() => onChange(location.pathname), [onChange, location]) useEffect(() => {
if (current) setSelected([current])
}, [current])
useEffect(() => {
setExpanded((prev) => expand ? getExpandKeys(wellsTree, expand) : prev)
}, [wellsTree, expand])
useEffect(() => onValueChange(location.pathname), [onValueChange, location.pathname])
return ( return (
<> <Drawer open={open} mask={false} onClose={onClose} title={'Список скважин'}>
<Button loading={showLoader} onClick={() => setVisible(true)}>{value ?? 'Выберите месторождение'}</Button> <Tree
<Drawer visible={visible} mask={false} onClose={() => setVisible(false)}> {...other}
<Typography.Title level={3}>Список скважин</Typography.Title> showIcon
<Skeleton active loading={showLoader}> selectedKeys={selected}
<Tree treeData={wellsTree}
{...other} onSelect={onSelect}
showIcon onExpand={setExpanded}
selectedKeys={selected} expandedKeys={expanded}
treeData={wellsTree} />
onSelect={onSelect} </Drawer>
onExpand={setExpanded}
expandedKeys={expanded}
/>
</Skeleton>
</Drawer>
</>
) )
}) })

View File

@ -9,6 +9,7 @@ export type CompanyViewProps = {
company?: CompanyDto company?: CompanyDto
} }
/** Компонент для отображения информации о компании */
export const CompanyView = memo<CompanyViewProps>(({ company }) => company ? ( export const CompanyView = memo<CompanyViewProps>(({ company }) => company ? (
<Tooltip title={ <Tooltip title={
<Grid style={{ columnGap: '8px' }}> <Grid style={{ columnGap: '8px' }}>

View File

@ -8,6 +8,7 @@ export type PermissionViewProps = {
info?: PermissionDto info?: PermissionDto
} }
/** Компонент для отображения информации о разрешении */
export const PermissionView = memo<PermissionViewProps>(({ info }) => info ? ( export const PermissionView = memo<PermissionViewProps>(({ info }) => info ? (
<Tooltip overlayInnerStyle={{ width: '400px' }} title={ <Tooltip overlayInnerStyle={{ width: '400px' }} title={
<Grid> <Grid>

View File

@ -9,6 +9,7 @@ export type RoleViewProps = {
role?: UserRoleDto role?: UserRoleDto
} }
/** Компонент для отображения информации о роли */
export const RoleView = memo<RoleViewProps>(({ role }) => { export const RoleView = memo<RoleViewProps>(({ role }) => {
if (!role) return ( <Tooltip title={'нет данных'}>-</Tooltip> ) if (!role) return ( <Tooltip title={'нет данных'}>-</Tooltip> )

View File

@ -1,8 +1,9 @@
import { Fragment, memo } from 'react' import { Fragment, memo } from 'react'
import { Tooltip } from 'antd' import { Tooltip } from 'antd'
import { TelemetryDto, TelemetryInfoDto } from '@api'
import { Grid, GridItem } from '@components/Grid' import { Grid, GridItem } from '@components/Grid'
import { formatDate } from '@utils'
import { TelemetryDto, TelemetryInfoDto } from '@api'
export const lables: Record<string, string> = { export const lables: Record<string, string> = {
timeZoneId: 'Временная зона', timeZoneId: 'Временная зона',
@ -18,6 +19,12 @@ export const lables: Record<string, string> = {
spinPlcVersion: 'Версия Спин Мастер', spinPlcVersion: 'Версия Спин Мастер',
} }
/**
* Строит название для телеметрии
*
* @param telemetry Объект телеметрии
* @returns Название
*/
export const getTelemetryLabel = (telemetry?: TelemetryDto) => export const getTelemetryLabel = (telemetry?: TelemetryDto) =>
`${telemetry?.id ?? '-'} / ${telemetry?.info?.deposit ?? '-'} / ${telemetry?.info?.cluster ?? '-'} / ${telemetry?.info?.well ?? '-'}` `${telemetry?.id ?? '-'} / ${telemetry?.info?.deposit ?? '-'} / ${telemetry?.info?.cluster ?? '-'} / ${telemetry?.info?.well ?? '-'}`
@ -25,17 +32,23 @@ export type TelemetryViewProps = {
telemetry?: TelemetryDto telemetry?: TelemetryDto
} }
/** Компонент для отображения информации о телеметрии */
export const TelemetryView = memo<TelemetryViewProps>(({ telemetry }) => telemetry?.info ? ( export const TelemetryView = memo<TelemetryViewProps>(({ telemetry }) => telemetry?.info ? (
<Tooltip <Tooltip
overlayInnerStyle={{ width: '400px' }} overlayInnerStyle={{ width: '400px' }}
title={ title={
<Grid> <Grid>
{(Object.keys(telemetry.info) as Array<keyof TelemetryInfoDto>).map((key, i) => ( {(Object.keys(telemetry.info) as Array<keyof TelemetryInfoDto>).map((key, i) => {
<Fragment key={i}> let value = telemetry.info?.[key]
<GridItem row={i+1} col={1}>{lables[key] ?? key}:</GridItem> value = key === 'drillingStartDate' ? formatDate(value) : value
<GridItem row={i+1} col={2}>{telemetry.info?.[key]}</GridItem>
</Fragment> return (
))} <Fragment key={i}>
<GridItem row={i+1} col={1}>{lables[key] ?? key}:</GridItem>
<GridItem row={i+1} col={2}>{value}</GridItem>
</Fragment>
)
})}
</Grid> </Grid>
} }
> >

View File

@ -1,4 +1,4 @@
import { memo } from 'react' import { HTMLProps, memo } from 'react'
import { Tooltip } from 'antd' import { Tooltip } from 'antd'
import { UserOutlined } from '@ant-design/icons' import { UserOutlined } from '@ant-design/icons'
@ -6,33 +6,59 @@ import { UserDto } from '@api'
import { Grid, GridItem } from '@components/Grid' import { Grid, GridItem } from '@components/Grid'
import { CompanyView } from './CompanyView' import { CompanyView } from './CompanyView'
export type UserViewProps = { export type UserViewProps = HTMLProps<HTMLSpanElement> & {
user?: UserDto user?: UserDto
} }
export const UserView = memo<UserViewProps>(({ user }) => user ? ( /** Компонент для отображения информации о пользователе */
<Tooltip title={( export const UserView = memo<UserViewProps>(({ user, ...other }) =>
<Grid style={{ columnGap: '8px' }}> user ? (
<GridItem row={1} col={1}>Фамилия:</GridItem> <Tooltip
<GridItem row={1} col={2}>{user?.surname}</GridItem> title={
<Grid style={{ columnGap: '8px' }}>
<GridItem row={1} col={1}>
Фамилия:
</GridItem>
<GridItem row={1} col={2}>
{user?.surname}
</GridItem>
<GridItem row={2} col={1}>Имя:</GridItem> <GridItem row={2} col={1}>
<GridItem row={2} col={2}>{user?.name}</GridItem> Имя:
</GridItem>
<GridItem row={2} col={2}>
{user?.name}
</GridItem>
<GridItem row={3} col={1}>Отчество:</GridItem> <GridItem row={3} col={1}>
<GridItem row={3} col={2}>{user?.patronymic}</GridItem> Отчество:
</GridItem>
<GridItem row={3} col={2}>
{user?.patronymic}
</GridItem>
<GridItem row={4} col={1}>Компания:</GridItem> <GridItem row={4} col={1}>
<GridItem row={4} col={2}> Компания:
<CompanyView company={user?.company}/> </GridItem>
</GridItem> <GridItem row={4} col={2}>
</Grid> <CompanyView company={user?.company} />
)}> </GridItem>
<UserOutlined style={{ marginRight: 8 }}/> </Grid>
{user?.login} }
</Tooltip> >
) : ( <span {...other}>
<Tooltip title='нет пользователя'>-</Tooltip> <UserOutlined style={{ marginRight: 8 }} />
)) {user?.login}
</span>
</Tooltip>
) : (
<Tooltip title={'нет пользователя'}>
<span {...other}>
<UserOutlined style={{ marginRight: 8 }} />
---
</span>
</Tooltip>
)
)
export default UserView export default UserView

View File

@ -1,5 +1,5 @@
import { memo } from 'react' import { DetailedHTMLProps, HTMLAttributes, memo } from 'react'
import { Tooltip } from 'antd' import { Tooltip, TooltipProps } from 'antd'
import { Grid, GridItem } from '@components/Grid' import { Grid, GridItem } from '@components/Grid'
import { WellIcon, WellIconState } from '@components/icons' import { WellIcon, WellIconState } from '@components/icons'
@ -13,12 +13,22 @@ const wellState: Record<number, { enum: WellIconState, label: string }> = {
2: { enum: 'inactive', label: 'Завершена' }, 2: { enum: 'inactive', label: 'Завершена' },
} }
export type WellViewProps = { export type WellViewProps = TooltipProps & {
well?: WellDto well?: WellDto
iconProps?: DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>
labelProps?: DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>
} }
export const WellView = memo<WellViewProps>(({ well }) => well ? ( /**
<Tooltip title={( * Получить название скважины
* @param well Объект с данными скважины
* @returns Название скважины
*/
export const getWellTitle = (well: WellDto) => `${well.deposit || '-'} / ${well.cluster || '-'} / ${well.caption || '-'}`
/** Компонент для отображения информации о скважине */
export const WellView = memo<WellViewProps>(({ well, iconProps, labelProps, ...other }) => well ? (
<Tooltip {...other} title={(
<Grid style={{ columnGap: '8px' }}> <Grid style={{ columnGap: '8px' }}>
<GridItem row={1} col={1}>Название:</GridItem> <GridItem row={1} col={1}>Название:</GridItem>
<GridItem row={1} col={2}>{well.caption ?? '---'}</GridItem> <GridItem row={1} col={2}>{well.caption ?? '---'}</GridItem>
@ -47,10 +57,12 @@ export const WellView = memo<WellViewProps>(({ well }) => well ? (
<GridItem row={8} col={2}>{well.id ?? '---'}</GridItem> <GridItem row={8} col={2}>{well.id ?? '---'}</GridItem>
</Grid> </Grid>
)}> )}>
<span role={'img'} style={{ marginRight: 8, lineHeight: 0, verticalAlign: '-0.25em' }}> <span role={'img'} style={{ marginRight: 8, lineHeight: 0, verticalAlign: '-0.25em' }} {...iconProps}>
<WellIcon state={wellState[well.idState || 0].enum} width={'1em'} height={'1em'} /> <WellIcon state={wellState[well.idState || 0].enum} width={'1em'} height={'1em'} />
</span> </span>
{well.caption} <span {...labelProps}>
{getWellTitle(well)}
</span>
</Tooltip> </Tooltip>
) : ( ) : (
<Tooltip title={'нет скважины'}>-</Tooltip> <Tooltip title={'нет скважины'}>-</Tooltip>

View File

@ -21,6 +21,7 @@ export type WirelineViewProps = TooltipProps & {
buttonProps?: ButtonProps buttonProps?: ButtonProps
} }
/** Компонент для отображения информации о талевом канате */
export const WirelineView = memo<WirelineViewProps>(({ wireline, buttonProps, ...other }) => ( export const WirelineView = memo<WirelineViewProps>(({ wireline, buttonProps, ...other }) => (
<Tooltip <Tooltip
{...other} {...other}

View File

@ -1,14 +1,7 @@
export type { PermissionViewProps } from './PermissionView' export * from './PermissionView'
export type { TelemetryViewProps } from './TelemetryView' export * from './TelemetryView'
export type { CompanyViewProps } from './CompanyView' export * from './CompanyView'
export type { RoleViewProps } from './RoleView' export * from './RoleView'
export type { UserViewProps } from './UserView' export * from './UserView'
export type { WirelineViewProps } from './WirelineView' export * from './WirelineView'
export { PermissionView } from './PermissionView'
export { TelemetryView, getTelemetryLabel } from './TelemetryView'
export { CompanyView } from './CompanyView'
export { RoleView } from './RoleView'
export { UserView } from './UserView'
export { WirelineView } from './WirelineView'
export * from './WellView' export * from './WellView'

View File

@ -20,7 +20,7 @@ export const WidgetSettingsWindow = memo<WidgetSettingsWindowProps>(({ settings,
return ( return (
<Modal <Modal
{...other} {...other}
visible={!!settings} open={!!settings}
title={( title={(
<> <>
Настройка виджета {settings?.label ? `"${settings?.label}"` : ''} Настройка виджета {settings?.label ? `"${settings?.label}"` : ''}

View File

@ -1,5 +1,2 @@
export { WidgetSettingsWindow } from './WidgetSettingsWindow' export * from './WidgetSettingsWindow'
export { BaseWidget } from './BaseWidget' export * from './BaseWidget'
export type { WidgetSettingsWindowProps } from './WidgetSettingsWindow'
export type { WidgetSettings, BaseWidgetProps } from './BaseWidget'

View File

@ -1,22 +0,0 @@
import { createContext, useContext } from 'react'
import { WellDto } from '@api'
/** Контекст текущей скважины */
export const WellContext = createContext<[WellDto, (well: WellDto) => void]>([{}, () => {}])
/** Контекст текущего корневого пути */
export const RootPathContext = createContext<string>('')
/**
* Получение текущей скважины
*
* @returns Текущая скважина, либо `null`
*/
export const useWell = () => useContext(WellContext)
/**
* Получает текущий корневой путь
*
* @returns Текущий корневой путь
*/
export const useRootPath = () => useContext(RootPathContext)

23
src/context/deposit.ts Normal file
View File

@ -0,0 +1,23 @@
import { createContext, useContext } from 'react'
import { DepositDto } from '@api'
/** Контекст текущего месторождения */
export const DepositContext = createContext<DepositDto | null>(null)
/**
* Получить текущее месторождение
*
* @returns Текущее месторождение, либо `null`
*/
export const useDeposit = () => useContext(DepositContext)
/** Контекст со списком месторождений */
export const DepositListContext = createContext<DepositDto[]>([])
/**
* Получить список скважин
*
* @returns Список скважин
*/
export const useDepositList = () => useContext(DepositListContext)

5
src/context/index.ts Normal file
View File

@ -0,0 +1,5 @@
export * from './deposit'
export * from './layout_props'
export * from './root_path'
export * from './user'
export * from './well'

View File

@ -0,0 +1,31 @@
import { createContext, useContext, useEffect } from 'react'
import { LayoutPortalProps } from '@components/LayoutPortal'
/** Контекст метода редактирования параметров заголовка и меню */
export const LayoutPropsContext = createContext<(props: LayoutPortalProps) => void>(() => {})
/**
* Получить метод задания параметров заголовка и меню
*
* @returns Получить метод задания параметров заголовка и меню
*/
export const useLayoutProps = (props?: LayoutPortalProps) => {
const setLayoutProps = useContext(LayoutPropsContext)
useEffect(() => {
if (props) setLayoutProps(props)
}, [setLayoutProps, props])
return setLayoutProps
}
/** Контекст для блока справа от крошек на страницах скважин и админки */
export const TopRightBlockContext = createContext<(block: JSX.Element) => void>(() => {})
/**
* Получить метод задания элементов справа от крошек
*
* @returns Метод задания элементов справа от крошек
*/
export const useTopRightBlock = () => useContext(TopRightBlockContext)

11
src/context/root_path.ts Normal file
View File

@ -0,0 +1,11 @@
import { createContext, useContext } from 'react'
/** Контекст текущего корневого пути */
export const RootPathContext = createContext<string>('/')
/**
* Получить текущий корневой путь
*
* @returns Текущий корневой путь
*/
export const useRootPath = () => useContext(RootPathContext)

13
src/context/user.ts Normal file
View File

@ -0,0 +1,13 @@
import { createContext, useContext } from 'react'
import { UserTokenDto } from '@api'
/** Контекст текущего пользователя */
export const UserContext = createContext<UserTokenDto>({})
/**
* Получить текущего пользователя
*
* @returns Текущий пользователь, либо `null`
*/
export const useUser = () => useContext(UserContext)

13
src/context/well.ts Normal file
View File

@ -0,0 +1,13 @@
import { createContext, useContext } from 'react'
import { WellDto } from '@api'
/** Контекст текущей скважины */
export const WellContext = createContext<[WellDto, (well: WellDto) => void]>([{}, () => {}])
/**
* Получить текущую скважину
*
* @returns Текущая скважина, либо пустой объект
*/
export const useWell = () => useContext(WellContext)

40
src/images/AsbLogo.svg Normal file
View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 1800 8000 4000" version="1.1" alt="АСБ" fill="#2d2242">
<defs>
<clipPath id="logo-clip-path-id0">
<path d="m 5189,716 c 587,0 1063,476 1063,1063 0,587 -476,1063 -1063,1063 -588,0 -1064,-476 -1064,-1063 0,-587 476,-1063 1064,-1063 z" />
</clipPath>
</defs>
<g transform="translate(-982.80503,2106.4728)">
<g fill="#e31e24">
<path d="M 1756,3564 H 1018 L 3236,2 3848,3 4452,0 4400,184 C 4637,66 4905,0 5189,0 5751,0 6253,261 6579,669 l -233,810 C 6213,964 5745,584 5189,584 c -528,0 -975,341 -1134,815 l -30,108 c -20,87 -31,178 -31,272 0,660 535,1195 1195,1195 318,0 607,-125 821,-327 l -220,764 c -185,88 -388,147 -601,147 -636,0 -1194,-334 -1508,-836 l -239,842 h -702 l 187,-595 H 2146 Z M 3082,2443 3703,446 2463,2444 Z" />
<path d="m 7725,3574 c -534.9685,-1.0406 -1176.3914,-0.3681 -1863.0925,-0.3084 L 6882,2 l 1790,1 -136,559 -1176,9 -121,462 h 836 c 570,93 953,697 950,1254 -3,656 -585,1291 -1300,1287 z m -995,-606 c 333,0 665,0 998,2 381,2 691,-335 693,-686 1,-291 -206,-632 -510,-673 h -824 z"/>
</g>
<path d="m 5347,1437 h -242 v -122 h 242 z" />
<path d="m 5455,1555 h -463 v -86 h 463 z" />
<path d="m 5597,2523 h -737 l 167,-936 h 392 z" />
<path d="m 5246,2523 h -46 v -788 h 46 z" />
<g fill="#fefefe">
<path d="m 5166,1737 -105,93 28,-154 z" />
<path d="m 5288,1737 105,93 -28,-154 z" />
<path d="m 5224,1696 61,-42 h -113 z" />
<path d="m 5143,2007 -124,55 20,-110 z" />
<path d="m 5310,2007 125,55 -20,-110 z" />
<path d="m 5091,1894 138,68 136,-68 -136,-111 z" />
<path d="m 5052,2132 180,119 180,-121 -183,-87 z" />
<path d="m 5163,2297 -214,148 47,-261 z" />
<path d="m 5292,2297 213,148 -47,-261 z" />
<path d="m 5226,2337 271,186 h -539 z" />
</g>
<g clip-path="url(#logo-clip-path-id0)">
<g fill="9d9e9e">
<path d="m 5136,177 c -688,66 -1152,378 -1415,911 l 1475,-196 783,591 z" />
<path d="M 6684,1229 C 6401,599 5957,260 5367,182 l 659,1333 -308,931 z" />
</g>
<path d="m 6189,3044 c 509,-466 692,-994 581,-1579 l -1059,1044 -981,-1 z" />
<path d="m 4267,3105 c 598,345 1157,360 1681,78 L 4633,2488 4337,1552 Z" />
<path d="m 3626,1346 c -142,676 17,1212 447,1622 l 253,-1466 798,-571 z" />
</g>
<path fill="none" d="m 5189,716 c 587,0 1063,476 1063,1063 0,587 -476,1063 -1063,1063 -588,0 -1064,-476 -1064,-1063 0,-587 476,-1063 1064,-1063 z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

33
src/images/Logo.svg Normal file
View File

@ -0,0 +1,33 @@
<svg version="1.1" viewBox="0 0 896 282" fill="#f3f6e8" className="logo" overflow="visible">
<g className="logo-icon">
<path fill="#9e1937" d="m126 32.2h-92.5c-2.58 0-4.67-2.09-4.67-4.67s2.09-4.67 4.67-4.67h92.5c2.58 0 4.67 2.09 4.67 4.67s-2.09 4.67-4.67 4.67" />
<path d="m30.5 274h98.3l-36.1-194h-26.2zm104 9.33h-110c-1.39 0-2.7-0.617-3.59-1.68-0.887-1.07-1.25-2.47-0.999-3.83l37.8-203c0.41-2.21 2.34-3.82 4.59-3.82h34c2.25 0 4.18 1.6 4.59 3.82l37.8 203c0.253 1.36-0.112 2.77-0.999 3.83-0.887 1.07-2.2 1.68-3.59 1.68" />
<path d="m113 10.3h-66.9c-2.58 0-4.67-2.09-4.67-4.67 0-2.58 2.09-4.67 4.67-4.67h66.9c2.58 0 4.67 2.09 4.67 4.67 0 2.58-2.09 4.67-4.67 4.67" />
<path d="m155 262c-2.17 0-4.12-1.53-4.57-3.74l-41.1-203h-58.8l-39.9 197h85.9l-44.2-33.2c-1.61-1.21-2.26-3.3-1.62-5.21 0.635-1.91 2.42-3.19 4.43-3.19h37.1l-34.6-28.7c-1.51-1.26-2.08-3.33-1.41-5.17 0.668-1.85 2.42-3.08 4.39-3.08h27.8l-25.3-25.5c-1.33-1.34-1.72-3.34-1-5.08 0.725-1.74 2.42-2.87 4.31-2.87h18.3l-16.8-19c-1.22-1.37-1.51-3.33-0.759-5.01 0.754-1.67 2.42-2.75 4.25-2.75h17.6c2.58 0 4.67 2.09 4.67 4.67s-2.09 4.67-4.67 4.67h-7.23l16.8 19c1.22 1.38 1.51 3.34 0.759 5.01-0.754 1.67-2.42 2.75-4.25 2.75h-17.4l25.3 25.5c1.33 1.34 1.72 3.34 1 5.08-0.724 1.74-2.42 2.87-4.31 2.87h-26.1l34.6 28.7c1.51 1.26 2.08 3.33 1.41 5.17-0.668 1.85-2.42 3.08-4.39 3.08h-36.1l44.2 33.2c1.61 1.21 2.26 3.3 1.62 5.21-0.635 1.91-2.42 3.19-4.43 3.19h-106c-1.4 0-2.73-0.629-3.61-1.71-0.886-1.09-1.24-2.51-0.961-3.88l41.8-206c0.441-2.18 2.35-3.74 4.57-3.74h66.5c2.22 0 4.13 1.56 4.57 3.74l41.8 206c0.512 2.53-1.12 4.99-3.65 5.5-0.312 0.0625-0.624 0.0948-0.932 0.0948" />
</g>
<g className="logo-label">
<path fill="#9e1937" d="m316 140c2.76-2.67 5.01-1.71 5.01 2.13v30.3c0 3.84-3.14 6.98-6.98 6.98h-2.38c-3.84 0-6.98-3.14-6.98-6.98v-14.5c0-3.84 2.26-9.16 5.01-11.8l6.31-6.09" />
<path d="m647 159c0 3.84-3.14 6.98-6.97 6.98h-3.84c-3.84 0-6.97-3.14-6.97-6.98v-118c0-3.84 3.14-6.98 6.97-6.98h3.84c3.84 0 6.97 3.14 6.97 6.98v118" />
<path d="m707 144c0 3.84 3.14 6.97 6.98 6.97h52.7c3.84 0 6.98 3.14 6.98 6.97v1.84c0 3.84-3.14 6.98-6.98 6.98h-70.4c-3.84 0-6.97-3.14-6.97-6.98v-118c0-3.84 3.14-6.98 6.97-6.98h3.84c3.84 0 6.97 3.14 6.97 6.98v102" />
<path d="m827 144c0 3.84 3.14 6.97 6.97 6.97h52.7c3.84 0 6.98 3.14 6.98 6.97v1.84c0 3.84-3.14 6.98-6.98 6.98h-70.4c-3.84 0-6.98-3.14-6.98-6.98v-118c0-3.84 3.14-6.98 6.98-6.98h3.84c3.84 0 6.98 3.14 6.98 6.98v102" />
<path d="m279 101c0 33.2-15.2 49.9-39.4 49.9h-19.3c-3.84 0-6.97-3.14-6.97-6.97v-87.3c0-3.84 3.14-6.98 6.97-6.98h20.5c23 0 38.1 18.1 38.1 51.4zm18.3 1.09c0-29.6-12.9-67.7-56.1-67.7h-38.7c-3.84 0-6.98 3.14-6.98 6.98v118c0 3.84 3.14 6.98 6.98 6.98h39.4c34.3 0 55.4-26.1 55.4-64.1" />
<path d="m432 101c0 33.2-15.2 49.9-39.4 49.9h-19.3c-3.84 0-6.97-3.14-6.97-6.97v-87.3c0-3.84 3.14-6.98 6.97-6.98h20.5c23 0 38.1 18.1 38.1 51.4zm18.3 1.09c0-29.6-12.9-67.7-56.1-67.7h-38.7c-3.84 0-6.98 3.14-6.98 6.98v118c0 3.84 3.14 6.98 6.98 6.98h39.4c34.3 0 55.4-26.1 55.4-64.1" />
<path d="m539 94.6h-33c-3.84 0-6.98-3.14-6.98-6.98v-30.9c0-3.84 3.14-6.98 6.98-6.98h36.3c8.89 0 23.6 1.63 23.6 22 0 19.4-13.8 22.9-26.9 22.9zm26.9 6.9c8.35-4.9 18.3-12.2 18.3-31.6 0-27.8-21.8-35.4-43.4-35.4h-52.6c-3.84 0-6.98 3.14-6.98 6.98v118c0 3.84 3.14 6.97 6.98 6.97h3.84c3.84 0 6.97-3.14 6.97-6.97v-42.5c0-3.84 3.14-6.98 6.98-6.98h34.9c21.4 0 23.6 12.5 23.6 23.4 0.308 6.29 0 16.5 0 23.9s3.76 9.1 8.94 9.1h8.85v-40.1c0-18.5-10.3-20.7-16.3-24.7" />
<path d="m220 256c-0.964 0-1.77-0.809-1.77-1.77v-2.85h-17.7c-0.962 0-1.77-0.806-1.77-1.77v-23.4c0-0.965 0.807-1.77 1.77-1.77h2.62c0.962 0 1.77 0.809 1.77 1.77v19.4h9.25v-19.4c0-0.965 0.807-1.77 1.77-1.77h2.62c0.964 0 1.77 0.809 1.77 1.77v19.4h1.89c0.962 0 1.77 0.807 1.77 1.77v6.86c0 0.962-0.809 1.77-1.77 1.77h-2.23" />
<path d="m267 251c-0.964 0-1.77-0.806-1.77-1.77v-15l-11.2 15.6c-0.463 0.694-1.2 1.12-2.01 1.12h-2.39c-0.964 0-1.77-0.806-1.77-1.77v-23.4c0-0.965 0.807-1.77 1.77-1.77h2.62c0.962 0 1.77 0.809 1.77 1.77v15l11.2-15.6c0.463-0.691 1.19-1.12 2-1.12h2.39c0.964 0 1.77 0.809 1.77 1.77v23.4c0 0.965-0.809 1.77-1.77 1.77h-2.62" />
<path d="m319 238c0-2.54-1.39-4.78-5.51-4.89v9.78c4.04-0.076 5.51-1.73 5.51-4.89zm-17.1 0c0 3.12 1.66 4.81 5.43 4.89v-9.78c-3.93 0.115-5.43 2.27-5.43 4.89zm11.6 12.4c0 0.965-0.807 1.77-1.77 1.77h-2.62c-0.962 0-1.77-0.809-1.77-1.77v-1.85c-7.2-0.0758-12-3.82-12-10.6 0-6.66 4.89-10.4 12-10.6v-1.08c0-0.965 0.812-1.77 1.77-1.77h2.62c0.964 0 1.77 0.809 1.77 1.77v1.08c7.01 0.158 12.1 3.97 12.1 10.6 0 6.7-4.89 10.5-12.1 10.6v1.85" />
<path d="m356 236c0.886 0.115 1.92 0.194 2.81 0.194 1.62 0 3.62-0.694 3.62-3.31 0-2.39-1.54-3.12-3.74-3.12-0.807 0-1.42 0.0393-2.69 0.0788zm13-3.08c0 4.81-3.74 9.05-9.98 9.05-0.576 0-2.04 0-3-0.115v7.4c0 0.965-0.809 1.77-1.77 1.77h-2.62c-0.964 0-1.77-0.806-1.77-1.77v-23.4c0-1 0.807-1.81 1.77-1.81 2.04-0.0393 4.97-0.0758 6.47-0.0758 8.2 0 10.9 4.28 10.9 8.94" />
<path d="m405 246c5.12 0 7.78-3.62 7.78-8.17 0-4.93-3.43-8.16-7.78-8.16-4.47 0-7.78 3.24-7.78 8.16 0 4.62 3.47 8.17 7.78 8.17zm0-22.1c8.2 0 14.3 5.35 14.3 13.9 0 8.17-6.13 13.9-14.3 13.9-8.21 0-14.3-5.35-14.3-13.9 0-7.82 5.74-13.9 14.3-13.9" />
<path d="m450 246c0.423 0.115 0.923 0.232 2.08 0.232 2.39 0 3.58-1.04 3.58-2.85 0-1.7-1.27-2.43-3.27-2.43h-2.39zm2.04-10.4c1.58 0 2.85-0.654 2.85-2.62 0-1.62-1.46-2.43-2.96-2.43-0.77 0-1.23 0.0768-1.93 0.156v4.89zm5.93 1.93c1.96 0.771 3.85 2.73 3.85 6.2 0 5.66-4.39 8.32-10.2 8.32-1.85 0-4.2-0.0364-5.97-0.113-0.925-0.0393-1.77-0.923-1.77-1.85v-23.3c0-0.964 0.809-1.81 1.77-1.85 1.81-0.0759 4.32-0.155 6.39-0.155 6.43 0 9.05 2.97 9.05 6.7 0 2.81-1.08 4.7-3.08 6.05" />
<path d="m500 246c5.12 0 7.78-3.62 7.78-8.17 0-4.93-3.43-8.16-7.78-8.16-4.47 0-7.78 3.24-7.78 8.16 0 4.62 3.47 8.17 7.78 8.17zm0-22.1c8.2 0 14.3 5.35 14.3 13.9 0 8.17-6.13 13.9-14.3 13.9-8.21 0-14.3-5.35-14.3-13.9 0-7.82 5.74-13.9 14.3-13.9" />
<path d="m555 250c0 0.965-0.807 1.77-1.77 1.77h-12.8c-0.962 0-1.77-0.806-1.77-1.77v-23.4c0-0.965 0.809-1.77 1.77-1.77h12.4c0.962 0 1.77 0.809 1.77 1.77v2.23c0 0.96-0.809 1.77-1.77 1.77h-8.05v4.74h6.89c0.962 0 1.77 0.809 1.77 1.77v2.23c0 0.965-0.809 1.77-1.77 1.77h-6.89v4.89h8.43c0.964 0 1.77 0.807 1.77 1.77v2.23" />
<path d="m613 246c1 0.116 1.62 0.192 2.31 0.192 2.54 0 3.35-1.27 3.35-2.78 0-1.58-0.849-3-3.08-3-0.77 0-1.66 0.077-2.58 0.232zm3.08-11.4c5.2 0 8.74 3.24 8.74 8.32 0 5.62-3.74 9.01-10.5 9.01-2.39 0-4.28-0.0758-5.74-0.153-1-0.0393-1.77-0.846-1.77-1.85v-23.4c0-0.965 0.809-1.77 1.77-1.77h12.7c0.962 0 1.77 0.809 1.77 1.77v2.23c0 0.96-0.809 1.77-1.77 1.77h-8.32v4.28c0.733-0.157 2.23-0.233 3.08-0.233" />
<path d="m661 238 4.93-11.9c0.347-0.771 1.08-1.27 1.89-1.27h2.2c1.04 0 1.73 0.733 1.73 1.66 0 0.268-0.076 0.538-0.192 0.807l-7.93 18.1c-1.81 4.12-4.16 6.39-7.9 6.39-1.04 0-2.12-0.191-3.16-0.654-0.541-0.268-0.886-0.733-0.886-1.42 0-0.233 0.037-0.502 0.153-0.809l0.733-1.89c0.347-0.923 0.925-1.2 1.62-1.2 0.231 0 0.463 0.0393 0.694 0.0759 0.502 0.118 0.846 0.118 1 0.118 0.809 0 1.46-0.31 1.81-1.08l0.347-0.809-9.98-16.6c-0.192-0.35-0.268-0.697-0.268-1.04 0-0.889 0.615-1.66 1.69-1.66h2.58c0.807 0 1.62 0.462 2.04 1.19l6.89 12" />
<path d="m702 236c0.886 0.115 1.92 0.194 2.81 0.194 1.62 0 3.62-0.694 3.62-3.31 0-2.39-1.54-3.12-3.74-3.12-0.807 0-1.42 0.0393-2.69 0.0788zm13-3.08c0 4.81-3.74 9.05-9.98 9.05-0.578 0-2.04 0-3-0.115v7.4c0 0.965-0.809 1.77-1.77 1.77h-2.62c-0.964 0-1.77-0.806-1.77-1.77v-23.4c0-1 0.807-1.81 1.77-1.81 2.04-0.0393 4.97-0.0758 6.47-0.0758 8.2 0 10.9 4.28 10.9 8.94" />
<path d="m756 250c0 0.965-0.807 1.77-1.77 1.77h-12.8c-0.962 0-1.77-0.806-1.77-1.77v-23.4c0-0.965 0.809-1.77 1.77-1.77h12.4c0.962 0 1.77 0.809 1.77 1.77v2.23c0 0.96-0.809 1.77-1.77 1.77h-8.05v4.74h6.89c0.962 0 1.77 0.809 1.77 1.77v2.23c0 0.965-0.809 1.77-1.77 1.77h-6.89v4.89h8.43c0.964 0 1.77 0.807 1.77 1.77v2.23" />
<path d="m803 250c0 0.965-0.809 1.77-1.77 1.77h-2.62c-0.964 0-1.77-0.806-1.77-1.77v-9.09h-9.82v9.09c0 0.965-0.812 1.77-1.77 1.77h-2.62c-0.964 0-1.77-0.806-1.77-1.77v-23.4c0-0.965 0.807-1.77 1.77-1.77h2.62c0.962 0 1.77 0.809 1.77 1.77v8.55h9.82v-8.55c0-0.965 0.807-1.77 1.77-1.77h2.62c0.964 0 1.77 0.809 1.77 1.77v23.4" />
<path d="m849 251c-0.962 0-1.77-0.806-1.77-1.77v-15l-11.2 15.6c-0.463 0.694-1.19 1.12-2 1.12h-2.39c-0.962 0-1.77-0.806-1.77-1.77v-23.4c0-0.965 0.809-1.77 1.77-1.77h2.62c0.962 0 1.77 0.809 1.77 1.77v15l11.2-15.6c0.463-0.691 1.2-1.12 2-1.12h2.39c0.962 0 1.77 0.809 1.77 1.77v23.4c0 0.965-0.809 1.77-1.77 1.77h-2.62" />
<path d="m896 250c0 0.965-0.809 1.77-1.77 1.77h-12.8c-0.964 0-1.77-0.806-1.77-1.77v-23.4c0-0.965 0.807-1.77 1.77-1.77h12.4c0.962 0 1.77 0.809 1.77 1.77v2.23c0 0.96-0.809 1.77-1.77 1.77h-8.05v4.74h6.89c0.962 0 1.77 0.809 1.77 1.77v2.23c0 0.965-0.809 1.77-1.77 1.77h-6.89v4.89h8.43c0.962 0 1.77 0.807 1.77 1.77v2.23" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@ -1,9 +1,14 @@
import { memo } from 'react' import { memo } from 'react'
import logo from '@images/logo_32.png' import { ReactComponent as RawLogo } from './Logo.svg'
export const Logo = memo<React.DetailedHTMLProps<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>>((props) => ( export type LogoProps = React.SVGProps<SVGSVGElement> & {
<img src={logo} alt={'АСБ'} className={'logo'} {...props} /> size?: number
onlyIcon?: boolean
}
export const Logo = memo<LogoProps>(({ size = 170, onlyIcon, ...props }) => (
<RawLogo style={{ width: size, height: 282/896*size }} {...props} />
)) ))
export default Logo export default Logo

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@ -1,21 +1,28 @@
import React from 'react' import locale from 'antd/lib/locale/ru_RU'
import { ConfigProvider } from 'antd'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import React from 'react'
import { getUser } from '@utils'
import { OpenAPI } from '@api'
import reportWebVitals from './reportWebVitals'
import App from './App' import App from './App'
import '@styles/include/antd_theme.less'
import '@styles/index.css' import '@styles/index.css'
// OpenAPI.BASE = 'http://localhost:3000'
// TODO: Удалить взятие из 'token' в следующем релизе, вставлено для совместимости
OpenAPI.TOKEN = async () => getUser().token || localStorage.getItem('token') || ''
OpenAPI.HEADERS = { 'Content-Type': 'application/json' }
const container = document.getElementById('root') ?? document.body const container = document.getElementById('root') ?? document.body
const root = createRoot(container) const root = createRoot(container)
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<App /> <ConfigProvider locale={locale}>
<App />
</ConfigProvider>
</React.StrictMode> </React.StrictMode>
) )
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals()

View File

@ -0,0 +1,46 @@
import { memo } from 'react'
import {
ApiOutlined,
BankOutlined,
BranchesOutlined,
DashboardOutlined,
FileSearchOutlined,
FolderOutlined,
IdcardOutlined,
MonitorOutlined,
TeamOutlined,
UserOutlined,
} from '@ant-design/icons'
import { makeItem, PrivateMenu } from '@components/PrivateMenu'
import { isDev } from '@utils'
export const menuItems = [
makeItem('Месторождения', 'deposit', [], <FolderOutlined />),
makeItem('Кусты', 'cluster', [], <FolderOutlined />),
makeItem('Скважины', 'well', [], <FolderOutlined />),
makeItem('Пользователи', 'user', [], <UserOutlined />),
makeItem('Компании', 'company', [], <BankOutlined />),
makeItem('Типы компаний', 'company_type', [], <BankOutlined />),
makeItem('Роли', 'role', [], <TeamOutlined />),
makeItem('Разрешения', 'permission', [], <IdcardOutlined />),
makeItem('Телеметрия', 'telemetry', [], <DashboardOutlined />, [
makeItem('Просмотр', 'viewer', [], <MonitorOutlined />),
makeItem('Объединение', 'merger', [], <BranchesOutlined />),
]),
makeItem('Журнал посещений', 'visit_log', [], <FileSearchOutlined />),
isDev() && makeItem('API', '/swagger/index.html', [], <ApiOutlined />),
].filter(Boolean)
export const AdminNavigationMenu = memo((props) => (
<PrivateMenu
{...props}
items={menuItems}
rootPath={'/admin'}
selectable={false}
mode={'inline'}
theme={'dark'}
/>
))
export default AdminNavigationMenu

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