Compare commits

...

161 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
ts_salikhov
321900da89 Merge branch 'dev' into feature/add-page-operation-time 2022-10-05 14:28:54 +04:00
ts_salikhov
be7e76acf7 На странице Наработка добавлен сброс даты и выделение данных при наведении 2022-10-05 14:20:39 +04:00
ts_salikhov
74b309ee87 Доработан график D3HorizontalChart 2022-10-04 13:55:18 +04:00
ts_salikhov
a965a9b693 Исправлена работа фильтра подсистем 2022-10-03 13:47:34 +04:00
goodmice
eb8cf1f1d4 Изменено название страницы и логотип в шапке 2022-09-12 13:21:37 +05:00
goodmice
bd7c2842c5 Исправлена работа очистки операций перед импортом 2022-09-12 12:40:59 +05:00
goodmice
c8050b91e5 Исправлено наложение телеметрий при смене скважины
* Добавлено отображение загрузки графика
2022-08-23 17:44:02 +05:00
262 changed files with 5460 additions and 4146 deletions

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.113: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-сервера |
| cloud.digitaldrilling.ru | Внешний адрес 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>
```

33
package-lock.json generated
View File

@ -16,9 +16,7 @@
"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"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.18.2", "@babel/core": "^7.18.2",
@ -14689,19 +14687,6 @@
"requires-port": "^1.0.0" "requires-port": "^1.0.0"
} }
}, },
"node_modules/usehooks-ts": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.7.2.tgz",
"integrity": "sha512-DeLqSnGg9VvpwPZA+6lKVURJKM9EBu7bbIXuYclQ9COO3w4lacnJa0uP0iJbC/lAmY7GlmPinjZfGNNmDTlUpg==",
"engines": {
"node": ">=16.15.0",
"npm": ">=8"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -14786,11 +14771,6 @@
"minimalistic-assert": "^1.0.0" "minimalistic-assert": "^1.0.0"
} }
}, },
"node_modules/web-vitals": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz",
"integrity": "sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg=="
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@ -26417,12 +26397,6 @@
"requires-port": "^1.0.0" "requires-port": "^1.0.0"
} }
}, },
"usehooks-ts": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.7.2.tgz",
"integrity": "sha512-DeLqSnGg9VvpwPZA+6lKVURJKM9EBu7bbIXuYclQ9COO3w4lacnJa0uP0iJbC/lAmY7GlmPinjZfGNNmDTlUpg==",
"requires": {}
},
"util-deprecate": { "util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -26492,11 +26466,6 @@
"minimalistic-assert": "^1.0.0" "minimalistic-assert": "^1.0.0"
} }
}, },
"web-vitals": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz",
"integrity": "sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg=="
},
"webidl-conversions": { "webidl-conversions": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",

View File

@ -11,9 +11,7 @@
"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": {
"test": "jest", "test": "jest",
@ -26,11 +24,11 @@
"dev": "webpack-dev-server --env=\"ENV=dev\" --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", "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.113: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": "npx openapi -i https://cloud.digitaldrilling.ru/swagger/v1/swagger.json -o src/services/api",
"oug_dev": "npx openapi -i http://46.146.209.148:89/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://46.146.209.148:89", "proxy": "http://46.146.207.184",
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
"react-app", "react-app",

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,51 +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'))
import '@styles/include/antd_theme.less' 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 { Key, memo, ReactNode } from 'react'
import { Layout, LayoutProps } from 'antd'
import PageHeader from '@components/PageHeader'
import { WellTreeSelector, WellTreeSelectorProps } from '@components/selectors/WellTreeSelector'
import { wrapPrivateComponent } from '@utils'
export type LayoutPortalProps = LayoutProps & {
title?: ReactNode
noSheet?: boolean
selector?: WellTreeSelectorProps
}
const _LayoutPortal = memo<LayoutPortalProps>(({ title, noSheet, selector, ...props }) => (
<Layout.Content>
<PageHeader title={title}>
<WellTreeSelector {...selector} />
</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,30 +0,0 @@
import { memo } from 'react'
import { Layout } from 'antd'
import { Link } 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 }) => (
<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,36 +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,
} from './numeric'
export { makeColumnsPlanFact } from './plan_fact'
export { makeSelectColumn } from './select'
export { makeTagColumn, makeTagInput } from './tag'
export { makeFilterTextMatch, makeTextColumn } 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<DataType<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 компонента редактора
@ -44,13 +35,16 @@ export type columnPropsOther<T = any> = ColumnProps<DataType<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,20 +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 makeColumn, { columnPropsOther, DataType, makeGroupColumn, RenderMethod } from '.' import { getObjectByDeepKey } from '../Table'
import { ColumnFilterItem, CompareFn } from 'antd/lib/table/interface'
export const RegExpIsFloat = /^[-+]?\d+\.?\d*$/ export const RegExpIsFloat = /^[-+]?\d+\.?\d*$/
type FilterMethod<T> = (value: string | number | boolean, record: DataType<T>) => boolean export const makeNumericSorter = <T extends number = number>(key: Key): SorterMethod<T> => (a, b) => {
if (!a && !b) return 0
if (!a) return 1
if (!b) return -1
export const makeNumericRender = <T extends unknown>(fixed?: number): RenderMethod<T> => (value: T) => { return Number(getObjectByDeepKey(key, a)) - Number(getObjectByDeepKey(key, b))
let val = '-' }
if ((value ?? null) !== null && Number.isFinite(+value)) {
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 (
@ -24,7 +29,7 @@ export const makeNumericRender = <T extends unknown>(fixed?: number): RenderMeth
) )
} }
export const makeNumericColumnOptions = <T extends unknown = any>(fixed?: number, sorterKey?: string): columnPropsOther<T> => ({ export const makeNumericColumnOptions = <T extends number>(fixed?: number, sorterKey?: string): ColumnProps<T> => ({
editable: true, editable: true,
initialValue: 0, initialValue: 0,
width: 100, width: 100,
@ -37,61 +42,99 @@ export const makeNumericColumnOptions = <T extends unknown = any>(fixed?: number
render: makeNumericRender<T>(fixed), render: makeNumericRender<T>(fixed),
}) })
export const makeNumericColumn = <T extends unknown = any>( export const makeNumericColumn = <T extends number>(
title: ReactNode, title: ReactNode,
key: string, key: Key,
filters?: ColumnFilterItem[],
filterDelegate?: (key: string | number) => FilterMethod<T>,
renderDelegate?: RenderMethod<T>, renderDelegate?: RenderMethod<T>,
filterDelegate?: FilterGenerator<T>,
width?: string | number, width?: string | number,
other?: columnPropsOther, other?: ColumnProps<T>,
) => makeColumn(title, key, { ) => makeColumn(title, key, {
filters, editable: true,
onFilter: filterDelegate ? filterDelegate(key) : undefined, onFilter: filterDelegate ? filterDelegate(key) : undefined,
sorter: makeNumericSorter(key), sorter: makeNumericSorter(key),
width, width,
input: <InputNumber style={{ width: '100%' }}/>, input: <InputNumber style={{ width: '100%' }} defaultValue={0} />,
render: renderDelegate ?? makeNumericRender<T>(2), render: renderDelegate || makeNumericRender<T>(2),
align: 'right', align: 'right',
...other ...other
}) })
export const makeNumericColumnPlanFact = <T extends unknown = any>( export const makeNumericColumnPlanFact = <T extends number>(
title: ReactNode, title: ReactNode,
key: string, key: Key,
filters?: ColumnFilterItem[],
filterDelegate?: (key: string | number) => FilterMethod<T>,
renderDelegate?: RenderMethod<T>, renderDelegate?: RenderMethod<T>,
width?: string | number filterDelegate?: FilterGenerator<T>,
width?: string | number,
other?: ColumnProps<T>,
) => {
return {
title,
children: [
makeNumericColumn<T>('План', `${key}.plan`, renderDelegate, filterDelegate, width, other),
makeNumericColumn<T>('Факт', `${key}.fact`, renderDelegate, filterDelegate, width, other),
]
}
}
/**
* @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,
key: Key,
renderDelegate?: RenderMethod<T>,
filterDelegate?: FilterGenerator<T>,
width?: string | number,
other?: ColumnProps<T>,
) => makeGroupColumn(title, [ ) => makeGroupColumn(title, [
makeNumericColumn<T>('п', key + 'Plan', filters, filterDelegate, renderDelegate, width), makeNumericColumn<T>('План', key + 'Plan', renderDelegate, filterDelegate, width, other),
makeNumericColumn<T>('ф', key + 'Fact', filters, filterDelegate, renderDelegate, width), makeNumericColumn<T>('Факт', key + 'Fact', renderDelegate, filterDelegate, width, other),
]) ])
export const makeNumericStartEnd = <T extends unknown = any>( export const makeNumericStartEnd = <T extends number>(
title: ReactNode, title: ReactNode,
key: string, key: Key,
fixed: number, fixed: number,
filters?: ColumnFilterItem[],
filterDelegate?: (key: string | number) => FilterMethod<T>,
renderDelegate?: RenderMethod<T>, renderDelegate?: RenderMethod<T>,
filterDelegate?: FilterGenerator<T>,
width?: string | number, width?: string | number,
) => makeGroupColumn(title, [ ) => makeGroupColumn(title, [
makeNumericColumn<T>('старт', key + 'Start', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, key + 'Start')), makeNumericColumn<T>('старт', key + 'Start', renderDelegate, filterDelegate, width, makeNumericColumnOptions(fixed, key + 'Start')),
makeNumericColumn<T>('конец', key + 'End', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, key + 'End')) makeNumericColumn<T>('конец', key + 'End', renderDelegate, filterDelegate, width, makeNumericColumnOptions(fixed, key + 'End'))
]) ])
export const makeNumericMinMax = <T extends unknown = any>( export const makeNumericMinMax = <T extends number>(
title: ReactNode, title: ReactNode,
key: string, key: Key,
fixed: number, fixed: number,
filters?: ColumnFilterItem[],
filterDelegate?: (key: string | number) => FilterMethod<T>,
renderDelegate?: RenderMethod<T>, renderDelegate?: RenderMethod<T>,
filterDelegate?: FilterGenerator<T>,
width?: string | number, width?: string | number,
) => makeGroupColumn(title, [ ) => makeGroupColumn(title, [
makeNumericColumn<T>('мин', key + 'Min', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, key + 'Min')), makeNumericColumn<T>('мин', key + 'Min', renderDelegate, filterDelegate, width, makeNumericColumnOptions(fixed, key + 'Min')),
makeNumericColumn<T>('макс', key + 'Max', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, key + 'Max')), makeNumericColumn<T>('макс', key + 'Max', renderDelegate, filterDelegate, width, makeNumericColumnOptions(fixed, key + 'Max')),
])
export const makeNumericAvgRange = <T extends number>(
title: ReactNode,
key: Key,
fixed: number,
renderDelegate?: RenderMethod<T>,
filterDelegate?: FilterGenerator<T>,
width?: string | number,
) => makeGroupColumn(title, [
makeNumericColumn<T>('мин', `${key}.min`, renderDelegate, filterDelegate, width, makeNumericColumnOptions(fixed, `${key}.min`)),
makeNumericColumn<T>('сред', `${key}.avg`, renderDelegate, filterDelegate, width, makeNumericColumnOptions(fixed, `${key}.avg`)),
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,8 +1,32 @@
import { Tooltip } from 'antd'
import { ColumnFilterItem } from 'antd/lib/table/interface' import { ColumnFilterItem } from 'antd/lib/table/interface'
import { ReactNode } from 'react' import { Key, ReactNode } from 'react'
import makeColumn, { 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
@ -13,12 +37,13 @@ export const makeTextColumn = <T extends unknown = any>(
filters?: ColumnFilterItem[], filters?: ColumnFilterItem[],
sorter?: SorterMethod<T>, sorter?: SorterMethod<T>,
render?: RenderMethod<T>, render?: RenderMethod<T>,
other?: columnPropsOther other?: ColumnProps
) => makeColumn(title, key, { ) => makeColumn(title, key, {
editable: true,
filters, filters,
onFilter: filters ? makeFilterTextMatch(key) : undefined, onFilter: filters ? makeFilterTextMatch(key) : undefined,
sorter: sorter ?? makeStringSorter(key), sorter: sorter || makeStringSorter(key),
render: render, render: render || makeTextRender(),
...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,37 +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> = ColumnGroupType<T> | ColumnType<T> export type BaseTableColumn<T> = ColumnGroupType<T> | ColumnType<T>
export type TableColumns<T> = OmitExtends<BaseTableColumn<T>, TableColumnSettings>[] export type TableColumn<T> = OmitExtends<BaseTableColumn<T>, TableColumnSettings>
export type TableColumns<T> = TableColumn<T>[]
export type TableContainer<T> = TableProps<T> & { export type TableProps<T> = RawTableProps<T> & {
columns: TableColumns<T> /** Массив колонок таблицы с настройками (описаны в `TableColumnSettings`) */
columns: TableColumn<T>[]
/** Название таблицы для сохранения настроек */
tableName?: string tableName?: string
/** Отображать ли кнопку настроек */
showSettingsChanger?: boolean showSettingsChanger?: boolean
} }
const _Table = <T extends object>({ columns, dataSource, tableName, showSettingsChanger, ...other }: TableContainer<T>) => { export interface DataSet<T, D = any> {
const [newColumns, setNewColumns] = useState<TableColumns<T>>([]) [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) => (
@ -53,6 +112,13 @@ const _Table = <T extends object>({ columns, dataSource, tableName, showSettings
) )
} }
/**
* Обёртка над компонентом таблицы AntD
*
* Особенности:
* * Поддержка составных ключей столбцов
* * Работа с настройками столбцов таблицы
*/
export const Table = memo(_Table) as typeof _Table export const Table = memo(_Table) as typeof _Table
export default Table export default Table

View File

@ -1,11 +1,11 @@
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 = <T extends object>(columns?: TableColumns<T>, 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 ?? {})
@ -46,8 +46,8 @@ const _TableSettingsChanger = <T extends object>({ title, columns, settings, onC
useEffect(() => { useEffect(() => {
setTableColumns([ setTableColumns([
makeColumn('Название', 'title'), makeTextColumn<string>('Название', 'title'),
makeColumn(null, 'visible', { makeColumn<any>(null, 'visible', {
title: () => ( title: () => (
<> <>
Показать Показать
@ -56,7 +56,7 @@ const _TableSettingsChanger = <T extends object>({ title, columns, settings, onC
</Button> </Button>
</> </>
), ),
render: (visible: boolean, _?: TableColumnSettings, index: number = NaN) => ( render: (visible, _, index = NaN) => (
<Switch <Switch
checked={visible} checked={visible}
checkedChildren={'Отображён'} checkedChildren={'Отображён'}
@ -84,7 +84,7 @@ const _TableSettingsChanger = <T extends object>({ title, columns, settings, onC
<> <>
<Modal <Modal
centered centered
visible={visible} open={visible}
onCancel={onModalCancel} onCancel={onModalCancel}
onOk={onModalOk} onOk={onModalOk}
title={title ?? 'Настройка отображения таблицы'} title={title ?? 'Настройка отображения таблицы'}

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,58 +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,
makeNumericColumnOptions,
makeNumericColumnPlanFact,
makeNumericStartEnd,
makeNumericMinMax,
makeSelectColumn,
makeTagColumn,
makeTagInput,
makeTextColumn,
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,42 +0,0 @@
import { timeToMoment } from '@utils'
import { isRawDate } from '@utils'
import { TimeDto } from '@api'
import { DataType } from './Columns'
import { CompareFn } from 'antd/lib/table/interface'
export const makeNumericSorter = <T extends unknown>(key: keyof DataType<T>): CompareFn<DataType<T>> =>
(a: DataType<T>, b: DataType<T>) => Number(a[key]) - Number(b[key])
export const makeNumericObjSorter = (key: [string, string]) =>
(a: DataType, b: DataType) => Number(a[key[0]][key[1]]) - Number(b[key[0]][key[1]])
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

@ -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'
@ -36,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,
@ -131,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

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

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

@ -9,9 +9,9 @@ import { getChartIcon, isDev, usePartialProps } from '@utils'
import { BaseDataType } from '../types' 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 extends BaseDataType> = (group: ChartGroup<DataType>, data: DataType[]) => ReactNode type D3GroupRenderFunction<DataType extends BaseDataType> = (group: ChartGroup<DataType>, data: DataType[], flowData: DataType[] | undefined) => ReactNode
export type D3HorizontalCursorSettings<DataType extends BaseDataType> = { export type D3HorizontalCursorSettings<DataType extends BaseDataType> = {
width?: number width?: number
@ -27,6 +27,7 @@ export type D3HorizontalCursorSettings<DataType extends BaseDataType> = {
export type D3HorizontalCursorProps<DataType extends BaseDataType> = 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 spaceBetweenGroups?: number
@ -38,7 +39,7 @@ const defaultLineStyle: SVGProps<SVGLineElement> = {
const offsetY = 5 const offsetY = 5
const makeDefaultRender = <DataType extends BaseDataType>(): 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 ?? ''}`
@ -74,6 +75,7 @@ const _D3HorizontalCursor = <DataType extends BaseDataType>({
lineStyle: _lineStyle, lineStyle: _lineStyle,
data, data,
flowData,
groups, groups,
sizes, sizes,
yAxis, yAxis,
@ -167,7 +169,7 @@ const _D3HorizontalCursor = <DataType extends BaseDataType>({
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])
@ -190,7 +192,7 @@ const _D3HorizontalCursor = <DataType extends BaseDataType>({
> >
<div className={'tooltip-wrapper'}> <div className={'tooltip-wrapper'}>
<div className={`adaptive-tooltip tooltip ${position} ${className}`} <div className={`adaptive-tooltip tooltip ${position} ${className}`}
style={{height: 'auto', bottom: `${position === 'bottom' ? '0' : ''}`}} style={{height: 'auto', bottom: `${position === 'bottom' ? '0' : ''}`}}
> >
<div className={'tooltip-content'}> <div className={'tooltip-content'}>
{tooltipBodies[i]} {tooltipBodies[i]}

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, useUserSettings } from '@utils' import { isDev, useElementSize, usePartialProps, useUserSettings } from '@utils'
import { import {
BaseDataType, BaseDataType,
@ -35,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) {
@ -73,8 +73,8 @@ export type ChartGroup<DataType extends BaseDataType> = {
} }
const defaultOffsets: ChartOffset = { const defaultOffsets: ChartOffset = {
top: 10, top: 0,
bottom: 10, bottom: 0,
left: 100, left: 100,
right: 20, right: 20,
} }
@ -115,6 +115,8 @@ export type D3MonitoringChartsProps<DataType extends BaseDataType> = Omit<React.
loading?: boolean loading?: boolean
/** Массив отображаемых данных */ /** Массив отображаемых данных */
data?: DataType[] data?: DataType[]
/** Массив данных для прямоугольников */
flowData?: DataType[]
/** Отступы графика от края SVG */ /** Отступы графика от края SVG */
offset?: Partial<ChartOffset> offset?: Partial<ChartOffset>
/** Цвет фона в формате CSS-значения */ /** Цвет фона в формате CSS-значения */
@ -180,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,
@ -193,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)
@ -208,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])
@ -241,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) => {
@ -351,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')
@ -452,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])
@ -462,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)
@ -491,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
@ -513,6 +517,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
style={{ style={{
width: givenWidth, width: givenWidth,
height: givenHeight, height: givenHeight,
...style,
}} }}
> >
<div <div
@ -528,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>
@ -569,6 +574,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
sizes={sizes} sizes={sizes}
spaceBetweenGroups={spaceBetweenGroups} spaceBetweenGroups={spaceBetweenGroups}
data={data} data={data}
flowData={flowData}
height={height} height={height}
/> />
</D3MouseZone> </D3MouseZone>

View File

@ -135,7 +135,7 @@ const _D3MonitoringEditor = <DataType extends BaseDataType>({
<Modal <Modal
centered centered
width={800} width={800}
visible={visible} open={visible}
title={'Настройка групп графиков'} title={'Настройка групп графиков'}
onCancel={onCancel} onCancel={onCancel}
footer={( footer={(

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

@ -8,7 +8,7 @@ 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'

View File

@ -6,7 +6,8 @@ import { appendTransition } from './base'
export const renderRectArea = <DataType extends BaseDataType>( 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 BaseDataType>(
!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,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'
@ -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,41 +1,50 @@
import { Button, Drawer, Skeleton, Tree, TreeDataNode, TreeProps, Typography } from 'antd' import { Drawer, Tree, TreeDataNode, TreeProps } from 'antd'
import { useState, useEffect, useCallback, memo, Key } from 'react' import { useState, useEffect, useCallback, memo, Key, useMemo } 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)
const getKeyByUrl = (url?: string): [Key | null, string | null] => { const getKeyByUrl = (url?: string): [Key | null, string | null, number | null] => {
const result = url?.match(/^\/([^\/]+)\/([^\/?]+)/) // pattern "/:type/:id" const result = url?.match(URL_REGEX) // pattern "/:type/:id"
if (!result) return [null, null] if (!result) return [null, null, null]
return [result[0], result[1]] return [result[0], result[1], result[2] && result[2] !== 'null' ? Number(result[2]) : null]
} }
const getLabel = (wellsTree: TreeDataNode[], value?: string): string | undefined => { const getLabel = (wellsTree: TreeDataNode[], value?: string): string | undefined => {
const [url, type] = getKeyByUrl(value) const [url, type, key] = getKeyByUrl(value)
if (!url) return if (!url) return
let deposit: TreeDataNode | undefined let deposit: TreeDataNode | undefined
let cluster: TreeDataNode | undefined let cluster: TreeDataNode | undefined
let well: TreeDataNode | 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: TreeDataNode) => cluster.key === url) cluster = deposit.children?.find((cluster: TreeDataNode) => cluster.key === url)
)) ))
@ -44,6 +53,7 @@ const getLabel = (wellsTree: TreeDataNode[], value?: string): string | undefined
return 'Ошибка! Куст не найден!' return 'Ошибка! Куст не найден!'
case 'well': case 'well':
if (key === null) return 'Скважина не выбрана'
deposit = wellsTree.find((deposit) => ( deposit = wellsTree.find((deposit) => (
cluster = deposit.children?.find((cluster: TreeDataNode) => ( cluster = deposit.children?.find((cluster: TreeDataNode) => (
well = cluster.children?.find((well: TreeDataNode) => well.key === url) well = cluster.children?.find((well: TreeDataNode) => well.key === url)
@ -74,6 +84,9 @@ export type WellTreeSelectorProps = TreeProps<TreeDataNode> & {
show?: boolean show?: boolean
expand?: boolean | Key[] expand?: boolean | Key[]
current?: Key current?: Key
onClose?: () => void
onChange?: (value: string | undefined) => void
open?: boolean
} }
const getExpandKeys = (treeData: TreeDataNode[], depositKeys?: Key[] | boolean): Key[] => { const getExpandKeys = (treeData: TreeDataNode[], depositKeys?: Key[] | boolean): Key[] => {
@ -88,78 +101,54 @@ const getExpandKeys = (treeData: TreeDataNode[], depositKeys?: Key[] | boolean):
return out return out
} }
export const WellTreeSelector = memo<WellTreeSelectorProps>(({ show, expand, current, ...other }) => { const makeWellsTreeData = (deposits: DepositDto[]): TreeDataNode[] => deposits.map(deposit =>({
const [wellsTree, setWellsTree] = useState<TreeDataNode[]>([]) title: deposit.caption,
const [showLoader, setShowLoader] = useState<boolean>(false) key: `/deposit/${deposit.id}`,
const [visible, setVisible] = useState<boolean>(false) 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()
console.log(location.pathname) const wellsTree = useMemo(() => makeWellsTreeData(deposits), [deposits])
useEffect(() => { const onValueChange = useCallback((value?: string): void => {
if (current) setSelected([current])
}, [current])
useEffect(() => setVisible((prev) => show ?? prev), [show])
useEffect(() => {
setExpanded((prev) => expand ? getExpandKeys(wellsTree, expand) : prev)
}, [wellsTree, expand])
useEffect(() => {
invokeWebApiWrapperAsync(
async () => {
const deposits: Array<DepositDto> = await DepositService.getDeposits()
const wellsTree: 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)}
/>
})),
}
}),
}))
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 => {
const newRoot = /\/(\w+)\/\d+/.exec(String(value)) const newRoot = URL_REGEX.exec(String(value))
const oldRoot = /\/(\w+)(?:\/\d+)?/.exec(location.pathname) const oldRoot = URL_REGEX.exec(location.pathname)
if (!newRoot || !oldRoot) return if (!newRoot || !oldRoot) return
let newPath = newRoot[0] let newPath = newRoot[0]
@ -172,26 +161,28 @@ export const WellTreeSelector = memo<WellTreeSelectorProps>(({ show, expand, cur
navigate(newPath, { state: { from: location.pathname }}) 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

@ -10,6 +10,7 @@ export type UserViewProps = HTMLProps<HTMLSpanElement> & {
user?: UserDto user?: UserDto
} }
/** Компонент для отображения информации о пользователе */
export const UserView = memo<UserViewProps>(({ user, ...other }) => export const UserView = memo<UserViewProps>(({ user, ...other }) =>
user ? ( user ? (
<Tooltip <Tooltip

View File

@ -1,4 +1,4 @@
import { memo } from 'react' import { DetailedHTMLProps, HTMLAttributes, memo } from 'react'
import { Tooltip, TooltipProps } from 'antd' import { Tooltip, TooltipProps } from 'antd'
import { Grid, GridItem } from '@components/Grid' import { Grid, GridItem } from '@components/Grid'
@ -15,9 +15,19 @@ const wellState: Record<number, { enum: WellIconState, label: string }> = {
export type WellViewProps = TooltipProps & { export type WellViewProps = TooltipProps & {
well?: WellDto well?: WellDto
iconProps?: DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>
labelProps?: DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>
} }
export const WellView = memo<WellViewProps>(({ well, ...other }) => well ? ( /**
* Получить название скважины
* @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={( <Tooltip {...other} title={(
<Grid style={{ columnGap: '8px' }}> <Grid style={{ columnGap: '8px' }}>
<GridItem row={1} col={1}>Название:</GridItem> <GridItem row={1} col={1}>Название:</GridItem>
@ -47,10 +57,12 @@ export const WellView = memo<WellViewProps>(({ well, ...other }) => 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,11 +1,14 @@
import { memo } from 'react' import { memo } from 'react'
import { ReactComponent as AsbLogo } from '@images/dd_logo_white_opt.svg' import { ReactComponent as RawLogo } from './Logo.svg'
export type LogoProps = React.SVGProps<SVGSVGElement> & { size?: number } export type LogoProps = React.SVGProps<SVGSVGElement> & {
size?: number
onlyIcon?: boolean
}
export const Logo = memo<LogoProps>(({ size = 200, ...props }) => ( export const Logo = memo<LogoProps>(({ size = 170, onlyIcon, ...props }) => (
<AsbLogo className={'logo'} height={'100%'} {...props} /> <RawLogo style={{ width: size, height: 282/896*size }} {...props} />
)) ))
export default Logo export default Logo

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

View File

@ -3,19 +3,18 @@ import { Input } from 'antd'
import { import {
EditableTable, EditableTable,
makeColumn,
makeSelectColumn, makeSelectColumn,
makeStringSorter, makeStringSorter,
defaultPagination, defaultPagination,
makeTimezoneColumn makeTimezoneColumn,
makeNumericColumn,
makeTextColumn
} from '@components/Table' } from '@components/Table'
import { invokeWebApiWrapperAsync } from '@components/factory' import { invokeWebApiWrapperAsync } from '@components/factory'
import { AdminClusterService, AdminDepositService } from '@api' import { AdminClusterService, AdminDepositService } from '@api'
import { arrayOrDefault, wrapPrivateComponent } from '@utils' import { arrayOrDefault, coordsFormat, withPermissions } from '@utils'
import { min1 } from '@utils/validationRules' import { min1 } from '@utils/validationRules'
import { coordsFixed } from './DepositController'
const ClusterController = memo(() => { const ClusterController = memo(() => {
const [deposits, setDeposits] = useState([]) const [deposits, setDeposits] = useState([])
const [clusters, setClusters] = useState([]) const [clusters, setClusters] = useState([])
@ -32,17 +31,11 @@ const ClusterController = memo(() => {
const clusterColumns = useMemo(() => [ const clusterColumns = useMemo(() => [
makeSelectColumn('Месторождение', 'idDeposit', deposits, '--', { makeSelectColumn('Месторождение', 'idDeposit', deposits, '--', {
width: 200, width: 200,
editable: true,
sorter: makeStringSorter('idDeposit') sorter: makeStringSorter('idDeposit')
}), }),
makeColumn('Название', 'caption', { makeTextColumn('Название', 'caption', undefined, undefined, undefined, { width: 200, formItemRules: min1 }),
width: 200, makeNumericColumn('Широта', 'latitude', coordsFormat, undefined, 150),
editable: true, makeNumericColumn('Долгота', 'longitude', coordsFormat, undefined, 150),
sorter: makeStringSorter('caption'),
formItemRules: min1,
}),
makeColumn('Широта', 'latitude', { width: 150, editable: true, render: coordsFixed }),
makeColumn('Долгота', 'longitude', { width: 150, editable: true, render: coordsFixed }),
makeTimezoneColumn('Зона', 'timezone', null, true, { width: 150 }), makeTimezoneColumn('Зона', 'timezone', null, true, { width: 150 }),
], [deposits]) ], [deposits])
@ -108,13 +101,10 @@ const ClusterController = memo(() => {
onRowEdit={tableHandlers.edit} onRowEdit={tableHandlers.edit}
onRowDelete={tableHandlers.delete} onRowDelete={tableHandlers.delete}
tableName={'admin_cluster_controller'} tableName={'admin_cluster_controller'}
scroll={{ x: true }}
/> />
</> </>
) )
}) })
export default wrapPrivateComponent(ClusterController, { export default withPermissions(ClusterController, ['AdminDeposit.get', 'AdminCluster.get'])
requirements: ['AdminDeposit.get', 'AdminCluster.get'],
title: 'Кусты',
route: 'cluster',
})

View File

@ -3,14 +3,13 @@ import { Input } from 'antd'
import { import {
EditableTable, EditableTable,
makeColumn,
makeStringSorter,
makeSelectColumn, makeSelectColumn,
defaultPagination defaultPagination,
makeTextColumn
} from '@components/Table' } from '@components/Table'
import { invokeWebApiWrapperAsync } from '@components/factory' import { invokeWebApiWrapperAsync } from '@components/factory'
import { AdminCompanyService, AdminCompanyTypeService } from '@api' import { AdminCompanyService, AdminCompanyTypeService } from '@api'
import { arrayOrDefault, wrapPrivateComponent } from '@utils' import { arrayOrDefault, withPermissions } from '@utils'
import { min1 } from '@utils/validationRules' import { min1 } from '@utils/validationRules'
const CompanyController = memo(() => { const CompanyController = memo(() => {
@ -37,16 +36,8 @@ const CompanyController = memo(() => {
})) }))
setColumns([ setColumns([
makeColumn('Название', 'caption', { makeTextColumn('Название', 'caption', undefined, undefined, undefined, { width: 200, formItemRules: min1 }),
width: 200, makeSelectColumn('Тип компании', 'idCompanyType', companyTypes, null, { width: 200 }),
editable: true,
sorter: makeStringSorter('caption'),
formItemRules: min1,
}),
makeSelectColumn('Тип компании', 'idCompanyType', companyTypes, null, {
width: 200,
editable: true
}),
]) ])
await updateTable() await updateTable()
@ -97,13 +88,10 @@ const CompanyController = memo(() => {
onRowEdit={tableHandlers.edit} onRowEdit={tableHandlers.edit}
onRowDelete={tableHandlers.delete} onRowDelete={tableHandlers.delete}
tableName={'admin_company_controller'} tableName={'admin_company_controller'}
scroll={{ x: true }}
/> />
</> </>
) )
}) })
export default wrapPrivateComponent(CompanyController, { export default withPermissions(CompanyController, ['AdminCompany.get', 'AdminCompanyType.get'])
requirements: ['AdminCompany.get', 'AdminCompanyType.get'],
title: 'Компании',
route: 'company',
})

View File

@ -1,19 +1,14 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { Input } from 'antd' import { Input } from 'antd'
import { EditableTable, makeColumn, makeStringSorter, defaultPagination } from '@components/Table' import { EditableTable, defaultPagination, makeTextColumn } from '@components/Table'
import { invokeWebApiWrapperAsync } from '@components/factory' import { invokeWebApiWrapperAsync } from '@components/factory'
import { arrayOrDefault, wrapPrivateComponent } from '@utils' import { arrayOrDefault, withPermissions } from '@utils'
import { min1 } from '@utils/validationRules' import { min1 } from '@utils/validationRules'
import { AdminCompanyTypeService } from '@api' import { AdminCompanyTypeService } from '@api'
const columns = [ const columns = [
makeColumn('Название', 'caption', { makeTextColumn('Название', 'caption', undefined, undefined, undefined, { width: 200, formItemRules: min1 }),
width: 200,
editable: true,
sorter: makeStringSorter('caption'),
formItemRules: min1,
}),
] ]
const CompanyTypeController = memo(() => { const CompanyTypeController = memo(() => {
@ -75,13 +70,10 @@ const CompanyTypeController = memo(() => {
onRowEdit={tableHandlers.edit} onRowEdit={tableHandlers.edit}
onRowDelete={tableHandlers.delete} onRowDelete={tableHandlers.delete}
tableName={'admin_company_type_controller'} tableName={'admin_company_type_controller'}
scroll={{ x: true }}
/> />
</> </>
) )
}) })
export default wrapPrivateComponent(CompanyTypeController, { export default withPermissions(CompanyTypeController, ['AdminCompanyType.get'])
requirements: ['AdminCompanyType.get'],
title: 'Типы компаний',
route: 'company_type',
})

View File

@ -2,17 +2,15 @@ import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { Input } from 'antd' import { Input } from 'antd'
import { invokeWebApiWrapperAsync } from '@components/factory' import { invokeWebApiWrapperAsync } from '@components/factory'
import { EditableTable, makeColumn, defaultPagination, makeTimezoneColumn } from '@components/Table' import { EditableTable, defaultPagination, makeTimezoneColumn, makeTextColumn, makeNumericColumn } from '@components/Table'
import { arrayOrDefault, wrapPrivateComponent } from '@utils' import { arrayOrDefault, coordsFormat, withPermissions } from '@utils'
import { min1 } from '@utils/validationRules' import { min1 } from '@utils/validationRules'
import { AdminDepositService } from '@api' import { AdminDepositService } from '@api'
export const coordsFixed = (coords) => coords && isFinite(coords) ? (+coords).toPrecision(10) : '-'
const depositColumns = [ const depositColumns = [
makeColumn('Название', 'caption', { width: 200, editable: true, formItemRules: min1 }), makeTextColumn('Название', 'caption', undefined, undefined, undefined, { width: 200, formItemRules: min1 }),
makeColumn('Широта', 'latitude', { width: 150, editable: true, render: coordsFixed }), makeNumericColumn('Широта', 'latitude', coordsFormat, undefined, 150),
makeColumn('Долгота', 'longitude', { width: 150, editable: true, render: coordsFixed }), makeNumericColumn('Долгота', 'longitude', coordsFormat, undefined, 150),
makeTimezoneColumn('Зона', 'timezone', null, true, { width: 150 }), makeTimezoneColumn('Зона', 'timezone', null, true, { width: 150 }),
] ]
@ -77,13 +75,10 @@ const DepositController = memo(() => {
onRowEdit={tableHandlers.edit} onRowEdit={tableHandlers.edit}
onRowDelete={tableHandlers.delete} onRowDelete={tableHandlers.delete}
tableName={'admin_deposit_controller'} tableName={'admin_deposit_controller'}
scroll={{ x: true }}
/> />
</> </>
) )
}) })
export default wrapPrivateComponent(DepositController, { export default withPermissions(DepositController, ['AdminDeposit.get'])
requirements: ['AdminDeposit.get'],
title: 'Месторождения',
route: 'deposit',
})

View File

@ -1,23 +1,15 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { Input } from 'antd' import { Input } from 'antd'
import { EditableTable, makeColumn, makeStringSorter } from '@components/Table' import { EditableTable, makeTextColumn } from '@components/Table'
import { invokeWebApiWrapperAsync } from '@components/factory' import { invokeWebApiWrapperAsync } from '@components/factory'
import { arrayOrDefault, wrapPrivateComponent } from '@utils' import { arrayOrDefault, withPermissions } from '@utils'
import { min1 } from '@utils/validationRules' import { min1 } from '@utils/validationRules'
import { AdminPermissionService } from '@api' import { AdminPermissionService } from '@api'
const columns = [ const columns = [
makeColumn('Название', 'name', { makeTextColumn('Название', 'name', undefined, undefined, undefined, { isRequired: true, formItemRules: min1 }),
editable: true, makeTextColumn('Описание', 'description'),
sorter: makeStringSorter('name'),
isRequired: true,
formItemRules: min1,
}),
makeColumn('Описание', 'description', {
editable: true,
sorter: makeStringSorter('description'),
}),
] ]
const PermissionController = memo(() => { const PermissionController = memo(() => {
@ -82,13 +74,10 @@ const PermissionController = memo(() => {
onRowEdit={tableHandlers.edit} onRowEdit={tableHandlers.edit}
onRowDelete={tableHandlers.delete} onRowDelete={tableHandlers.delete}
tableName={'admin_permission_controller'} tableName={'admin_permission_controller'}
scroll={{ x: true }}
/> />
</> </>
) )
}) })
export default wrapPrivateComponent(PermissionController, { export default withPermissions(PermissionController, ['AdminPermission.get'])
requirements: ['AdminPermission.get'],
title: 'Разрешения',
route: 'permission',
})

View File

@ -5,7 +5,7 @@ import { PermissionView, RoleView } from '@components/views'
import { invokeWebApiWrapperAsync } from '@components/factory' import { invokeWebApiWrapperAsync } from '@components/factory'
import { EditableTable, makeTagColumn, makeTextColumn } from '@components/Table' import { EditableTable, makeTagColumn, makeTextColumn } from '@components/Table'
import { AdminPermissionService, AdminUserRoleService } from '@api' import { AdminPermissionService, AdminUserRoleService } from '@api'
import { arrayOrDefault, wrapPrivateComponent } from '@utils' import { arrayOrDefault, withPermissions } from '@utils'
import { min1 } from '@utils/validationRules' import { min1 } from '@utils/validationRules'
const RoleController = memo(() => { const RoleController = memo(() => {
@ -19,15 +19,13 @@ const RoleController = memo(() => {
)), [roles, searchValue]) )), [roles, searchValue])
const columns = useMemo(() => [ const columns = useMemo(() => [
makeTextColumn('Название', 'caption', null, null, null, { width: 100, editable: true, formItemRules: min1 }), makeTextColumn('Название', 'caption', null, null, null, { width: 100, formItemRules: min1 }),
makeTagColumn('Включённые роли', 'roles', roles, 'id', 'caption', { makeTagColumn('Включённые роли', 'roles', roles, 'id', 'caption', {
width: 400, width: 400,
editable: true,
render: (role) => <RoleView role={role} /> render: (role) => <RoleView role={role} />
}, { allowClear: true }), }, { allowClear: true }),
makeTagColumn('Разрешения', 'permissions', permissions, 'id', 'name', { makeTagColumn('Разрешения', 'permissions', permissions, 'id', 'name', {
width: 600, width: 600,
editable: true,
render: (permission) => <PermissionView info={permission} />, render: (permission) => <PermissionView info={permission} />,
}), }),
], [roles, permissions]) ], [roles, permissions])
@ -89,13 +87,10 @@ const RoleController = memo(() => {
onRowEdit={tableHandlers.edit} onRowEdit={tableHandlers.edit}
onRowDelete={tableHandlers.delete} onRowDelete={tableHandlers.delete}
tableName={'admin_role_controller'} tableName={'admin_role_controller'}
scroll={{ x: true }}
/> />
</> </>
) )
}) })
export default wrapPrivateComponent(RoleController, { export default withPermissions(RoleController, ['AdminPermission.get', 'AdminUserRole.get'])
requirements: ['AdminPermission.get', 'AdminUserRole.get'],
title: 'Роли',
route: 'role',
})

View File

@ -8,7 +8,7 @@ import LoaderPortal from '@components/LoaderPortal'
import { lables } from '@components/views/TelemetryView' import { lables } from '@components/views/TelemetryView'
import { invokeWebApiWrapperAsync } from '@components/factory' import { invokeWebApiWrapperAsync } from '@components/factory'
import TelemetrySelect from '@components/selectors/TelemetrySelect' import TelemetrySelect from '@components/selectors/TelemetrySelect'
import { arrayOrDefault, wrapPrivateComponent } from '@utils' import { arrayOrDefault, withPermissions } from '@utils'
import { AdminTelemetryService } from '@api' import { AdminTelemetryService } from '@api'
const { Item } = Descriptions const { Item } = Descriptions
@ -134,9 +134,4 @@ const TelemetryMerger = memo(() => {
) )
}) })
export default wrapPrivateComponent(TelemetryMerger, { export default withPermissions(TelemetryMerger)
requirements: [],
title: 'Объединение',
route: 'merger',
key: 'merger',
})

View File

@ -6,7 +6,7 @@ import { Button, Input } from 'antd'
import { import {
defaultPagination, defaultPagination,
makeColumn, makeColumn,
makeDateSorter, makeDateColumn,
makeNumericColumn, makeNumericColumn,
makeNumericRender, makeNumericRender,
makeTextColumn, makeTextColumn,
@ -14,7 +14,7 @@ import {
} from '@components/Table' } from '@components/Table'
import Poprompt from '@components/selectors/Poprompt' import Poprompt from '@components/selectors/Poprompt'
import { invokeWebApiWrapperAsync } from '@components/factory' import { invokeWebApiWrapperAsync } from '@components/factory'
import { arrayOrDefault, wrapPrivateComponent } from '@utils' import { arrayOrDefault, withPermissions } from '@utils'
import { AdminTelemetryService } from '@api' import { AdminTelemetryService } from '@api'
const TelemetryController = memo(() => { const TelemetryController = memo(() => {
@ -50,10 +50,10 @@ const TelemetryController = memo(() => {
const columns = useMemo(() => [ const columns = useMemo(() => [
makeColumn('', 'hasParent', { render: mergeRender }), makeColumn('', 'hasParent', { render: mergeRender }),
makeNumericColumn('ID', 'id', null, null, makeNumericRender(0)), makeNumericColumn('ID', 'id', makeNumericRender(0)),
makeTextColumn('UID', 'remoteUid'), makeTextColumn('UID', 'remoteUid'),
makeTextColumn('Назначена на скважину', 'realWell'), makeTextColumn('Назначена на скважину', 'realWell'),
makeTextColumn('Дата начала бурения', 'drillingStartDate', null, makeDateSorter('drillingStartDate')), makeDateColumn('Дата начала бурения', 'drillingStartDate'),
makeTextColumn('Часовой пояс', 'timeZoneId'), makeTextColumn('Часовой пояс', 'timeZoneId'),
makeTextColumn('Скважина', 'well'), makeTextColumn('Скважина', 'well'),
makeTextColumn('Куст', 'cluster'), makeTextColumn('Куст', 'cluster'),
@ -115,14 +115,10 @@ const TelemetryController = memo(() => {
pagination={defaultPagination} pagination={defaultPagination}
dataSource={filteredTelemetryData} dataSource={filteredTelemetryData}
tableName={'admin_telemetry_controller'} tableName={'admin_telemetry_controller'}
scroll={{ x: true }}
/> />
</> </>
) )
}) })
export default wrapPrivateComponent(TelemetryController, { export default withPermissions(TelemetryController)
requirements: [],
title: 'Просмотр',
route: 'viewer',
key: 'viewer',
})

View File

@ -1,13 +1,8 @@
import { Layout } from 'antd'
import { memo, useMemo } from 'react' import { memo, useMemo } from 'react'
import { Navigate, Route, Routes } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import { RootPathContext, useRootPath } from '@asb/context' import { RootPathContext, useRootPath } from '@asb/context'
import { PrivateMenu } from '@components/Private' import { withPermissions } from '@utils'
import { NoAccessComponent, wrapPrivateComponent } from '@utils'
import TelemetryViewer from './TelemetryViewer'
import TelemetryMerger from './TelemetryMerger'
const Telemetry = memo(() => { const Telemetry = memo(() => {
const root = useRootPath() const root = useRootPath()
@ -15,30 +10,9 @@ const Telemetry = memo(() => {
return ( return (
<RootPathContext.Provider value={rootPath}> <RootPathContext.Provider value={rootPath}>
<Layout> <Outlet />
<PrivateMenu>
<PrivateMenu.Link content={TelemetryViewer} />
<PrivateMenu.Link content={TelemetryMerger} />
</PrivateMenu>
<Layout>
<Layout.Content className={'site-layout-background'}>
<Routes>
<Route index element={<Navigate to={TelemetryViewer.route} replace />} />
<Route path={'*'} element={<NoAccessComponent />} />
<Route path={TelemetryViewer.route} element={<TelemetryViewer />} />
<Route path={TelemetryMerger.route} element={<TelemetryMerger />} />
</Routes>
</Layout.Content>
</Layout>
</Layout>
</RootPathContext.Provider> </RootPathContext.Provider>
) )
}) })
export default wrapPrivateComponent(Telemetry, { export default withPermissions(Telemetry, ['AdminTelemetry.get'])
requirements: ['AdminTelemetry.get'],
title: 'Телеметрия',
key: 'telemetry',
route: 'telemetry/*',
})

View File

@ -17,7 +17,7 @@ import { invokeWebApiWrapperAsync } from '@components/factory'
import { AdminCompanyService, AdminUserRoleService, AdminUserService } from '@api' import { AdminCompanyService, AdminUserRoleService, AdminUserService } from '@api'
import { createLoginRules, nameRules, phoneRules, emailRules } from '@utils/validationRules' import { createLoginRules, nameRules, phoneRules, emailRules } from '@utils/validationRules'
import { makeTextOnFilter, makeTextFilters, makeArrayOnFilter } from '@utils/filters' import { makeTextOnFilter, makeTextFilters, makeArrayOnFilter } from '@utils/filters'
import { arrayOrDefault, wrapPrivateComponent } from '@utils' import { arrayOrDefault, withPermissions } from '@utils'
import RoleTag from './RoleTag' import RoleTag from './RoleTag'
@ -115,7 +115,6 @@ const UserController = memo(() => {
setColumns([ setColumns([
makeTextColumn('Логин', 'login', null, null, null, { makeTextColumn('Логин', 'login', null, null, null, {
editable: true,
formItemRules: [ formItemRules: [
{ required: true }, { required: true },
...createLoginRules, ...createLoginRules,
@ -130,41 +129,34 @@ const UserController = memo(() => {
], ],
}), }),
makeTextColumn('Фамилия', 'surname', filters.surname, null, null, { makeTextColumn('Фамилия', 'surname', filters.surname, null, null, {
editable: true,
formItemRules: [{ required: true }, ...nameRules], formItemRules: [{ required: true }, ...nameRules],
filterSearch: true, filterSearch: true,
onFilter: makeTextOnFilter('surname'), onFilter: makeTextOnFilter('surname'),
}), }),
makeTextColumn('Имя', 'name', filters.name, null, null, { makeTextColumn('Имя', 'name', filters.name, null, null, {
editable: true,
formItemRules: nameRules, formItemRules: nameRules,
filterSearch: true, filterSearch: true,
onFilter: makeTextOnFilter('name'), onFilter: makeTextOnFilter('name'),
}), }),
makeTextColumn('Отчество', 'patronymic', filters.partonymic, null, null, { makeTextColumn('Отчество', 'patronymic', filters.partonymic, null, null, {
editable: true,
formItemRules: nameRules, formItemRules: nameRules,
filterSearch: true, filterSearch: true,
onFilter: makeTextOnFilter('patronymic'), onFilter: makeTextOnFilter('patronymic'),
}), }),
makeTextColumn('E-mail', 'email', filters.email, null, null, { makeTextColumn('E-mail', 'email', filters.email, null, null, {
editable: true,
formItemRules: [{ required: true }, ...emailRules], formItemRules: [{ required: true }, ...emailRules],
filterSearch: true, filterSearch: true,
onFilter: makeTextOnFilter('email'), onFilter: makeTextOnFilter('email'),
}), }),
makeTextColumn('Номер телефона', 'phone', null, null, null, { makeTextColumn('Номер телефона', 'phone', null, null, null, {
editable: true,
formItemRules: phoneRules, formItemRules: phoneRules,
}), }),
makeTextColumn('Должность', 'position', null, null, null, { editable: true }), makeTextColumn('Должность', 'position', null, null, null),
makeTextColumn('Роли', 'roleNames', roleFilters, null, rolesRender, { makeTextColumn('Роли', 'roleNames', roleFilters, null, rolesRender, {
editable: true,
input: <RoleTag roles={roles} />, input: <RoleTag roles={roles} />,
onFilter: makeArrayOnFilter('roleNames'), onFilter: makeArrayOnFilter('roleNames'),
}), }),
makeSelectColumn('Компания', 'idCompany', companies, '--', { makeSelectColumn('Компания', 'idCompany', companies, '--', {
editable: true,
sorter: makeNumericSorter('idCompany'), sorter: makeNumericSorter('idCompany'),
}) })
]) ])
@ -214,6 +206,7 @@ const UserController = memo(() => {
buttonsWidth={120} buttonsWidth={120}
pagination={defaultPagination} pagination={defaultPagination}
tableName={'admin_user_controller'} tableName={'admin_user_controller'}
scroll={{ x: true }}
/> />
</LoaderPortal> </LoaderPortal>
<ChangePassword <ChangePassword
@ -226,8 +219,4 @@ const UserController = memo(() => {
) )
}) })
export default wrapPrivateComponent(UserController, { export default withPermissions(UserController, ['AdminUser.get', 'AdminCompany.get', 'AdminUserRole.get'])
requirements: ['AdminUser.get', 'AdminCompany.get', 'AdminUserRole.get'],
title: 'Пользователи',
route: 'user',
})

View File

@ -3,7 +3,7 @@ import { Input } from 'antd'
import { invokeWebApiWrapperAsync } from '@components/factory' import { invokeWebApiWrapperAsync } from '@components/factory'
import { defaultPagination, makeColumn, makeDateSorter, makeStringSorter, Table } from '@components/Table' import { defaultPagination, makeColumn, makeDateSorter, makeStringSorter, Table } from '@components/Table'
import { arrayOrDefault, formatDate, wrapPrivateComponent } from '@utils' import { arrayOrDefault, formatDate, withPermissions } from '@utils'
import { RequestTrackerService } from '@api' import { RequestTrackerService } from '@api'
const logRecordCount = 1000 const logRecordCount = 1000
@ -59,13 +59,10 @@ const VisitLog = memo(() => {
dataSource={filteredLogData} dataSource={filteredLogData}
pagination={defaultPagination} pagination={defaultPagination}
tableName={'visit_log'} tableName={'visit_log'}
scroll={{ x: true }}
/> />
</> </>
) )
}) })
export default wrapPrivateComponent(VisitLog, { export default withPermissions(VisitLog, ['RequestTracker.get'])
requirements: ['RequestTracker.get'],
title: 'Журнал посещений',
route: 'visit_log',
})

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