forked from ddrilling/asb_cloud_front
Compare commits
267 Commits
feature/bu
...
dev
Author | SHA1 | Date | |
---|---|---|---|
|
04bd79d563 | ||
|
4b0adeeb7b | ||
|
3b0182216e | ||
|
269b59560a | ||
|
c867c51bf5 | ||
|
5d64102a7c | ||
|
d0c2774774 | ||
|
de58c81e87 | ||
|
860d74f2db | ||
ed43ebb082 | |||
44104f672b | |||
fce6c1909e | |||
c0b5f82ad2 | |||
8d6f5ac1a5 | |||
bef281feb8 | |||
a79cac9d51 | |||
878ab921c1 | |||
8154f2f0ba | |||
94707b3c9a | |||
8825c4c26c | |||
0e25e10785 | |||
b063b5dcd9 | |||
969edda933 | |||
3396050fae | |||
d36cd1acbd | |||
77d65be601 | |||
16fec4e42f | |||
923d469a86 | |||
8f52066bce | |||
bfd1e51cfa | |||
0e63d93fa7 | |||
0ef6d67772 | |||
f4adb528ca | |||
7af33d702d | |||
d235b01c80 | |||
5af996f9e5 | |||
65b9ede580 | |||
08fcf2736e | |||
28a0962793 | |||
8f98cc066c | |||
4b20a44d88 | |||
043f73fde3 | |||
1a737b6afe | |||
17d7b7c41d | |||
4dd57aff98 | |||
ebe3a50fbe | |||
dc0f80fee5 | |||
cb8be79274 | |||
ee289cc619 | |||
e430cdd5b4 | |||
540da341da | |||
20b271d91e | |||
259e2e4be8 | |||
0cbd9559f2 | |||
40d3a77c1c | |||
7731dfe8e7 | |||
7acb7ce2b2 | |||
0aeef42811 | |||
5eb66e5fbc | |||
44afd5f1f0 | |||
a2d641abdd | |||
51ac260c74 | |||
16fb37910f | |||
de7e8fd259 | |||
a9aeeb6da3 | |||
fe52116a9b | |||
d56810700f | |||
e39ce8d410 | |||
bd81aa0401 | |||
59b1d49286 | |||
871e71e777 | |||
fc91cfc6ff | |||
3216a90af3 | |||
11a632c246 | |||
b2c34d07a9 | |||
ec2513b4a0 | |||
9c9fc63bc2 | |||
cb17c6868c | |||
b2d241a8e7 | |||
cc1c6a0661 | |||
ac9b4d6c0d | |||
18f789c980 | |||
6375db5a0b | |||
aa0fafb7a1 | |||
bc73490029 | |||
|
1ec74e92f1 | ||
|
a2ca2c3251 | ||
|
a04090db57 | ||
|
f82b80d0d0 | ||
9c85b1e229 | |||
|
4b63c26306 | ||
|
33b6a2012a | ||
bf2d6d6d36 | |||
683ccb1c03 | |||
|
a0e488389b | ||
|
45c63317a2 | ||
|
a4db41fdd4 | ||
17ccecb2dd | |||
c8753378eb | |||
685191484a | |||
e773943b61 | |||
|
11cb245cf5 | ||
|
6024c7ca64 | ||
|
811d48a47b | ||
|
ccbc0e1938 | ||
|
bea165d76e | ||
|
b7317a02d5 | ||
|
d7669a2317 | ||
|
dcd37177a1 | ||
|
2072ac2072 | ||
|
c3d53284bd | ||
|
879fea41a5 | ||
|
26222104b7 | ||
|
0948c2a602 | ||
44543fec31 | |||
0797c20ff4 | |||
2bfdd4385f | |||
121cb83d83 | |||
e852ede73c | |||
e52013a685 | |||
1337656828 | |||
1b9db1c35a | |||
|
a1f1ce1915 | ||
|
5dd1fc8258 | ||
|
53a1d33a55 | ||
|
1a4189901a | ||
|
91556aaf81 | ||
|
85b17ccf66 | ||
|
530001d110 | ||
|
af031d94d7 | ||
|
1a02b39e52 | ||
|
d14513e249 | ||
|
718d32f2b9 | ||
|
fc8b351b7c | ||
|
93e6d2171d | ||
|
847cfce2b6 | ||
|
c9885e4603 | ||
|
fee0849ea4 | ||
|
bd8962df26 | ||
|
60118f9327 | ||
|
9c2b0ecd26 | ||
|
411b79ee60 | ||
|
ddc6f7840d | ||
|
a574b78cdb | ||
|
83666424ad | ||
|
d43e7d3da9 | ||
|
116de6e912 | ||
|
8c2c1b7913 | ||
|
6455be0891 | ||
|
1b23ee3437 | ||
|
978a26e455 | ||
|
c1dced00f7 | ||
|
3d98a2c6df | ||
|
bbf15c1f35 | ||
|
38004088a7 | ||
|
61d71899db | ||
|
836bcd583d | ||
|
70a04ad228 | ||
|
7f2d337b4f | ||
|
39c1289d32 | ||
|
336fe6e0d4 | ||
|
2aca41da83 | ||
|
eb9d85c1a4 | ||
|
799fff7c0e | ||
|
443f14c0a8 | ||
|
c33674c6c5 | ||
|
d3fd851e20 | ||
|
6a97da855d | ||
|
321900da89 | ||
|
84204ab146 | ||
|
be7e76acf7 | ||
|
74b309ee87 | ||
|
8db523b6de | ||
|
34c9b81e77 | ||
|
b02b828cb6 | ||
|
b2feb95e82 | ||
|
01f499b85d | ||
|
a965a9b693 | ||
|
e147f44032 | ||
|
66d801fb80 | ||
|
9d5c38f984 | ||
|
38fa8ffbeb | ||
|
c82fec8f60 | ||
|
e0f8583f99 | ||
|
1f8bf12821 | ||
|
0ce979fdc8 | ||
|
b74d6d1e4f | ||
|
f038e5e36e | ||
|
95950458c8 | ||
|
292d4e0ef0 | ||
|
564ea43a80 | ||
|
d49a6e1df3 | ||
|
9017ddf835 | ||
|
6588e3accb | ||
|
664e3aa57a | ||
|
6c52a091c9 | ||
|
e610ab2768 | ||
|
ea4dd1dfe0 | ||
|
6a74544901 | ||
|
922a329e76 | ||
|
eb8cf1f1d4 | ||
|
e7306eae79 | ||
|
bd7c2842c5 | ||
|
c387ded5ce | ||
|
fc5b7d217a | ||
|
a22b043af9 | ||
|
567e6c4510 | ||
|
c3200427e4 | ||
|
77cf9ab794 | ||
|
3045bf10e6 | ||
|
d792f75861 | ||
|
9d0ddc6f21 | ||
|
0e6a0888d8 | ||
|
cc1eb706ea | ||
|
2a490243d7 | ||
|
bc8dbaf113 | ||
|
2dbc9720bb | ||
|
bd803c2f46 | ||
|
30a73184f4 | ||
|
a41f5d4bf5 | ||
|
fbe86e23f6 | ||
|
11187fa937 | ||
|
c8050b91e5 | ||
|
aceedd8dee | ||
|
1dd19399e5 | ||
|
00f7d6e9da | ||
|
d9000384ca | ||
|
1a86f1727a | ||
|
3f7fd3c0c0 | ||
b44d796ad0 | |||
|
20f002111d | ||
|
0e432df77c | ||
|
ccd2158f36 | ||
|
1e27317ea1 | ||
|
83d535c305 | ||
|
1240c15401 | ||
|
1fb4846dbe | ||
|
f25d351349 | ||
|
aaf0f7b523 | ||
|
9599edf8f2 | ||
|
12cb389a0b | ||
|
a35c09e245 | ||
|
1de4e8d909 | ||
|
8ade639e0e | ||
|
cf27402810 | ||
|
48ee08acab | ||
cc37bccacf | |||
0a18b79416 | |||
539030e9fd | |||
|
f6540ff804 | ||
|
a5e94816e5 | ||
|
983155e0fa | ||
|
f6046770e7 | ||
|
cf34baa0a3 | ||
|
0148fed514 | ||
|
423e575483 | ||
|
5e5da5c4a9 | ||
|
c507f77394 | ||
|
934eab2f3c | ||
|
a8613e6b75 | ||
|
15333510fd | ||
|
fc6646eb80 | ||
|
5511c06410 | ||
|
310253133d | ||
cda32551ed | |||
|
cefcf9a75e | ||
|
354d6945d7 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -11,8 +11,10 @@
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
# build directories
|
||||
/build
|
||||
/dev_build
|
||||
/prod_build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
15
.vscode/settings.json
vendored
15
.vscode/settings.json
vendored
@ -1,6 +1,17 @@
|
||||
{
|
||||
"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
191
CODE_STANDART.md
Normal 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` - для компонентов, применяющихся как виджеты в дашбордах.
|
68
README.md
68
README.md
@ -7,7 +7,7 @@
|
||||
|
||||
Установка выполняется одной командой:
|
||||
```bash
|
||||
npm i
|
||||
npm ci
|
||||
```
|
||||
|
||||
## 2. Автогенерация сервисов
|
||||
@ -19,12 +19,12 @@ npm i
|
||||
|
||||
Если сервер запущен на текущей машине достаточно написать:
|
||||
```bash
|
||||
npm run update_openapi
|
||||
npm run oul
|
||||
```
|
||||
|
||||
Для получения сервисов с основного сервера:
|
||||
```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-адрес | Описание |
|
||||
|:-|:-|
|
||||
| 127.0.0.1:5000 | Локальный адрес вашей машины (привязан к `update_openapi`) |
|
||||
| 192.168.1.70:5000 | Локальный адрес development-сервера (привязан к `update_openapi_server`) |
|
||||
| 46.146.209.148:89 | Внешний адрес development-сервера |
|
||||
| 46.146.209.148 | Внешний адрес production-сервера |
|
||||
| IP-адрес | Команда | Описание |
|
||||
|:-------------------------|:--------|:------------------------------------|
|
||||
| 127.0.0.1:5000 | oul | Локальный адрес вашей машины |
|
||||
| 192.168.1.113:5000 | oud | Локальный адрес development-сервера |
|
||||
| 46.146.207.184:80 | oug_dev | Внешний адрес development-сервера |
|
||||
| cloud.digitaldrilling.ru | oug | Внешний адрес production-сервера |
|
||||
|
||||
## 3. Компиляция production-версии приложения
|
||||
После выполнения вышеописанных пунктов приложение готово к компиляции.
|
||||
@ -60,3 +60,53 @@ npm run build
|
||||
```bash
|
||||
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>
|
||||
```
|
||||
|
16014
package-lock.json
generated
16014
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@ -11,20 +11,24 @@
|
||||
"react": "^18.1.0",
|
||||
"react-dom": "^18.1.0",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"rxjs": "^7.5.5",
|
||||
"usehooks-ts": "^2.6.0",
|
||||
"web-vitals": "^2.1.4"
|
||||
"rxjs": "^7.5.5"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "webpack-dev-server --mode=development --open --hot",
|
||||
"build": "webpack --mode=production",
|
||||
"test": "jest",
|
||||
"build": "webpack --env=\"ENV=prod\"",
|
||||
"dev_build": "webpack --env=\"ENV=dev\"",
|
||||
"prod_build": "webpack --env=\"ENV=prod\"",
|
||||
|
||||
"start": "webpack-dev-server --env=\"ENV=dev\" --open --hot",
|
||||
"prod": "webpack-dev-server --env=\"ENV=prod\" --open --hot",
|
||||
"dev": "webpack-dev-server --env=\"ENV=dev\" --open --hot",
|
||||
|
||||
"oul": "npx openapi -i http://127.0.0.1:5000/swagger/v1/swagger.json -o src/services/api",
|
||||
"oud": "npx openapi -i http://192.168.1.70:5000/swagger/v1/swagger.json -o src/services/api",
|
||||
"oug": "npx openapi -i http://46.146.209.148/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"
|
||||
"oud": "npx openapi -i http://192.168.1.10:5000/swagger/v1/swagger.json -o src/services/api",
|
||||
"oug": "npx openapi -i https://cloud.digitaldrilling.ru/swagger/v1/swagger.json -o src/services/api",
|
||||
"oug_dev": "npx openapi -i http://46.146.207.184/swagger/v1/swagger.json -o src/services/api"
|
||||
},
|
||||
"proxy": "http://46.146.209.148:89",
|
||||
"proxy": "http://46.146.207.184",
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
@ -83,24 +87,30 @@
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"babel-jest": "^28.1.0",
|
||||
"babel-loader": "^8.2.5",
|
||||
"colors": "^1.4.0",
|
||||
"css-loader": "^6.7.1",
|
||||
"css-minimizer-webpack-plugin": "^4.2.0",
|
||||
"extract-loader": "^5.1.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"interpolate-html-plugin": "^4.0.0",
|
||||
"jest": "^28.1.0",
|
||||
"less": "^4.1.3",
|
||||
"less-loader": "^11.0.0",
|
||||
"mini-css-extract-plugin": "^2.6.1",
|
||||
"openapi-typescript": "^5.4.0",
|
||||
"openapi-typescript-codegen": "^0.23.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"react-test-renderer": "^18.1.0",
|
||||
"source-map-loader": "^3.0.1",
|
||||
"style-loader": "^3.3.1",
|
||||
"terser-webpack-plugin": "^5.3.6",
|
||||
"ts-loader": "^9.3.0",
|
||||
"typescript": "^4.7.4",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^5.73.0",
|
||||
"webpack-cli": "^4.9.2",
|
||||
"webpack-dev-server": "^4.9.1"
|
||||
"webpack-dev-server": "^4.9.1",
|
||||
"webpack-merge": "^5.8.0"
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,7 @@
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="white" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="black" />
|
||||
<meta name="description" content="Онлайн мониторинг процесса бурения в реальном времени в офисе заказчика" />
|
||||
<title>АСБ Vision</title>
|
||||
<title>DDrilling</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"short_name": "ЕЦП",
|
||||
"name": "Единая Цифровая Платформа",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
|
63
src/App.tsx
63
src/App.tsx
@ -1,50 +1,57 @@
|
||||
import { BrowserRouter as Router, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { memo } from 'react'
|
||||
import { ConfigProvider } from 'antd'
|
||||
import locale from 'antd/lib/locale/ru_RU'
|
||||
import { lazy, memo, Suspense } from 'react'
|
||||
|
||||
import { RootPathContext } from '@asb/context'
|
||||
import { getUserToken, NoAccessComponent } from '@utils'
|
||||
import { OpenAPI } from '@api'
|
||||
import SuspenseFallback from '@components/SuspenseFallback'
|
||||
import { NoAccessComponent } from '@utils'
|
||||
|
||||
import AdminPanel from '@pages/AdminPanel'
|
||||
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/pages/App.less'
|
||||
|
||||
import '@styles/App.less'
|
||||
const UserOutlet = lazy(() => import('@components/outlets/UserOutlet'))
|
||||
const DepositsOutlet = lazy(() => import('@components/outlets/DepositsOutlet'))
|
||||
const LayoutPortal = lazy(() => import('@components/LayoutPortal'))
|
||||
|
||||
//OpenAPI.BASE = 'http://localhost:3000'
|
||||
OpenAPI.TOKEN = async () => getUserToken() ?? ''
|
||||
OpenAPI.HEADERS = {'Content-Type': 'application/json'}
|
||||
const Login = lazy(() => import('@pages/public/Login'))
|
||||
const Register = lazy(() => import('@pages/public/Register'))
|
||||
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(() => (
|
||||
<ConfigProvider locale={locale}>
|
||||
<RootPathContext.Provider value={''}>
|
||||
<Suspense fallback={<SuspenseFallback style={{ minHeight: '100vh' }} />}>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route index element={<Navigate to={Deposit.getKey()} replace />} />
|
||||
<Route index element={<Navigate to={'deposit'} replace />} />
|
||||
<Route path={'*'} element={<NoAccessComponent />} />
|
||||
|
||||
{/* Public pages */}
|
||||
<Route path={Login.route} element={<Login />} />
|
||||
<Route path={Register.route} element={<Register />} />
|
||||
|
||||
{/* Admin pages */}
|
||||
<Route path={AdminPanel.route} element={<AdminPanel />} />
|
||||
<Route path={'/login'} element={<Login />} />
|
||||
<Route path={'/register'} element={<Register />} />
|
||||
|
||||
{/* User pages */}
|
||||
<Route path={Deposit.route} element={<Deposit />} />
|
||||
<Route path={Cluster.route} element={<Cluster />} />
|
||||
<Route path={Well.route} element={<Well />} />
|
||||
<Route path={FileDownload.route} element={<FileDownload />} />
|
||||
<Route element={<UserOutlet />}>
|
||||
<Route path={'/file_download/:idFile/*'} 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>
|
||||
</Router>
|
||||
</Suspense>
|
||||
</RootPathContext.Provider>
|
||||
</ConfigProvider>
|
||||
))
|
||||
|
||||
export default App
|
||||
|
@ -2,8 +2,8 @@ import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { Rule } from 'antd/lib/form'
|
||||
import { Form, Input, Modal, FormProps } from 'antd'
|
||||
|
||||
import { useUser } from '@asb/context'
|
||||
import { AuthService, UserDto } from '@api'
|
||||
import { getUserId, getUserLogin } from '@utils'
|
||||
import { passwordRules, createPasswordRules } from '@utils/validationRules'
|
||||
|
||||
import LoaderPortal from './LoaderPortal'
|
||||
@ -31,7 +31,8 @@ export const ChangePassword = memo<ChangePasswordProps>(({ user, visible, onCanc
|
||||
const [showLoader, setShowLoader] = useState<boolean>(false)
|
||||
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()
|
||||
|
||||
@ -51,7 +52,7 @@ export const ChangePassword = memo<ChangePasswordProps>(({ user, visible, onCanc
|
||||
},
|
||||
setShowLoader,
|
||||
`Не удалось сменить пароль пользователя ${userData.login}`,
|
||||
'Смена пароля пользователя'
|
||||
{ actionName: 'Смена пароля пользователя' }
|
||||
), [userData, onOk])
|
||||
|
||||
return (
|
||||
@ -63,7 +64,7 @@ export const ChangePassword = memo<ChangePasswordProps>(({ user, visible, onCanc
|
||||
{user && <> (<UserView user={user} />)</>}
|
||||
</>
|
||||
)}
|
||||
visible={visible}
|
||||
open={visible}
|
||||
onCancel={onModalCancel}
|
||||
onOk={() => form.submit()}
|
||||
okText={'Сохранить'}
|
||||
|
129
src/components/ColorPicker.tsx
Normal file
129
src/components/ColorPicker.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Input, Popover, Slider } from 'antd'
|
||||
import { CopyOutlined } from '@ant-design/icons'
|
||||
|
||||
import { copyToClipboard } from './factory'
|
||||
|
||||
import '@styles/components/color_picker.less'
|
||||
|
||||
export class Color {
|
||||
public r: number
|
||||
public g: number
|
||||
public b: number
|
||||
public a: number = 1
|
||||
|
||||
public constructor(color: Color | string)
|
||||
public constructor(r: number, g: number, b: number, a?: number)
|
||||
|
||||
constructor(...args: any[]) {
|
||||
let out
|
||||
if (args[0] instanceof Color) {
|
||||
out = args[0]
|
||||
} else if (typeof args[0] === 'string') {
|
||||
out = Color.parseToObject(args[0])
|
||||
} else if (typeof args[0] === 'number') {
|
||||
out = { r: args[0], g: args[1], b: args[2], a: args[3] ?? 1 }
|
||||
} else throw new Error('Некорректные аргументы')
|
||||
this.r = out.r
|
||||
this.g = out.g
|
||||
this.b = out.b
|
||||
this.a = out.a
|
||||
}
|
||||
|
||||
public static parse(str: string): Color {
|
||||
const out = Color.parseToObject(str)
|
||||
return new Color(out.r, out.g, out.b, out.a)
|
||||
}
|
||||
|
||||
private static parseToObject(str: string) {
|
||||
let rgb: number[] = []
|
||||
let a: number = 1
|
||||
if (str.startsWith('rgb')) {
|
||||
const parts = str.replaceAll(/\s/g, '').match(/rgba?\((\d+),(\d+),(\d)+(?:,([\d.]+))?\)/)
|
||||
if (parts) {
|
||||
rgb = parts.slice(1, 4).map((v) => Math.min(0, Math.max(parseInt(v), 255)))
|
||||
if (parts[4]) a = parseFloat(`0${parts[4]}`)
|
||||
}
|
||||
} else if (str.startsWith('#')) {
|
||||
const parts = str.slice(1)
|
||||
let rgba: string[] | null = parts.length > 5 ? parts.match(/.{1,2}/g) : [...parts]
|
||||
if (rgba) {
|
||||
rgb = rgba.slice(0, 3).map((v) => parseInt(v, 16))
|
||||
if (rgba[3]) a = parseInt(rgba[3], 16) / 255
|
||||
}
|
||||
}
|
||||
if (rgb.length < 3)
|
||||
throw new Error('Некорректная строка')
|
||||
return { r: rgb[0], g: rgb[1], b: rgb[2], a }
|
||||
}
|
||||
|
||||
public toString = () => this.toHexString()
|
||||
public toCssString = () => `rgba(${this.r},${this.g},${this.b},${this.a})`
|
||||
public toHexString() {
|
||||
const a = Math.floor(this.a * 255)
|
||||
let out = '#' + [this.r, this.g, this.b].map((v) => v.toString(16).padStart(2, '0')).join('')
|
||||
if (a < 255) out += a.toString(16).padStart(2, '0')
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
export type ColorPickerProps = {
|
||||
value?: string | Color
|
||||
onChange?: (value: Color) => void
|
||||
size?: number | string
|
||||
id?: string
|
||||
}
|
||||
|
||||
const makeChangeColor = (set: React.Dispatch<React.SetStateAction<Color>>, accessor: 'r' | 'g' | 'b' | 'a') => (value: number) => set((prev: Color) => {
|
||||
const out = new Color(prev)
|
||||
out[accessor] = value
|
||||
return out
|
||||
})
|
||||
|
||||
export const ColorPicker = memo<ColorPickerProps>(({ value = '#AA33BB', onChange, size, ...other }) => {
|
||||
const [color, setColor] = useState<Color>(new Color(255, 255, 255))
|
||||
|
||||
useEffect(() => setColor(new Color(value)), [value])
|
||||
|
||||
const divStyle = useMemo(() => ({
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: color.toCssString(),
|
||||
}), [size, color])
|
||||
|
||||
const changeR = useMemo(() => makeChangeColor(setColor, 'r'), [])
|
||||
const changeG = useMemo(() => makeChangeColor(setColor, 'g'), [])
|
||||
const changeB = useMemo(() => makeChangeColor(setColor, 'b'), [])
|
||||
const changeA = useMemo(() => makeChangeColor(setColor, 'a'), [])
|
||||
|
||||
const onClose = useCallback((visible: boolean) => {
|
||||
if (!visible)
|
||||
onChange?.(color)
|
||||
}, [color, onChange])
|
||||
|
||||
const onCopyClick = useCallback(() => copyToClipboard(color.toHexString()), [color])
|
||||
|
||||
return (
|
||||
<Popover
|
||||
trigger={'click'}
|
||||
onOpenChange={onClose}
|
||||
content={(
|
||||
<div className={'asb-color-picker-content'}>
|
||||
<div className={'asb-color-picker-sliders'}>
|
||||
<Slider vertical min={0} max={255} defaultValue={color.r} onChange={changeR} />
|
||||
<Slider vertical min={0} max={255} defaultValue={color.g} onChange={changeG} />
|
||||
<Slider vertical min={0} max={255} defaultValue={color.b} onChange={changeB} />
|
||||
<Slider vertical min={0} max={1} step={0.01} defaultValue={color.a} onChange={changeA} />
|
||||
</div>
|
||||
<Input {...other} value={color.toHexString()} addonBefore={(
|
||||
<CopyOutlined onClick={onCopyClick} />
|
||||
)} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className={'asb-color-picker-preview'} style={divStyle}/>
|
||||
</Popover>
|
||||
)
|
||||
})
|
||||
|
||||
export default ColorPicker
|
@ -1,5 +1,5 @@
|
||||
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 { invokeWebApiWrapperAsync, notify } from './factory'
|
||||
@ -43,11 +43,9 @@ export type CopyUrlButtonProps = Omit<CopyUrlProps, 'children'> & ButtonProps
|
||||
export const CopyUrlButton = memo<CopyUrlButtonProps>(({ sendLoading, hideUnsupported, onCopy, ...other }) => {
|
||||
return (
|
||||
<CopyUrl sendLoading={sendLoading} hideUnsupported={hideUnsupported} onCopy={onCopy}>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
title={'Скопировать URL в буфер обмена'}
|
||||
{...other}
|
||||
/>
|
||||
<Tooltip title={'Скопировать URL в буфер обмена'}>
|
||||
<Button icon={<CopyOutlined />} {...other} />
|
||||
</Tooltip>
|
||||
</CopyUrl>
|
||||
)
|
||||
})
|
||||
|
@ -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>
|
||||
))
|
@ -2,7 +2,7 @@ import { memo, ReactNode } from 'react'
|
||||
import { Link, LinkProps } from 'react-router-dom'
|
||||
import { FileWordOutlined } from '@ant-design/icons'
|
||||
|
||||
import { FileInfoDto } from '@api'
|
||||
import { FileInfoDto, WellDto } from '@api'
|
||||
import { downloadFile } from './factory'
|
||||
|
||||
import { getLinkToFile } from '@pages/FileDownload'
|
||||
@ -13,6 +13,7 @@ export type DownloadLinkProps = LinkProps & {
|
||||
file?: FileInfoDto
|
||||
name?: string
|
||||
icon?: ReactNode
|
||||
well?: WellDto
|
||||
}
|
||||
|
||||
export const DownloadLink = memo<DownloadLinkProps>(({
|
||||
@ -20,6 +21,7 @@ export const DownloadLink = memo<DownloadLinkProps>(({
|
||||
file,
|
||||
name,
|
||||
icon = <FileWordOutlined />,
|
||||
well,
|
||||
...other
|
||||
}) => (
|
||||
<Link
|
||||
@ -30,7 +32,7 @@ export const DownloadLink = memo<DownloadLinkProps>(({
|
||||
to={getLinkToFile(file)}
|
||||
onClick={(e) => {
|
||||
if (file)
|
||||
downloadFile(file)
|
||||
downloadFile(file, well)
|
||||
e.preventDefault()
|
||||
return false
|
||||
}}
|
||||
|
165
src/components/FastRunMenu.tsx
Normal file
165
src/components/FastRunMenu.tsx
Normal 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
|
@ -22,7 +22,7 @@ export const Grid = memo<ComponentProps>(({ children, style, ...other }) => (
|
||||
</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 localCol = +col
|
||||
const localColSpan = colSpan ? colSpan - 1 : 0
|
||||
@ -32,12 +32,11 @@ export const GridItem = memo<GridItemProps>(({ children, row, col, rowSpan, colS
|
||||
gridColumnEnd: localCol + localColSpan,
|
||||
gridRowStart: localRow,
|
||||
gridRowEnd: localRow + localRowSpan,
|
||||
padding: '4px',
|
||||
...style,
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={gridItemStyle} {...other}>
|
||||
<div className={`asb-grid-item ${className || ''}`} style={gridItemStyle} {...other}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
@ -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
|
@ -1,31 +0,0 @@
|
||||
import { memo, ReactNode } from 'react'
|
||||
import { Layout, LayoutProps } from 'antd'
|
||||
|
||||
import PageHeader from '@components/PageHeader'
|
||||
import WellTreeSelector from '@components/selectors/WellTreeSelector'
|
||||
import { wrapPrivateComponent } from '@utils'
|
||||
|
||||
export type LayoutPortalProps = LayoutProps & {
|
||||
title?: ReactNode
|
||||
noSheet?: boolean
|
||||
showSelector?: boolean
|
||||
}
|
||||
|
||||
const _LayoutPortal = memo<LayoutPortalProps>(({ title, noSheet, showSelector, ...props }) => (
|
||||
<Layout.Content>
|
||||
<PageHeader title={title}>
|
||||
<WellTreeSelector show={showSelector} />
|
||||
</PageHeader>
|
||||
<Layout>
|
||||
{noSheet ? props.children : (
|
||||
<Layout.Content className={'site-layout-background sheet'} {...props}/>
|
||||
)}
|
||||
</Layout>
|
||||
</Layout.Content>
|
||||
))
|
||||
|
||||
export const LayoutPortal = wrapPrivateComponent(_LayoutPortal, {
|
||||
requirements: ['Deposit.get'],
|
||||
})
|
||||
|
||||
export default LayoutPortal
|
@ -1,5 +0,0 @@
|
||||
export * from './AdminLayoutPortal'
|
||||
export * from './LayoutPortal'
|
||||
|
||||
export type { AdminLayoutPortalProps } from './AdminLayoutPortal'
|
||||
export type { LayoutPortalProps } from './LayoutPortal'
|
139
src/components/LayoutPortal.tsx
Normal file
139
src/components/LayoutPortal.tsx
Normal 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
|
@ -3,14 +3,22 @@ import { HTMLAttributes } from 'react'
|
||||
import { Loader } from '@components/icons'
|
||||
|
||||
type LoaderPortalProps = HTMLAttributes<HTMLDivElement> & {
|
||||
/** Показать ли загрузку */
|
||||
show?: boolean,
|
||||
/** Затемнять ли дочерний блок */
|
||||
fade?: boolean,
|
||||
/** Параметры спиннера */
|
||||
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-content'}>{children}</div>
|
||||
<div className={`loader-content${fillContent ? ' loader-content-fill' : ''}`}>{children}</div>
|
||||
{show && fade && <div className={'loader-fade'}/>}
|
||||
{show && <div className={'loader-overlay'}><Loader {...spinnerProps} /></div>}
|
||||
</div>
|
||||
|
45
src/components/MenuBreadcrumb.tsx
Normal file
45
src/components/MenuBreadcrumb.tsx
Normal 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>
|
||||
))
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
import { memo } from 'react'
|
||||
import { Layout } from 'antd'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { BasicProps } from 'antd/lib/layout/layout'
|
||||
|
||||
import { headerHeight } from '@utils'
|
||||
import { UserMenu } from './UserMenu'
|
||||
|
||||
import Logo from '@images/Logo'
|
||||
|
||||
export type PageHeaderProps = BasicProps & {
|
||||
title?: string
|
||||
isAdmin?: boolean
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const PageHeader: React.FC<PageHeaderProps> = memo(({ title = 'Мониторинг', isAdmin, children, ...other }) => {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Layout.Header className={'header'} {...other}>
|
||||
<Link to={'/'} style={{ height: headerHeight }}>
|
||||
<Logo />
|
||||
</Link>
|
||||
<h1 className={'title'}>{title}</h1>
|
||||
{children}
|
||||
<UserMenu isAdmin={isAdmin} />
|
||||
</Layout.Header>
|
||||
</Layout>
|
||||
)
|
||||
})
|
||||
|
||||
export default PageHeader
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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'
|
95
src/components/PrivateMenu.tsx
Normal file
95
src/components/PrivateMenu.tsx
Normal 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} />
|
||||
})
|
0
src/pages/SuspenseFallback.tsx → src/components/SuspenseFallback.tsx
Executable file → Normal file
0
src/pages/SuspenseFallback.tsx → src/components/SuspenseFallback.tsx
Executable file → Normal file
@ -1,26 +1,53 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Key, ReactNode } from 'react'
|
||||
|
||||
import { formatDate } from '@utils'
|
||||
|
||||
import makeColumn, { columnPropsOther } from '.'
|
||||
import { DatePickerWrapper, makeDateSorter } from '..'
|
||||
import { makeColumn, ColumnProps, SorterMethod } from '.'
|
||||
import { DatePickerWrapper, getObjectByDeepKey } from '..'
|
||||
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,
|
||||
key: string,
|
||||
utc?: boolean,
|
||||
format?: string,
|
||||
other?: columnPropsOther,
|
||||
other?: ColumnProps<T>,
|
||||
pickerOther?: DatePickerWrapperProps,
|
||||
) => makeColumn(title, key, {
|
||||
) => makeColumn<T>(title, key, {
|
||||
editable: true,
|
||||
...other,
|
||||
render: (date) => (
|
||||
<div className={'text-align-r-container'}>
|
||||
<span>{formatDate(date, utc, format) ?? '-'}</span>
|
||||
</div>
|
||||
),
|
||||
sorter: makeDateSorter(key),
|
||||
sorter: makeDateSorter<T>(key),
|
||||
input: <DatePickerWrapper {...pickerOther} />,
|
||||
})
|
||||
|
||||
|
@ -1,37 +1,27 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Key, ReactNode } from 'react'
|
||||
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'
|
||||
export { makeTimeColumn } from './time'
|
||||
export {
|
||||
RegExpIsFloat,
|
||||
makeNumericRender,
|
||||
makeNumericColumn,
|
||||
makeNumericColumnOptions,
|
||||
makeNumericColumnPlanFact,
|
||||
makeNumericStartEnd,
|
||||
makeNumericMinMax,
|
||||
makeNumericAvgRange
|
||||
} 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'
|
||||
import { DataSet } from '../Table'
|
||||
import { OmitExtends } from '@utils/types'
|
||||
|
||||
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 RenderMethod<T = any> = (value: T, dataset?: DataType<T>, index?: number) => ReactNode
|
||||
export type SorterMethod<T = any> = (a?: DataType<T> | null, b?: DataType<T> | null) => number
|
||||
export type RenderMethod<T = any, DT = DataSet<T>> = (value: T | undefined, dataset: DT, index: number) => ReactNode | RenderedCell<T> | undefined
|
||||
export type SorterMethod<T = any, DT = DataSet<T> | null | undefined> = (a: DT, b: DT) => number
|
||||
export type FilterMethod<T = any, DT = DataSet<T>> = (value: string | number | T | undefined, record: DT) => boolean
|
||||
|
||||
export type columnPropsOther<T = any> = ColumnProps<T> & {
|
||||
export type FilterGenerator<T, DT = DataSet<T>> = (key: Key) => FilterMethod<T, DT>
|
||||
|
||||
export type ColumnProps<T = any> = OmitExtends<{
|
||||
// редактируемая колонка
|
||||
editable?: boolean
|
||||
// react компонента редактора
|
||||
@ -45,13 +35,16 @@ export type columnPropsOther<T = any> = ColumnProps<T> & {
|
||||
// дефолтное значение при добавлении новой строки
|
||||
initialValue?: string | number
|
||||
|
||||
onFilter?: FilterMethod<T>
|
||||
sorter?: SorterMethod<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,
|
||||
key: key,
|
||||
dataIndex: key,
|
||||
render: (value: T) => value,
|
||||
...other,
|
||||
})
|
||||
|
||||
|
@ -1,17 +1,25 @@
|
||||
import { InputNumber } from 'antd'
|
||||
import { ReactNode } from 'react'
|
||||
import { Key, ReactNode } from 'react'
|
||||
|
||||
import { makeNumericSorter } from '../sorters'
|
||||
import { columnPropsOther, makeGroupColumn, RenderMethod } from '.'
|
||||
import makeColumn, { ColumnProps, FilterGenerator, makeGroupColumn, RenderMethod, SorterMethod } from '.'
|
||||
import { getObjectByDeepKey } from '../Table'
|
||||
|
||||
export const RegExpIsFloat = /^[-+]?\d+\.?\d*$/
|
||||
|
||||
export const makeNumericRender = <T extends unknown>(fixed?: number): RenderMethod<T> => (value) => {
|
||||
let val = '-'
|
||||
if ((value ?? null) !== null && Number.isFinite(+value)) {
|
||||
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
|
||||
|
||||
return Number(getObjectByDeepKey(key, a)) - Number(getObjectByDeepKey(key, b))
|
||||
}
|
||||
|
||||
export const makeNumericRender = <T extends unknown>(fixed?: number, defaultValue: string = '-', precision: number = 5): RenderMethod<T> => (value) => {
|
||||
let val = defaultValue
|
||||
if (value !== undefined && value !== null && Number.isFinite(+value)) {
|
||||
val = (fixed ?? null) !== null
|
||||
? (+value).toFixed(fixed)
|
||||
: (+value).toPrecision(5)
|
||||
: (+value).toPrecision(precision)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -21,91 +29,112 @@ export const makeNumericRender = <T extends unknown>(fixed?: number): RenderMeth
|
||||
)
|
||||
}
|
||||
|
||||
export const makeNumericColumnOptions = (fixed?: number, sorterKey?: string): columnPropsOther => ({
|
||||
export const makeNumericColumnOptions = <T extends number>(fixed?: number, sorterKey?: string): ColumnProps<T> => ({
|
||||
editable: true,
|
||||
initialValue: 0,
|
||||
width: 100,
|
||||
sorter: sorterKey ? makeNumericSorter(sorterKey) : undefined,
|
||||
sorter: sorterKey ? makeNumericSorter<T>(sorterKey) : undefined,
|
||||
formItemRules: [{
|
||||
required: true,
|
||||
message: 'Введите число',
|
||||
pattern: RegExpIsFloat,
|
||||
}],
|
||||
render: makeNumericRender(fixed),
|
||||
render: makeNumericRender<T>(fixed),
|
||||
})
|
||||
|
||||
export const makeNumericColumn = (
|
||||
export const makeNumericColumn = <T extends number>(
|
||||
title: ReactNode,
|
||||
dataIndex: string,
|
||||
filters: object[],
|
||||
filterDelegate: (key: string | number) => any,
|
||||
renderDelegate: (_: any, row: object) => any,
|
||||
width: string,
|
||||
other?: columnPropsOther
|
||||
) => ({
|
||||
title: title,
|
||||
dataIndex: dataIndex,
|
||||
key: dataIndex,
|
||||
filters: filters,
|
||||
onFilter: filterDelegate ? filterDelegate(dataIndex) : null,
|
||||
sorter: makeNumericSorter(dataIndex),
|
||||
width: width,
|
||||
input: <InputNumber style={{ width: '100%' }}/>,
|
||||
render: renderDelegate ?? makeNumericRender(),
|
||||
key: Key,
|
||||
renderDelegate?: RenderMethod<T>,
|
||||
filterDelegate?: FilterGenerator<T>,
|
||||
width?: string | number,
|
||||
other?: ColumnProps<T>,
|
||||
) => makeColumn(title, key, {
|
||||
editable: true,
|
||||
onFilter: filterDelegate ? filterDelegate(key) : undefined,
|
||||
sorter: makeNumericSorter(key),
|
||||
width,
|
||||
input: <InputNumber style={{ width: '100%' }} defaultValue={0} />,
|
||||
render: renderDelegate || makeNumericRender<T>(2),
|
||||
align: 'right',
|
||||
...other
|
||||
})
|
||||
|
||||
export const makeNumericColumnPlanFact = (
|
||||
export const makeNumericColumnPlanFact = <T extends number>(
|
||||
title: ReactNode,
|
||||
dataIndex: string,
|
||||
filters: object[],
|
||||
filterDelegate: (key: string | number) => any,
|
||||
renderDelegate: (_: any, row: object) => any,
|
||||
width: string
|
||||
key: Key,
|
||||
renderDelegate?: RenderMethod<T>,
|
||||
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, [
|
||||
makeNumericColumn('п', dataIndex + 'Plan', filters, filterDelegate, renderDelegate, width),
|
||||
makeNumericColumn('ф', dataIndex + 'Fact', filters, filterDelegate, renderDelegate, width),
|
||||
makeNumericColumn<T>('План', key + 'Plan', renderDelegate, filterDelegate, width, other),
|
||||
makeNumericColumn<T>('Факт', key + 'Fact', renderDelegate, filterDelegate, width, other),
|
||||
])
|
||||
|
||||
export const makeNumericStartEnd = (
|
||||
export const makeNumericStartEnd = <T extends number>(
|
||||
title: ReactNode,
|
||||
dataIndex: string,
|
||||
key: Key,
|
||||
fixed: number,
|
||||
filters: object[],
|
||||
filterDelegate: (key: string | number) => any,
|
||||
renderDelegate: (_: any, row: object) => any,
|
||||
width: string,
|
||||
renderDelegate?: RenderMethod<T>,
|
||||
filterDelegate?: FilterGenerator<T>,
|
||||
width?: string | number,
|
||||
) => makeGroupColumn(title, [
|
||||
makeNumericColumn('старт', dataIndex + 'Start', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Start')),
|
||||
makeNumericColumn('конец', dataIndex + 'End', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'End'))
|
||||
makeNumericColumn<T>('старт', key + 'Start', renderDelegate, filterDelegate, width, makeNumericColumnOptions(fixed, key + 'Start')),
|
||||
makeNumericColumn<T>('конец', key + 'End', renderDelegate, filterDelegate, width, makeNumericColumnOptions(fixed, key + 'End'))
|
||||
])
|
||||
|
||||
export const makeNumericMinMax = (
|
||||
export const makeNumericMinMax = <T extends number>(
|
||||
title: ReactNode,
|
||||
dataIndex: string,
|
||||
key: Key,
|
||||
fixed: number,
|
||||
filters: object[],
|
||||
filterDelegate: (key: string | number) => any,
|
||||
renderDelegate: (_: any, row: object) => any,
|
||||
width: string,
|
||||
renderDelegate?: RenderMethod<T>,
|
||||
filterDelegate?: FilterGenerator<T>,
|
||||
width?: string | number,
|
||||
) => makeGroupColumn(title, [
|
||||
makeNumericColumn('мин', dataIndex + 'Min', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Min')),
|
||||
makeNumericColumn('макс', dataIndex + 'Max', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Max')),
|
||||
makeNumericColumn<T>('мин', key + 'Min', renderDelegate, filterDelegate, width, makeNumericColumnOptions(fixed, key + 'Min')),
|
||||
makeNumericColumn<T>('макс', key + 'Max', renderDelegate, filterDelegate, width, makeNumericColumnOptions(fixed, key + 'Max')),
|
||||
])
|
||||
|
||||
export const makeNumericAvgRange = (
|
||||
export const makeNumericAvgRange = <T extends number>(
|
||||
title: ReactNode,
|
||||
dataIndex: string,
|
||||
key: Key,
|
||||
fixed: number,
|
||||
filters: object[],
|
||||
filterDelegate: (key: string | number) => any,
|
||||
renderDelegate: (_: any, row: object) => any,
|
||||
width: string
|
||||
renderDelegate?: RenderMethod<T>,
|
||||
filterDelegate?: FilterGenerator<T>,
|
||||
width?: string | number,
|
||||
) => makeGroupColumn(title, [
|
||||
makeNumericColumn('мин', dataIndex + 'Min', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Min')),
|
||||
makeNumericColumn('сред', dataIndex + 'Avg', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Avg')),
|
||||
makeNumericColumn('макс', dataIndex + 'Max', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Max'))
|
||||
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
|
||||
|
@ -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
|
@ -1,21 +1,31 @@
|
||||
import { Select, SelectProps } from 'antd'
|
||||
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>(
|
||||
title: string,
|
||||
dataIndex: string,
|
||||
options: DefaultOptionType[],
|
||||
const findOption = <T extends DefaultOptionType>(value: any, options: T[] | undefined) =>
|
||||
options?.find((option) => String(option?.value) === String(value))
|
||||
|
||||
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,
|
||||
other?: columnPropsOther,
|
||||
other?: ColumnProps<T>,
|
||||
selectOther?: SelectProps<SelectValue>
|
||||
) => makeColumn(title, dataIndex, {
|
||||
) => makeColumn(title, key, {
|
||||
editable: true,
|
||||
...other,
|
||||
input: <Select options={options} {...selectOther}/>,
|
||||
render: (value) => {
|
||||
const item = options?.find(option => String(option?.value) === String(value))
|
||||
return other?.render?.(item?.value) ?? item?.label ?? defaultValue ?? value ?? '--'
|
||||
input: <SelectWrapper options={options} {...selectOther}/>,
|
||||
render: (value, dataset, index) => {
|
||||
const item = findOption(value, options)
|
||||
return other?.render?.(item, dataset, index) ?? item?.label ?? defaultValue?.label ?? value?.label ?? '--'
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -4,7 +4,7 @@ import { Select, SelectProps, Tag } from 'antd'
|
||||
|
||||
import type { OmitExtends } from '@utils/types'
|
||||
|
||||
import { columnPropsOther, DataType, makeColumn } from '.'
|
||||
import { ColumnProps, DataType, makeColumn } from '.'
|
||||
|
||||
export type TagInputProps<T extends DataType> = OmitExtends<{
|
||||
options: T[],
|
||||
@ -59,14 +59,15 @@ export const makeTagColumn = <T extends DataType>(
|
||||
options: T[],
|
||||
value_key: keyof DataType,
|
||||
label_key: keyof DataType,
|
||||
other?: columnPropsOther,
|
||||
other?: ColumnProps,
|
||||
tagOther?: TagInputProps<T>
|
||||
) => {
|
||||
const InputComponent = makeTagInput<T>(value_key, label_key)
|
||||
|
||||
return makeColumn(title, dataIndex, {
|
||||
editable: true,
|
||||
...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} />,
|
||||
})
|
||||
}
|
||||
|
@ -1,26 +1,49 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Tooltip } from 'antd'
|
||||
import { ColumnFilterItem } from 'antd/lib/table/interface'
|
||||
import { Key, ReactNode } from 'react'
|
||||
|
||||
import { columnPropsOther, DataType, RenderMethod, SorterMethod } from '.'
|
||||
import { makeStringSorter } from '../sorters'
|
||||
import { ColumnProps, makeColumn, DataType, RenderMethod, SorterMethod } from '.'
|
||||
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>) =>
|
||||
(filterValue: T, dataItem: DataType<T>) => dataItem[key] === filterValue
|
||||
|
||||
export const makeTextColumn = <T extends unknown = any>(
|
||||
title: ReactNode,
|
||||
dataIndex: string,
|
||||
filters: object[],
|
||||
key: string,
|
||||
filters?: ColumnFilterItem[],
|
||||
sorter?: SorterMethod<T>,
|
||||
render?: RenderMethod<T>,
|
||||
other?: columnPropsOther
|
||||
) => ({
|
||||
title: title,
|
||||
dataIndex: dataIndex,
|
||||
key: dataIndex,
|
||||
filters: filters,
|
||||
onFilter: filters ? makeFilterTextMatch(dataIndex) : null,
|
||||
sorter: sorter ?? makeStringSorter(dataIndex),
|
||||
render: render,
|
||||
other?: ColumnProps
|
||||
) => makeColumn(title, key, {
|
||||
editable: true,
|
||||
filters,
|
||||
onFilter: filters ? makeFilterTextMatch(key) : undefined,
|
||||
sorter: sorter || makeStringSorter(key),
|
||||
render: render || makeTextRender(),
|
||||
...other
|
||||
})
|
||||
|
||||
|
@ -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 '.'
|
||||
import { makeTimeSorter, TimePickerWrapper, TimePickerWrapperProps } from '..'
|
||||
export const makeTimeSorter = <T extends TimeDto>(key: Key): SorterMethod<T> => (a, b) => {
|
||||
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,
|
||||
key: string,
|
||||
utc?: boolean,
|
||||
format?: string,
|
||||
other?: columnPropsOther,
|
||||
other?: ColumnProps,
|
||||
pickerOther?: TimePickerWrapperProps,
|
||||
) => makeColumn(title, key, {
|
||||
) => makeColumn<T>(title, key, {
|
||||
editable: true,
|
||||
...other,
|
||||
render: (time) => (
|
||||
<div className={'text-align-r-container'}>
|
||||
<span>{formatTime(time, utc, format) ?? '-'}</span>
|
||||
</div>
|
||||
),
|
||||
sorter: makeTimeSorter(key),
|
||||
sorter: makeTimeSorter<T>(key),
|
||||
input: <TimePickerWrapper isUTC={utc} {...pickerOther} />,
|
||||
})
|
||||
|
||||
|
@ -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 { findTimezoneId, rawTimezones, TimezoneId } from '@utils'
|
||||
import type { OmitExtends } from '@utils/types'
|
||||
import { SimpleTimezoneDto } from '@api'
|
||||
|
||||
import { columnPropsOther, makeColumn } from '.'
|
||||
import { ColumnProps, makeColumn } from '.'
|
||||
|
||||
const makeTimezoneLabel = (id?: string | null, hours?: number) =>
|
||||
`UTC${hours && hours > 0 ? '+':''}${hours ? ('0' + hours).slice(-2) : '~??'} :: ${id ?? 'Неизвестно'}`
|
||||
@ -18,7 +18,7 @@ export const timezoneOptions = Object
|
||||
value: id,
|
||||
}))
|
||||
|
||||
export const makeTimezoneRenderer = () => (timezone?: SimpleTimezoneDto) => {
|
||||
export const makeTimezoneRender = () => (timezone?: SimpleTimezoneDto) => {
|
||||
if (!timezone) return 'UTC~?? :: Неизвестно'
|
||||
const { hours, timezoneId } = timezone
|
||||
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} />)
|
||||
})
|
||||
|
||||
export const makeTimezoneColumn = (
|
||||
export const makeTimezoneColumn = <T extends SimpleTimezoneDto>(
|
||||
title: ReactNode = 'Зона',
|
||||
key: string = 'timezone',
|
||||
defaultValue?: SimpleTimezoneDto,
|
||||
key: Key = 'timezone',
|
||||
defaultValue?: T,
|
||||
allowClear: boolean = true,
|
||||
other?: columnPropsOther,
|
||||
other?: ColumnProps<T>,
|
||||
selectOther?: TimezoneSelectProps
|
||||
) => makeColumn(title, key, {
|
||||
width: 100,
|
||||
editable: true,
|
||||
render: makeTimezoneRenderer(),
|
||||
render: makeTimezoneRender(),
|
||||
input: (
|
||||
<TimezoneSelect
|
||||
key={key}
|
||||
|
@ -6,8 +6,11 @@ import moment, { Moment } from 'moment'
|
||||
import { defaultFormat } from '@utils'
|
||||
|
||||
export type DatePickerWrapperProps = PickerDateProps<Moment> & {
|
||||
/** Значение селектора */
|
||||
value?: Moment,
|
||||
/** Метод вызывается при изменений даты */
|
||||
onChange?: (date: Moment | null) => any
|
||||
/** Конвертировать ли значение в UTC */
|
||||
isUTC?: boolean
|
||||
}
|
||||
|
||||
|
@ -9,10 +9,21 @@ import { defaultFormat } from '@utils'
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
export type DateRangeWrapperProps = RangePickerSharedProps<Moment> & {
|
||||
value?: RangeValue<Moment>,
|
||||
/** Значение селектора в виде массива из 2 элементов (от, до) */
|
||||
value?: RangeValue<Moment>
|
||||
/** Конвертировать ли значения в UTC */
|
||||
isUTC?: boolean
|
||||
/** Разрешить сброс значения селектора */
|
||||
allowClear?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Подготавливает значения к передаче в селектор
|
||||
*
|
||||
* @param value Массиз из 2 дат
|
||||
* @param isUTC Конвертировать ли значения в UTC
|
||||
* @returns Подготовленные даты
|
||||
*/
|
||||
const normalizeDates = (value?: RangeValue<Moment>, isUTC?: boolean): RangeValue<Moment> => {
|
||||
if (!value) return [null, null]
|
||||
return [
|
||||
@ -21,16 +32,16 @@ const normalizeDates = (value?: RangeValue<Moment>, isUTC?: boolean): RangeValue
|
||||
]
|
||||
}
|
||||
|
||||
export const DateRangeWrapper = memo<DateRangeWrapperProps>(({ value, isUTC, ...other }) => (
|
||||
export const DateRangeWrapper = memo<DateRangeWrapperProps>(({ value, isUTC, allowClear, ...other }) => (
|
||||
<RangePicker
|
||||
showTime
|
||||
allowClear={false}
|
||||
allowClear={allowClear}
|
||||
format={defaultFormat}
|
||||
defaultValue={[
|
||||
moment().subtract(1, 'days').startOf('day'),
|
||||
moment().startOf('day'),
|
||||
]}
|
||||
value={normalizeDates(value)}
|
||||
value={normalizeDates(value, isUTC)}
|
||||
{...other}
|
||||
/>
|
||||
))
|
||||
|
@ -1,19 +1,20 @@
|
||||
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 { 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
|
||||
dataIndex?: NamePath
|
||||
dataIndex?: Key
|
||||
input?: ReactNode
|
||||
isRequired?: boolean
|
||||
title: string
|
||||
formItemClass?: string
|
||||
formItemRules?: Rule[]
|
||||
children: ReactNode
|
||||
initialValue: any
|
||||
}
|
||||
|
||||
const itemStyle = { margin: 0 }
|
||||
|
||||
export const EditableCell = memo<EditableCellProps>(({
|
||||
editing,
|
||||
dataIndex,
|
||||
@ -24,21 +25,30 @@ export const EditableCell = memo<EditableCellProps>(({
|
||||
children,
|
||||
initialValue,
|
||||
...other
|
||||
}) => (
|
||||
<td style={editing ? { padding: 0 } : undefined} {...other}>
|
||||
{!editing ? children : (
|
||||
<Form.Item
|
||||
name={dataIndex}
|
||||
style={{ margin: 0 }}
|
||||
className={formItemClass}
|
||||
rules={formItemRules ?? [{
|
||||
}) => {
|
||||
const rules = useMemo(() => formItemRules || [{
|
||||
required: isRequired,
|
||||
message: `Это обязательное поле!`,
|
||||
}]}
|
||||
}], [formItemRules, isRequired])
|
||||
|
||||
const name = useMemo(() => dataIndex ? String(dataIndex).split('.') : undefined, [dataIndex])
|
||||
const tdStyle = useMemo(() => editing ? { padding: 0 } : undefined, [editing])
|
||||
|
||||
const edititngItem = useMemo(() => (
|
||||
<Form.Item
|
||||
name={name}
|
||||
style={itemStyle}
|
||||
className={formItemClass}
|
||||
rules={rules}
|
||||
initialValue={initialValue}
|
||||
>
|
||||
{input ?? <Input/>}
|
||||
{input ?? <Input />}
|
||||
</Form.Item>
|
||||
)}
|
||||
), [name, rules, formItemClass, initialValue, input])
|
||||
|
||||
return (
|
||||
<td style={tdStyle} {...other}>
|
||||
{editing ? edititngItem : children}
|
||||
</td>
|
||||
))
|
||||
)
|
||||
})
|
||||
|
@ -39,7 +39,7 @@ export const makeTableAction = ({
|
||||
},
|
||||
setLoader,
|
||||
errorMsg,
|
||||
actionName
|
||||
{ actionName }
|
||||
)
|
||||
)
|
||||
|
||||
@ -221,9 +221,7 @@ export const EditableTable = memo(({
|
||||
|
||||
const mergedColumns = useMemo(() => [...columns.map(handleColumn), operationColumn], [columns, handleColumn, operationColumn])
|
||||
|
||||
useEffect(() => {
|
||||
setData(tryAddKeys(dataSource))
|
||||
}, [dataSource])
|
||||
useEffect(() => setData(tryAddKeys(dataSource)), [dataSource])
|
||||
|
||||
return (
|
||||
<Form form={form}>
|
||||
|
@ -1,38 +1,96 @@
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
import { Key, memo, useCallback, useEffect, useState } from 'react'
|
||||
import { ColumnGroupType, ColumnType } from 'antd/lib/table'
|
||||
import { 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 { applyTableSettings, getTableSettings, setTableSettings, TableColumnSettings, TableSettings } from '@utils'
|
||||
|
||||
import TableSettingsChanger from './TableSettingsChanger'
|
||||
import { tryAddKeys } from './EditableTable'
|
||||
|
||||
import '@styles/index.css'
|
||||
|
||||
export type BaseTableColumn<T = any> = ColumnGroupType<T> | ColumnType<T>
|
||||
export type TableColumns<T = any> = OmitExtends<BaseTableColumn<T>, TableColumnSettings>[]
|
||||
export type BaseTableColumn<T> = ColumnGroupType<T> | ColumnType<T>
|
||||
export type TableColumn<T> = OmitExtends<BaseTableColumn<T>, TableColumnSettings>
|
||||
export type TableColumns<T> = TableColumn<T>[]
|
||||
|
||||
export type TableContainer = TableProps<any> & {
|
||||
columns: TableColumns
|
||||
dataSource: any[]
|
||||
export type TableProps<T> = RawTableProps<T> & {
|
||||
/** Массив колонок таблицы с настройками (описаны в `TableColumnSettings`) */
|
||||
columns: TableColumn<T>[]
|
||||
/** Название таблицы для сохранения настроек */
|
||||
tableName?: string
|
||||
/** Отображать ли кнопку настроек */
|
||||
showSettingsChanger?: boolean
|
||||
}
|
||||
|
||||
export const Table = memo<TableContainer>(({ columns, dataSource, tableName, showSettingsChanger, ...other }) => {
|
||||
const [newColumns, setNewColumns] = useState<TableColumns>([])
|
||||
export interface DataSet<T, D = any> {
|
||||
[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 onSettingsChanged = useCallback((settings?: TableSettings | null) => {
|
||||
if (tableName)
|
||||
setTableSettings(tableName, settings)
|
||||
setSettings(settings ?? {})
|
||||
setSettings(settings || {})
|
||||
}, [tableName])
|
||||
|
||||
useEffect(() => setSettings(tableName ? getTableSettings(tableName) : {}), [tableName])
|
||||
useEffect(() => setNewColumns(() => {
|
||||
const newColumns = applyTableSettings(columns, settings)
|
||||
const newColumns = applyTableSettings(applyColumnWrappers(columns), settings)
|
||||
if (tableName && showSettingsChanger) {
|
||||
const oldTitle = newColumns[0].title
|
||||
newColumns[0].title = (props) => (
|
||||
@ -52,6 +110,15 @@ export const Table = memo<TableContainer>(({ columns, dataSource, tableName, sho
|
||||
{...other}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Обёртка над компонентом таблицы AntD
|
||||
*
|
||||
* Особенности:
|
||||
* * Поддержка составных ключей столбцов
|
||||
* * Работа с настройками столбцов таблицы
|
||||
*/
|
||||
export const Table = memo(_Table) as typeof _Table
|
||||
|
||||
export default Table
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
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 { TableColumnSettings, makeTableSettings, mergeTableSettings, TableSettings } from '@utils'
|
||||
import { TableColumns } from './Table'
|
||||
import { makeColumn } from '.'
|
||||
import { Table, TableColumns } from './Table'
|
||||
import { makeColumn, makeTextColumn } from '.'
|
||||
|
||||
const parseSettings = (columns?: TableColumns, settings?: TableSettings | null): TableColumnSettings[] => {
|
||||
const parseSettings = <T extends object>(columns?: TableColumns<T>, settings?: TableSettings | null): TableColumnSettings[] => {
|
||||
const newSettings = mergeTableSettings(makeTableSettings(columns ?? []), settings ?? {})
|
||||
return Object.values(newSettings).map((set, i) => ({ ...set, key: i }))
|
||||
}
|
||||
@ -15,14 +15,14 @@ const parseSettings = (columns?: TableColumns, settings?: TableSettings | null):
|
||||
const unparseSettings = (columns: TableColumnSettings[]): TableSettings =>
|
||||
Object.fromEntries(columns.map((column) => [column.columnName, column]))
|
||||
|
||||
export type TableSettingsChangerProps = {
|
||||
export type TableSettingsChangerProps<T extends object> = {
|
||||
title?: string
|
||||
columns?: TableColumns
|
||||
columns?: TableColumns<T>
|
||||
settings?: TableSettings | null
|
||||
onChange: (settings: TableSettings | null) => void
|
||||
}
|
||||
|
||||
export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, columns, settings, onChange }) => {
|
||||
const _TableSettingsChanger = <T extends object>({ title, columns, settings, onChange }: TableSettingsChangerProps<T>) => {
|
||||
const [visible, setVisible] = useState<boolean>(false)
|
||||
const [newSettings, setNewSettings] = useState<TableColumnSettings[]>(parseSettings(columns, settings))
|
||||
const [tableColumns, setTableColumns] = useState<ColumnsType<TableColumnSettings>>([])
|
||||
@ -36,30 +36,34 @@ export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, co
|
||||
}, [])
|
||||
|
||||
const toogleAll = useCallback((show: boolean) => {
|
||||
setNewSettings((oldSettings) => oldSettings.map((column) => {
|
||||
setNewSettings((oldSettings) =>
|
||||
oldSettings.map((column) => {
|
||||
column.visible = show
|
||||
return column
|
||||
}))
|
||||
})
|
||||
)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setTableColumns([
|
||||
makeColumn('Название', 'title'),
|
||||
makeColumn(null, 'visible', {
|
||||
makeTextColumn<string>('Название', 'title'),
|
||||
makeColumn<any>(null, 'visible', {
|
||||
title: () => (
|
||||
<>
|
||||
Показать
|
||||
<Button type={'link'} onClick={() => toogleAll(true)}>Показать все</Button>
|
||||
<Button type={'link'} onClick={() => toogleAll(true)}>
|
||||
Показать все
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
render: (visible: boolean, _?: TableColumnSettings, index: number = NaN) => (
|
||||
render: (visible, _, index = NaN) => (
|
||||
<Switch
|
||||
checked={visible}
|
||||
checkedChildren={'Отображён'}
|
||||
unCheckedChildren={'Скрыт'}
|
||||
onChange={(visible) => onVisibilityChange(index, visible)}
|
||||
/>
|
||||
)
|
||||
),
|
||||
}),
|
||||
])
|
||||
}, [toogleAll, onVisibilityChange])
|
||||
@ -80,7 +84,7 @@ export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, co
|
||||
<>
|
||||
<Modal
|
||||
centered
|
||||
visible={visible}
|
||||
open={visible}
|
||||
onCancel={onModalCancel}
|
||||
onOk={onModalOk}
|
||||
title={title ?? 'Настройка отображения таблицы'}
|
||||
@ -88,9 +92,17 @@ export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, co
|
||||
>
|
||||
<Table columns={tableColumns} dataSource={newSettings} />
|
||||
</Modal>
|
||||
<Button size={'small'} style={{ position: 'absolute', left: 0, top: 0, opacity: .5 }} type={'link'} onClick={() => setVisible(true)} icon={<SettingOutlined />}/>
|
||||
<Button
|
||||
size={'small'}
|
||||
style={{ position: 'absolute', left: 0, top: 0, opacity: 0.5 }}
|
||||
type={'link'}
|
||||
onClick={() => setVisible(true)}
|
||||
icon={<SettingOutlined />}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const TableSettingsChanger = memo(_TableSettingsChanger) as typeof _TableSettingsChanger
|
||||
|
||||
export default TableSettingsChanger
|
||||
|
@ -6,8 +6,11 @@ import { defaultTimeFormat, momentToTime, timeToMoment } from '@utils'
|
||||
import { TimeDto } from '@api'
|
||||
|
||||
export type TimePickerWrapperProps = Omit<Omit<TimePickerProps, 'value'>, 'onChange'> & {
|
||||
/** Текущее значение */
|
||||
value?: TimeDto,
|
||||
/** Метод вызывается при изменений времени */
|
||||
onChange?: (date: TimeDto | null) => any
|
||||
/** Конвертировать ли время в UTC */
|
||||
isUTC?: boolean
|
||||
}
|
||||
|
||||
|
@ -1,59 +1,30 @@
|
||||
export { makeDateSorter, makeNumericSorter, makeStringSorter, makeTimeSorter } from './sorters'
|
||||
export { EditableTable, makeTableAction } from './EditableTable'
|
||||
export { DatePickerWrapper } from './DatePickerWrapper'
|
||||
export { TimePickerWrapper } from './TimePickerWrapper'
|
||||
export { DateRangeWrapper } from './DateRangeWrapper'
|
||||
export { Table } from './Table'
|
||||
export {
|
||||
RegExpIsFloat,
|
||||
timezoneOptions,
|
||||
TimezoneSelect,
|
||||
makeDateColumn,
|
||||
makeTimeColumn,
|
||||
makeGroupColumn,
|
||||
makeColumn,
|
||||
makeColumnsPlanFact,
|
||||
makeFilterTextMatch,
|
||||
makeNumericRender,
|
||||
makeNumericColumn,
|
||||
makeNumericColumnOptions,
|
||||
makeNumericColumnPlanFact,
|
||||
makeNumericStartEnd,
|
||||
makeNumericMinMax,
|
||||
makeNumericAvgRange,
|
||||
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 * from './EditableTable'
|
||||
export * from './DatePickerWrapper'
|
||||
export * from './TimePickerWrapper'
|
||||
export * from './DateRangeWrapper'
|
||||
export * from './Table'
|
||||
export * from './Columns'
|
||||
|
||||
export const defaultPagination = {
|
||||
defaultPageSize: 14,
|
||||
showSizeChanger: true,
|
||||
}
|
||||
|
||||
type PaginationContainer = {
|
||||
export type PaginationContainer<T> = {
|
||||
skip?: number
|
||||
take?: 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,
|
||||
pageSize: сontainer.take,
|
||||
total: сontainer.count ?? сontainer.items?.length ?? 0,
|
||||
|
@ -1,38 +0,0 @@
|
||||
import { timeToMoment } from '@utils'
|
||||
import { isRawDate } from '@utils'
|
||||
import { TimeDto } from '@api'
|
||||
|
||||
import { DataType } from './Columns'
|
||||
|
||||
export const makeNumericSorter = <T extends unknown>(key: keyof DataType<T>) =>
|
||||
(a: DataType<T>, b: DataType<T>) => Number(a[key]) - Number(b[key])
|
||||
|
||||
export const makeStringSorter = <T extends unknown>(key: keyof DataType<T>) => (a?: DataType<T> | null, b?: DataType<T> | null) => {
|
||||
if (!a && !b) return 0
|
||||
if (!a) return 1
|
||||
if (!b) return -1
|
||||
|
||||
return String(a[key]).localeCompare(String(b[key]))
|
||||
}
|
||||
|
||||
export const makeDateSorter = <T extends unknown>(key: keyof DataType<T>) => (a: DataType<T>, b: DataType<T>) => {
|
||||
const adate = a[key]
|
||||
const bdate = b[key]
|
||||
if (!isRawDate(adate) || !isRawDate(bdate))
|
||||
throw new Error('Date column contains not date formatted string(s)')
|
||||
|
||||
const date = new Date(adate)
|
||||
|
||||
return date.getTime() - new Date(bdate).getTime()
|
||||
}
|
||||
|
||||
export const makeTimeSorter = <T extends TimeDto>(key: keyof DataType<T>) => (a: DataType<T>, b: DataType<T>) => {
|
||||
const elma = a[key]
|
||||
const elmb = b[key]
|
||||
|
||||
if (!elma && !elmb) return 0
|
||||
if (!elma) return 1
|
||||
if (!elmb) return -1
|
||||
|
||||
return timeToMoment(elma).diff(timeToMoment(elmb))
|
||||
}
|
@ -10,6 +10,7 @@ import { notify, upload } from './factory'
|
||||
import { ErrorFetch } from './ErrorFetch'
|
||||
|
||||
export type UploadFormProps = {
|
||||
multiple?: boolean
|
||||
url: string
|
||||
disabled?: boolean
|
||||
accept?: string
|
||||
@ -22,7 +23,7 @@ export type UploadFormProps = {
|
||||
onUploadError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
export const UploadForm = memo<UploadFormProps>(({ url, disabled, style, formData, mimeTypes, onUploadStart, onUploadSuccess, onUploadComplete, onUploadError }) => {
|
||||
export const UploadForm = memo<UploadFormProps>(({ url, multiple, disabled, style, formData, mimeTypes, onUploadStart, onUploadSuccess, onUploadComplete, onUploadError }) => {
|
||||
const [fileList, setfileList] = useState<UploadFile<any>[]>([])
|
||||
|
||||
const checkMimeTypes = useCallback((file: RcFile) => {
|
||||
@ -38,7 +39,7 @@ export const UploadForm = memo<UploadFormProps>(({ url, disabled, style, formDat
|
||||
onUploadStart?.()
|
||||
try {
|
||||
const formDataLocal = new FormData()
|
||||
fileList.forEach((val) => formDataLocal.append('files', val.originFileObj as Blob))
|
||||
fileList.forEach((val) => formDataLocal.append(multiple ? 'files' : 'file', val.originFileObj as Blob))
|
||||
|
||||
if(formData)
|
||||
for(const propName in formData)
|
||||
@ -60,7 +61,7 @@ export const UploadForm = memo<UploadFormProps>(({ url, disabled, style, formDat
|
||||
setfileList([])
|
||||
onUploadComplete?.()
|
||||
}
|
||||
}, [fileList, formData, onUploadComplete, onUploadError, onUploadStart, onUploadSuccess, url])
|
||||
}, [fileList, formData, onUploadComplete, onUploadError, onUploadStart, onUploadSuccess, url, multiple])
|
||||
|
||||
const isSendButtonEnabled = fileList.length > 0
|
||||
return(
|
||||
@ -72,6 +73,7 @@ export const UploadForm = memo<UploadFormProps>(({ url, disabled, style, formDat
|
||||
fileList={fileList}
|
||||
onChange={(props) => setfileList(props.fileList)}
|
||||
beforeUpload={checkMimeTypes}
|
||||
maxCount={multiple ? undefined : 1}
|
||||
>
|
||||
<Button disabled={disabled} icon={<UploadOutlined/>}>Загрузить файл</Button>
|
||||
</Upload>
|
||||
|
@ -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 { Button, Dropdown, DropDownProps } from 'antd'
|
||||
import { UserOutlined } from '@ant-design/icons'
|
||||
import { Button, Collapse, Drawer, DrawerProps, Form, FormRule, Input, Popconfirm } from 'antd'
|
||||
import { useForm } from 'antd/lib/form/Form'
|
||||
|
||||
import { getUserLogin, removeUser } from '@utils'
|
||||
import { ChangePassword } from './ChangePassword'
|
||||
import { PrivateMenu } from './Private'
|
||||
import { useUser } from '@asb/context'
|
||||
import { Grid, GridItem } from '@components/Grid'
|
||||
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 }) => {
|
||||
const [isModalVisible, setIsModalVisible] = useState<boolean>(false)
|
||||
type ChangePasswordForm = {
|
||||
'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 location = useLocation()
|
||||
const user = useUser()
|
||||
|
||||
const onChangePasswordClick: MouseEventHandler = useCallback((e) => {
|
||||
setIsModalVisible(true)
|
||||
e.preventDefault()
|
||||
}, [])
|
||||
const navigateTo = useCallback((to: string) => navigate(to, { state: { from: location.pathname }}), [navigate, location.pathname])
|
||||
|
||||
const onChangePasswordOk = useCallback(() => {
|
||||
setIsModalVisible(false)
|
||||
navigate('/login', { state: { from: location.pathname }})
|
||||
}, [navigate, location])
|
||||
const onChangePasswordOk = useCallback(() => invokeWebApiWrapperAsync(
|
||||
async (values: any) => {
|
||||
await AuthService.changePassword(user.id ?? -1, `${values['new-password']}`)
|
||||
removeUser()
|
||||
navigateTo('/login')
|
||||
},
|
||||
setShowLoader,
|
||||
`Не удалось сменить пароль пользователя ${user.login}`,
|
||||
{ actionName: 'Смена пароля пользователя' },
|
||||
), [navigateTo])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
removeUser()
|
||||
navigateTo('/login')
|
||||
}, [navigateTo])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown
|
||||
<Drawer
|
||||
closable
|
||||
placement={'left'}
|
||||
className={'user-menu'}
|
||||
title={'Профиль пользователя'}
|
||||
{...other}
|
||||
placement={'bottomRight'}
|
||||
overlay={(
|
||||
<PrivateMenu mode={'vertical'} style={{ textAlign: 'right' }}>
|
||||
{isAdmin ? (
|
||||
<PrivateMenu.Link visible key={'/'} path={'/'} title={'Вернуться на сайт'} />
|
||||
) : (
|
||||
<PrivateMenu.Link path={'/admin'} content={AdminPanel} />
|
||||
)}
|
||||
<PrivateMenu.Link visible key={'change_password'} onClick={onChangePasswordClick} title={'Сменить пароль'} />
|
||||
<PrivateMenu.Link visible key={'login'} path={'/login'} onClick={removeUser} title={'Выход'} />
|
||||
</PrivateMenu>
|
||||
)}
|
||||
>
|
||||
<Button icon={<UserOutlined/>}>{getUserLogin()}</Button>
|
||||
</Dropdown>
|
||||
<ChangePassword
|
||||
visible={isModalVisible}
|
||||
onOk={onChangePasswordOk}
|
||||
onCancel={() => setIsModalVisible(false)}
|
||||
/>
|
||||
</>
|
||||
<div className={'profile-links'}>
|
||||
{isAdmin ? (
|
||||
<Button onClick={() => navigateTo('/')}>Вернуться на сайт</Button>
|
||||
) : isURLAvailable('/admin') && (
|
||||
<Button onClick={() => navigateTo('/admin')}>Панель администратора</Button>
|
||||
)}
|
||||
<Button type={'ghost'} onClick={logout}>Выход</Button>
|
||||
</div>
|
||||
<Collapse>
|
||||
<Collapse.Panel header={'Данные'} key={'summary'}>
|
||||
<Grid>
|
||||
<GridItem row={1} col={1}>Логин:</GridItem>
|
||||
<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>
|
||||
)
|
||||
})
|
||||
|
@ -1,11 +1,10 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useElementSize } from 'usehooks-ts'
|
||||
import { Property } from 'csstype'
|
||||
import { Empty } from 'antd'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
import LoaderPortal from '@components/LoaderPortal'
|
||||
import { isDev, usePartialProps } from '@utils'
|
||||
import { isDev, useElementSize, usePartialProps } from '@utils'
|
||||
|
||||
import D3MouseZone from './D3MouseZone'
|
||||
import { getChartClass } from './functions'
|
||||
@ -27,6 +26,7 @@ import {
|
||||
D3TooltipSettings,
|
||||
} from './plugins'
|
||||
import type {
|
||||
BaseDataType,
|
||||
ChartAxis,
|
||||
ChartDataset,
|
||||
ChartDomain,
|
||||
@ -35,7 +35,7 @@ import type {
|
||||
ChartTicks
|
||||
} from './types'
|
||||
|
||||
import '@styles/d3.less'
|
||||
import '@styles/components/d3.less'
|
||||
|
||||
const defaultOffsets: ChartOffset = {
|
||||
top: 10,
|
||||
@ -50,13 +50,13 @@ export const getByAccessor = <DataType extends Record<any, any>, R>(accessor: ke
|
||||
return (d) => d[accessor]
|
||||
}
|
||||
|
||||
const createAxis = <DataType,>(config: ChartAxis<DataType>) => {
|
||||
const createAxis = <DataType extends BaseDataType>(config: ChartAxis<DataType>) => {
|
||||
if (config.type === 'time')
|
||||
return d3.scaleTime()
|
||||
return d3.scaleLinear()
|
||||
}
|
||||
|
||||
export type D3ChartProps<DataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
export type D3ChartProps<DataType extends BaseDataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
/** Параметры общей горизонтальной оси */
|
||||
xAxis: ChartAxis<DataType>
|
||||
/** Параметры графиков */
|
||||
@ -94,7 +94,7 @@ export type D3ChartProps<DataType> = React.DetailedHTMLProps<React.HTMLAttribute
|
||||
}
|
||||
}
|
||||
|
||||
const getDefaultXAxisConfig = <DataType,>(): ChartAxis<DataType> => ({
|
||||
const getDefaultXAxisConfig = <DataType extends BaseDataType>(): ChartAxis<DataType> => ({
|
||||
type: 'time',
|
||||
accessor: (d: any) => new Date(d.date)
|
||||
})
|
||||
@ -130,7 +130,7 @@ const _D3Chart = <DataType extends Record<string, unknown>>({
|
||||
|
||||
const [charts, setCharts] = useState<ChartRegistry<DataType>[]>([])
|
||||
|
||||
const [rootRef, { width, height }] = useElementSize()
|
||||
const [rootRef, { width, height }] = useElementSize<HTMLDivElement>()
|
||||
|
||||
const xAxis = useMemo(() => {
|
||||
if (!data) return
|
||||
|
104
src/components/d3/D3HorizontalPercentChart.tsx
Normal file
104
src/components/d3/D3HorizontalPercentChart.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { Property } from 'csstype'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
import LoaderPortal from '@components/LoaderPortal'
|
||||
import { useElementSize, usePartialProps } from '@utils'
|
||||
import { ChartOffset } from './types'
|
||||
|
||||
import '@styles/components/d3.less'
|
||||
|
||||
export type PercentChartDataType = {
|
||||
name: string
|
||||
percent: number
|
||||
color?: Property.Color
|
||||
}
|
||||
|
||||
export type D3HorizontalChartProps = {
|
||||
width?: Property.Width
|
||||
height?: Property.Height
|
||||
data: PercentChartDataType[]
|
||||
offset?: Partial<ChartOffset>
|
||||
afterDraw?: (d: d3.Selection<SVGRectElement, PercentChartDataType, SVGGElement, unknown>) => void
|
||||
}
|
||||
|
||||
const defaultOffset = { top: 50, right: 100, bottom: 50, left: 100 }
|
||||
|
||||
export const D3HorizontalPercentChart = memo<D3HorizontalChartProps>(({
|
||||
width: givenWidth = '100%',
|
||||
height: givenHeight = '100%',
|
||||
offset: givenOffset,
|
||||
data,
|
||||
afterDraw,
|
||||
}) => {
|
||||
const offset = usePartialProps<ChartOffset>(givenOffset, defaultOffset)
|
||||
|
||||
const [divRef, { width, height }] = useElementSize<HTMLDivElement>()
|
||||
const rootRef = useRef<SVGGElement | null>(null)
|
||||
|
||||
const root = useCallback(() => rootRef.current ? d3.select(rootRef.current) : null, [rootRef.current])
|
||||
|
||||
const inlineWidth = useMemo(() => width - offset.left - offset.right, [width])
|
||||
const inlineHeight = useMemo(() => height - offset.top - offset.bottom, [height])
|
||||
|
||||
const xScale = useMemo(() => d3.scaleLinear().domain([0, 100]).range([0, inlineWidth]), [inlineWidth])
|
||||
const yScale = useMemo(() => d3.scaleBand().domain(data.map((d) => d.name)).range([0, inlineHeight]).padding(0.25), [data, inlineHeight])
|
||||
|
||||
useEffect(() => { /// Отрисовываем оси X сверху и снизу
|
||||
const r = root()
|
||||
if (width < 100 || height < 100 || !r) return
|
||||
const xAxisTop = d3.axisTop(xScale).tickFormat((d) => `${d}%`).ticks(4).tickSize(-inlineHeight)
|
||||
const xAxisBottom = d3.axisBottom(xScale).tickFormat((d) => `${d}%`).ticks(4)
|
||||
|
||||
r.selectChild<SVGGElement>('.axis.x.bottom').call(xAxisBottom)
|
||||
r.selectChild<SVGGElement>('.axis.x.top').call(xAxisTop)
|
||||
.selectAll('.tick')
|
||||
.attr('class', 'tick grid-line')
|
||||
}, [root, width, height, xScale, inlineHeight])
|
||||
|
||||
useEffect(() => { /// Отрисовываем ось Y слева
|
||||
const r = root()
|
||||
if (width < 100 || height < 100 || !r) return
|
||||
r.selectChild<SVGGElement>('.axis.y.left').call(d3.axisLeft(yScale))
|
||||
}, [root, width, height, yScale])
|
||||
|
||||
useEffect(() => {
|
||||
const r = root()
|
||||
if (width < 100 || height < 100 || !r) return
|
||||
|
||||
const delay = d3.transition().duration(500).ease(d3.easeLinear)
|
||||
|
||||
const rects = r.selectChild('.data').selectAll('rect').data(data)
|
||||
rects.enter().append('rect')
|
||||
rects.exit().remove()
|
||||
|
||||
const selectedRects = r.selectChild<SVGGElement>('.data')
|
||||
.selectAll<SVGRectElement, PercentChartDataType>('rect')
|
||||
|
||||
selectedRects.attr('fill', (d) => d.color || 'black')
|
||||
.attr('y', (d) => yScale(d.name) ?? null)
|
||||
.attr('height', yScale.bandwidth())
|
||||
.transition(delay)
|
||||
.attr('width', (d) => d.percent > 0 ? xScale(d.percent) : 0)
|
||||
|
||||
afterDraw?.(selectedRects)
|
||||
|
||||
}, [data, width, height, root, yScale, xScale, afterDraw])
|
||||
|
||||
return (
|
||||
<LoaderPortal show={false} style={{ width: givenWidth, height: givenHeight }}>
|
||||
<div ref={divRef} style={{ width: '100%', height: '100%' }}>
|
||||
<svg width={'100%'} height={'100%'}>
|
||||
<g ref={rootRef} transform={`translate(${offset.left}, ${offset.top})`}>
|
||||
<g className={'axis x top'}></g>
|
||||
<g className={'axis x bottom'} transform={`translate(0, ${inlineHeight})`}></g>
|
||||
<g className={'data'}></g>
|
||||
<g className={'axis y left'}></g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</LoaderPortal>
|
||||
)
|
||||
})
|
||||
|
||||
export default D3HorizontalPercentChart
|
@ -3,7 +3,7 @@ import * as d3 from 'd3'
|
||||
|
||||
import { ChartOffset } from './types'
|
||||
|
||||
import '@styles/d3.less'
|
||||
import '@styles/components/d3.less'
|
||||
|
||||
export type D3MouseState = {
|
||||
/** Позиция мыши по оси X */
|
||||
|
@ -1,6 +1,2 @@
|
||||
export * from './D3Chart'
|
||||
export type { D3ChartProps } from './D3Chart'
|
||||
|
||||
export * from './D3MonitoringCharts'
|
||||
|
||||
export * from './types'
|
||||
|
@ -1,18 +1,19 @@
|
||||
import { CSSProperties, ReactNode, SVGProps, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
import { wrapPlugin } from '@components/d3/plugins/base'
|
||||
import { useD3MouseZone } from '@components/d3/D3MouseZone'
|
||||
import { ChartGroup, ChartSizes } from '@components/d3/D3MonitoringCharts'
|
||||
import { D3TooltipPosition } from '@components/d3/plugins/D3Tooltip'
|
||||
import { getChartIcon, isDev, usePartialProps } from '@utils'
|
||||
|
||||
import { wrapPlugin } from './base'
|
||||
import { D3TooltipPosition } from './D3Tooltip'
|
||||
import { BaseDataType } from '../types'
|
||||
import { ChartGroup, ChartSizes } from './D3MonitoringCharts'
|
||||
|
||||
import '@styles/d3.less'
|
||||
import '@styles/components/d3.less'
|
||||
|
||||
type D3GroupRenderFunction<DataType> = (group: ChartGroup<DataType>, data: DataType[]) => ReactNode
|
||||
type D3GroupRenderFunction<DataType extends BaseDataType> = (group: ChartGroup<DataType>, data: DataType[], flowData: DataType[] | undefined) => ReactNode
|
||||
|
||||
export type D3HorizontalCursorSettings<DataType> = {
|
||||
export type D3HorizontalCursorSettings<DataType extends BaseDataType> = {
|
||||
width?: number
|
||||
height?: number
|
||||
render?: D3GroupRenderFunction<DataType>
|
||||
@ -23,11 +24,13 @@ export type D3HorizontalCursorSettings<DataType> = {
|
||||
lineStyle?: SVGProps<SVGLineElement>
|
||||
}
|
||||
|
||||
export type D3HorizontalCursorProps<DataType> = D3HorizontalCursorSettings<DataType> & {
|
||||
export type D3HorizontalCursorProps<DataType extends BaseDataType> = D3HorizontalCursorSettings<DataType> & {
|
||||
groups: ChartGroup<DataType>[]
|
||||
data: DataType[]
|
||||
flowData: DataType[] | undefined
|
||||
sizes: ChartSizes
|
||||
yAxis?: d3.ScaleTime<number, number>
|
||||
spaceBetweenGroups?: number
|
||||
}
|
||||
|
||||
const defaultLineStyle: SVGProps<SVGLineElement> = {
|
||||
@ -36,7 +39,7 @@ const defaultLineStyle: SVGProps<SVGLineElement> = {
|
||||
|
||||
const offsetY = 5
|
||||
|
||||
const makeDefaultRender = <DataType,>(): D3GroupRenderFunction<DataType> => (group, data) => (
|
||||
const makeDefaultRender = <DataType extends BaseDataType>(): D3GroupRenderFunction<DataType> => (group, data, flowData) => (
|
||||
<>
|
||||
{data.length > 0 ? group.charts.map((chart) => {
|
||||
const xFormat = (d: number | Date) => chart.xAxis.format?.(d) ?? `${(+d).toFixed(2)} ${chart.xAxis.unit ?? ''}`
|
||||
@ -61,8 +64,8 @@ const makeDefaultRender = <DataType,>(): D3GroupRenderFunction<DataType> => (gro
|
||||
</>
|
||||
)
|
||||
|
||||
const _D3HorizontalCursor = <DataType,>({
|
||||
width = 220,
|
||||
const _D3HorizontalCursor = <DataType extends BaseDataType>({
|
||||
spaceBetweenGroups = 30,
|
||||
height = 200,
|
||||
render = makeDefaultRender<DataType>(),
|
||||
position: _position = 'bottom',
|
||||
@ -72,6 +75,7 @@ const _D3HorizontalCursor = <DataType,>({
|
||||
lineStyle: _lineStyle,
|
||||
|
||||
data,
|
||||
flowData,
|
||||
groups,
|
||||
sizes,
|
||||
yAxis,
|
||||
@ -104,7 +108,7 @@ const _D3HorizontalCursor = <DataType,>({
|
||||
if (!unsubscribe() && isDev())
|
||||
console.warn('Не удалось отвязать эвент')
|
||||
}
|
||||
}, [subscribe])
|
||||
}, [subscribe, fixed, mouseState.visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!zone || !getXLine) return
|
||||
@ -139,7 +143,7 @@ const _D3HorizontalCursor = <DataType,>({
|
||||
if (!mouseState.visible || fixed) return
|
||||
|
||||
let top = mouseState.y + offsetY
|
||||
if (top + height >= sizes.chartsHeight) {
|
||||
if (mouseState.y >= sizes.chartsHeight / 2) {
|
||||
setPosition('bottom')
|
||||
top = mouseState.y - offsetY - height
|
||||
} else {
|
||||
@ -165,7 +169,7 @@ const _D3HorizontalCursor = <DataType,>({
|
||||
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)
|
||||
}, [groups, data, yAxis, lineY, fixed, mouseState.visible])
|
||||
@ -178,19 +182,23 @@ const _D3HorizontalCursor = <DataType,>({
|
||||
{groups.map((_, i) => (
|
||||
<foreignObject
|
||||
key={`${i}`}
|
||||
width={width}
|
||||
width={sizes.groupWidth + spaceBetweenGroups}
|
||||
height={height}
|
||||
x={sizes.groupLeft(i) + (sizes.groupWidth - width) / 2}
|
||||
x={sizes.groupLeft(i) - spaceBetweenGroups / 2}
|
||||
y={tooltipY}
|
||||
opacity={fixed || mouseState.visible ? 1 : 0}
|
||||
pointerEvents={fixed ? 'all' : 'none'}
|
||||
style={{ transition: 'opacity .1s ease-out', userSelect: fixed ? 'auto' : 'none' }}
|
||||
>
|
||||
<div className={`tooltip ${position} ${className}`}>
|
||||
<div className={'tooltip-wrapper'}>
|
||||
<div className={`adaptive-tooltip tooltip ${position} ${className}`}
|
||||
style={{height: 'auto', bottom: `${position === 'bottom' ? '0' : ''}`}}
|
||||
>
|
||||
<div className={'tooltip-content'}>
|
||||
{tooltipBodies[i]}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
))}
|
||||
</g>
|
119
src/components/d3/monitoring/D3MonitoringChartEditor.tsx
Normal file
119
src/components/d3/monitoring/D3MonitoringChartEditor.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import { Button, Checkbox, Form, FormItemProps, Input, InputNumber, Select, Tooltip } from 'antd'
|
||||
import { memo, useCallback, useEffect, useMemo } from 'react'
|
||||
|
||||
import { BaseDataType, MinMax } from '@components/d3/types'
|
||||
import { ColorPicker, Color } from '@components/ColorPicker'
|
||||
|
||||
import { ExtendedChartDataset } from './D3MonitoringCharts'
|
||||
|
||||
const { Item: RawItem } = Form
|
||||
|
||||
const Item = <Values,>({ style, ...other }: FormItemProps<Values>) => <RawItem<Values> style={{ margin: 0, marginBottom: 5, ...style }} {...other} />
|
||||
|
||||
const lineTypes = [
|
||||
{ value: 'line', label: 'Линия' },
|
||||
{ value: 'rect_area', label: 'Прямоугольная зона' },
|
||||
{ value: 'point', label: 'Точки' },
|
||||
{ value: 'area', label: 'Зона' },
|
||||
{ value: 'needle', label: 'Иглы' },
|
||||
]
|
||||
|
||||
export type D3MonitoringChartEditorProps<DataType extends BaseDataType> = {
|
||||
group: ExtendedChartDataset<DataType>[]
|
||||
chart: ExtendedChartDataset<DataType>
|
||||
onChange: (value: ExtendedChartDataset<DataType>) => boolean
|
||||
}
|
||||
|
||||
const _D3MonitoringChartEditor = <DataType extends BaseDataType>({
|
||||
group,
|
||||
chart: value,
|
||||
onChange,
|
||||
}: D3MonitoringChartEditorProps<DataType>) => {
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const onSave = useCallback((props: Partial<ExtendedChartDataset<DataType>>) => {
|
||||
const values = form.getFieldsValue()
|
||||
if (!values['label']) return
|
||||
const newValue = {
|
||||
...value,
|
||||
...values,
|
||||
...props
|
||||
}
|
||||
onChange(newValue)
|
||||
}, [value])
|
||||
|
||||
const onDomainChange = useCallback((mm: MinMax) => {
|
||||
onSave({
|
||||
xDomain: {
|
||||
min: ('min' in mm) ? mm.min : value.xDomain?.min,
|
||||
max: ('max' in mm) ? mm.max : value.xDomain?.max,
|
||||
}
|
||||
})
|
||||
}, [value])
|
||||
|
||||
const onColorChange = useCallback((color: Color) => {
|
||||
onSave({ color: color.toHexString() })
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
if (value.type)
|
||||
form.setFieldsValue({
|
||||
linkedTo: null,
|
||||
label: null,
|
||||
shortLabel: null,
|
||||
...value,
|
||||
})
|
||||
else
|
||||
form.resetFields()
|
||||
}, [value, form])
|
||||
|
||||
const options = useMemo(() => group.filter((chart) => chart.key !== value.key).map((chart) => ({
|
||||
value: chart.key,
|
||||
label: chart.label,
|
||||
})), [group, value])
|
||||
|
||||
return (
|
||||
<Form form={form}>
|
||||
<Tooltip title={'Возможность изменения типов линий будет добавлена в будущих обновлениях'}>
|
||||
<Item label={'Тип'} name={'type'}><Select disabled options={lineTypes} defaultValue={'Неизвестный'} /></Item>
|
||||
</Tooltip>
|
||||
<Item label={'Привязан к'} name={'linkedTo'}>
|
||||
<Select
|
||||
allowClear
|
||||
options={options}
|
||||
defaultValue={'Привязанный график отсутствует в группе'}
|
||||
onChange={(value) => onSave({ linkedTo: value })}
|
||||
/>
|
||||
</Item>
|
||||
<Item label={'Название'}>
|
||||
<Input.Group compact>
|
||||
<Item name={'label'} rules={[{ required: true }]}><Input placeholder={'Полное'} onChange={(e) => onSave({ label: e.target.value })} /></Item>
|
||||
<Item name={'shortLabel'}><Input placeholder={'Краткое'} onChange={(e) => onSave({ shortLabel: e.target.value })} /></Item>
|
||||
</Input.Group>
|
||||
</Item>
|
||||
<Item label={'Диапазон'}>
|
||||
<Input.Group compact>
|
||||
<InputNumber disabled={!!value.linkedTo} value={value.xDomain?.min} onChange={(min) => onDomainChange({ min: min ?? undefined })} placeholder={'Мин'} />
|
||||
<InputNumber disabled={!!value.linkedTo} value={value.xDomain?.max} onChange={(max) => onDomainChange({ max: max ?? undefined })} placeholder={'Макс'} />
|
||||
<Button
|
||||
disabled={!!value.linkedTo || (!Number.isFinite(value.xDomain?.min) && !Number.isFinite(value.xDomain?.max))}
|
||||
onClick={() => onDomainChange({ min: undefined, max: undefined })}
|
||||
>Авто</Button>
|
||||
</Input.Group>
|
||||
</Item>
|
||||
<Item label={'Цвет линий'}><ColorPicker onChange={onColorChange} value={value.color} /></Item>
|
||||
<Item>
|
||||
<Checkbox
|
||||
checked={value.showCurrentValue}
|
||||
onChange={(e) => onSave({ showCurrentValue: e.target.checked })}
|
||||
>
|
||||
Показать текущее значение сверху
|
||||
</Checkbox>
|
||||
</Item>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export const D3MonitoringChartEditor = memo(_D3MonitoringChartEditor) as typeof _D3MonitoringChartEditor
|
||||
|
||||
export default D3MonitoringChartEditor
|
@ -1,41 +1,50 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useElementSize } from 'usehooks-ts'
|
||||
import { Property } from 'csstype'
|
||||
import { Empty } from 'antd'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
import LoaderPortal from '@components/LoaderPortal'
|
||||
import { isDev, usePartialProps } from '@utils'
|
||||
import { isDev, useElementSize, usePartialProps, useUserSettings } from '@utils'
|
||||
|
||||
import {
|
||||
BaseDataType,
|
||||
ChartAxis,
|
||||
ChartDataset,
|
||||
ChartOffset,
|
||||
ChartRegistry,
|
||||
ChartTick,
|
||||
MinMax
|
||||
} from './types'
|
||||
} from '@components/d3/types'
|
||||
import {
|
||||
BasePluginSettings,
|
||||
D3ContextMenu,
|
||||
D3ContextMenuSettings,
|
||||
D3HorizontalCursor,
|
||||
D3HorizontalCursorSettings
|
||||
} from './plugins'
|
||||
import D3MouseZone from './D3MouseZone'
|
||||
import { getByAccessor, getChartClass, getGroupClass, getTicks } from './functions'
|
||||
import { renderArea, renderLine, renderNeedle, renderPoint, renderRectArea } from './renders'
|
||||
} from '@components/d3/plugins'
|
||||
import D3MouseZone from '@components/d3/D3MouseZone'
|
||||
import { getByAccessor, getChartClass, getGroupClass, getTicks } from '@components/d3/functions'
|
||||
import { renderArea, renderLine, renderNeedle, renderPoint, renderRectArea } from '@components/d3/renders'
|
||||
|
||||
import D3MonitoringEditor from './D3MonitoringEditor'
|
||||
import D3MonitoringCurrentValues from './D3MonitoringCurrentValues'
|
||||
import { D3HorizontalCursor, D3HorizontalCursorSettings } from './D3HorizontalCursor'
|
||||
import D3MonitoringLimitChart, { TelemetryRegulators } from './D3MonitoringLimitChart'
|
||||
|
||||
const roundTo = (v: number, to: number = 50) => {
|
||||
if (to == 0) return v
|
||||
if (v < 0) return Math.round(v / to) * to
|
||||
return Math.ceil(v / to) * to
|
||||
if (v === 0) return v
|
||||
return (v > 0 ? Math.ceil : Math.round)(v / to) * to
|
||||
}
|
||||
|
||||
const calculateDomain = (mm: MinMax, round: number = 100): Required<MinMax> => {
|
||||
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 round = getNear(Math.abs((mm.max ?? 0) - (mm.min ?? 0))) || 10
|
||||
let min = roundTo(mm.min ?? 0, round)
|
||||
let max = roundTo(mm.max ?? round, round)
|
||||
if (min - max < round) {
|
||||
if (round && Math.abs(min - max) < round) {
|
||||
const mid = (min + max) / 2
|
||||
min = mid - round
|
||||
max = mid + round
|
||||
@ -43,16 +52,18 @@ const calculateDomain = (mm: MinMax, round: number = 100): Required<MinMax> => {
|
||||
return { min, max }
|
||||
}
|
||||
|
||||
type ExtendedChartDataset<DataType> = ChartDataset<DataType> & {
|
||||
export type ExtendedChartDataset<DataType extends BaseDataType> = ChartDataset<DataType> & {
|
||||
/** Диапазон отображаемых значений по горизонтальной оси */
|
||||
xDomain: MinMax
|
||||
/** Скрыть отображение шкалы графика */
|
||||
hideLabel?: boolean
|
||||
/** Показать последнее значение сверху графика */
|
||||
showCurrentValue?: boolean
|
||||
}
|
||||
|
||||
type ExtendedChartRegistry<DataType> = ChartRegistry<DataType> & ExtendedChartDataset<DataType>
|
||||
export type ExtendedChartRegistry<DataType extends BaseDataType> = ChartRegistry<DataType> & ExtendedChartDataset<DataType>
|
||||
|
||||
export type ChartGroup<DataType> = {
|
||||
export type ChartGroup<DataType extends BaseDataType> = {
|
||||
/** Получить D3 выборку, содержащую корневой G-элемент группы */
|
||||
(): d3.Selection<SVGGElement, any, any, any>
|
||||
/** Уникальный ключ группы (индекс) */
|
||||
@ -62,18 +73,26 @@ export type ChartGroup<DataType> = {
|
||||
}
|
||||
|
||||
const defaultOffsets: ChartOffset = {
|
||||
top: 10,
|
||||
bottom: 10,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 100,
|
||||
right: 10,
|
||||
right: 20,
|
||||
}
|
||||
|
||||
const getDefaultYAxisConfig = <DataType,>(): ChartAxis<DataType> => ({
|
||||
const defaultRegulators: TelemetryRegulators = {
|
||||
1: { color: '#59B359', label: 'Скорость блока' },
|
||||
2: { color: '#FF0000', label: 'Давление' },
|
||||
3: { color: '#0000CC', label: 'Осевая нагрузка' },
|
||||
4: { color: '#990099', label: 'Момент на роторе' },
|
||||
5: { color: '#007070', label: 'Расход' },
|
||||
}
|
||||
|
||||
const getDefaultYAxisConfig = <DataType extends BaseDataType>(): ChartAxis<DataType> => ({
|
||||
type: 'time',
|
||||
accessor: (d: any) => new Date(d.date)
|
||||
})
|
||||
|
||||
const getDefaultYTicks = <DataType,>(): Required<ChartTick<DataType>> => ({
|
||||
const getDefaultYTicks = <DataType extends BaseDataType>(): Required<ChartTick<DataType>> => ({
|
||||
visible: false,
|
||||
format: (d: d3.NumberValue, idx: number, data?: DataType) => String(d),
|
||||
color: 'lightgray',
|
||||
@ -83,7 +102,7 @@ const getDefaultYTicks = <DataType,>(): Required<ChartTick<DataType>> => ({
|
||||
/**
|
||||
* @template DataType тип данных отображаемых записей
|
||||
*/
|
||||
export type D3MonitoringChartsProps<DataType extends Record<string, any>> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
export type D3MonitoringChartsProps<DataType extends BaseDataType> = Omit<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, 'ref'> & {
|
||||
/** Двумерный массив датасетов (группа-график) */
|
||||
datasetGroups: ExtendedChartDataset<DataType>[][]
|
||||
/** Ширина графика числом пикселей или CSS-значением (px/%/em/rem) */
|
||||
@ -96,6 +115,8 @@ export type D3MonitoringChartsProps<DataType extends Record<string, any>> = Reac
|
||||
loading?: boolean
|
||||
/** Массив отображаемых данных */
|
||||
data?: DataType[]
|
||||
/** Массив данных для прямоугольников */
|
||||
flowData?: DataType[]
|
||||
/** Отступы графика от края SVG */
|
||||
offset?: Partial<ChartOffset>
|
||||
/** Цвет фона в формате CSS-значения */
|
||||
@ -121,6 +142,11 @@ export type D3MonitoringChartsProps<DataType extends Record<string, any>> = Reac
|
||||
axisHeight?: number
|
||||
/** Отступ между группами графиков в пикселях (30 по умолчанию) */
|
||||
spaceBetweenGroups?: number
|
||||
/** Название графика для сохранения в базе */
|
||||
chartName?: string
|
||||
methods?: (value: {
|
||||
setSettingsVisible: (visible: boolean) => void
|
||||
}) => void
|
||||
}
|
||||
|
||||
export type ChartSizes = ChartOffset & {
|
||||
@ -156,6 +182,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
loading = false,
|
||||
datasetGroups,
|
||||
data,
|
||||
flowData,
|
||||
plugins,
|
||||
offset: _offset,
|
||||
yAxis: _yAxisConfig,
|
||||
@ -165,21 +192,27 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
axisHeight = 20,
|
||||
spaceBetweenGroups = 30,
|
||||
dash,
|
||||
chartName,
|
||||
methods,
|
||||
|
||||
className = '',
|
||||
style,
|
||||
...other
|
||||
}: D3MonitoringChartsProps<DataType>) => {
|
||||
const [datasets, setDatasets, resetDatasets] = useUserSettings(chartName, datasetGroups)
|
||||
const [regulators, setRegulators, resetRegulators] = useUserSettings(`${chartName}_regulators`, defaultRegulators)
|
||||
const [groups, setGroups] = useState<ChartGroup<DataType>[]>([])
|
||||
const [svgRef, setSvgRef] = useState<SVGSVGElement | null>(null)
|
||||
const [yAxisRef, setYAxisRef] = useState<SVGGElement | null>(null)
|
||||
const [chartAreaRef, setChartAreaRef] = useState<SVGGElement | null>(null)
|
||||
const [axesAreaRef, setAxesAreaRef] = useState<SVGGElement | null>(null)
|
||||
const [settingsVisible, setSettingsVisible] = useState<boolean>(false)
|
||||
|
||||
const offset = usePartialProps(_offset, defaultOffsets)
|
||||
const yTicks = usePartialProps<Required<ChartTick<DataType>>>(_yTicks, getDefaultYTicks)
|
||||
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 axesArea = useCallback(() => d3.select(axesAreaRef), [axesAreaRef])
|
||||
@ -206,35 +239,27 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
groupLeft: (i: number) => (groupWidth + spaceBetweenGroups) * i,
|
||||
axisTop: (i: number, count: number) => axisHeight * (maxChartCount - count + i + 1)
|
||||
})
|
||||
}, [groups, height, offset])
|
||||
}, [groups, width, height, offset, axisHeight, spaceBetweenGroups])
|
||||
|
||||
const yAxis = useMemo(() => {
|
||||
if (!data) return
|
||||
|
||||
const yAxis = d3.scaleTime()
|
||||
.domain([yDomain?.min ?? 0, yDomain?.max ?? 0])
|
||||
.domain([yDomain?.min || 0, yDomain?.max || 0])
|
||||
.range([0, sizes.chartsHeight])
|
||||
|
||||
return yAxis
|
||||
}, [groups, data, yDomain, sizes.chartsHeight])
|
||||
|
||||
const createAxesGroup = useCallback((i: number): ChartGroup<DataType> => Object.assign(
|
||||
() => chartArea().select('.' + getGroupClass(i)) as d3.Selection<SVGGElement, any, any, any>,
|
||||
{
|
||||
key: i,
|
||||
charts: [],
|
||||
}
|
||||
), [chartArea, axesArea])
|
||||
}, [groups, data, yDomain, sizes])
|
||||
|
||||
const chartDomains = useMemo(() => groups.map((group) => {
|
||||
const out: [string | number, ChartDomain][] = group.charts.map((chart) => {
|
||||
const mm = { ...chart.xDomain }
|
||||
let domain: Required<MinMax> = { min: 0, max: 100 }
|
||||
if (mm.min && mm.max) {
|
||||
if (!Number.isNaN((mm.min ?? NaN) + (mm.max ?? NaN))) {
|
||||
domain = mm as Required<MinMax>
|
||||
} else if (data) {
|
||||
const [min, max] = d3.extent(data, chart.x)
|
||||
domain = calculateDomain({ min, max, ...mm }, 100)
|
||||
domain = calculateDomain({ min, max, ...mm })
|
||||
}
|
||||
return [chart.key, {
|
||||
scale: d3.scaleLinear().domain([domain.min, domain.max]),
|
||||
@ -244,7 +269,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
|
||||
out.forEach(([key], i) => {
|
||||
const chart = group.charts.find((chart) => chart.key === key)
|
||||
const bind = chart?.bindDomainFrom
|
||||
const bind = chart?.linkedTo
|
||||
if (!bind) return
|
||||
const bindDomain = out.find(([key]) => key === bind)
|
||||
if (bindDomain)
|
||||
@ -254,14 +279,35 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
return Object.fromEntries(out)
|
||||
}), [groups, data])
|
||||
|
||||
useEffect(() => {
|
||||
const createAxesGroup = useCallback((i: number): ChartGroup<DataType> => Object.assign(
|
||||
() => chartArea().select('.' + getGroupClass(i)) as d3.Selection<SVGGElement, any, any, any>,
|
||||
{
|
||||
key: i,
|
||||
charts: [],
|
||||
}
|
||||
), [chartArea])
|
||||
|
||||
const onGroupsChange = useCallback((settings: ExtendedChartDataset<DataType>[][], regulators: TelemetryRegulators) => {
|
||||
setSettingsVisible(false)
|
||||
setRegulators(regulators)
|
||||
setDatasets(settings)
|
||||
}, [setDatasets, setRegulators])
|
||||
|
||||
const onGroupsReset = useCallback(() => {
|
||||
setSettingsVisible(false)
|
||||
resetRegulators()
|
||||
resetDatasets()
|
||||
}, [resetDatasets, resetRegulators])
|
||||
|
||||
useEffect(() => methods?.({ setSettingsVisible }), [methods]) /// Возвращаем в делегат доступные методы
|
||||
|
||||
useEffect(() => { /// Обновляем группы
|
||||
if (isDev()) {
|
||||
datasetGroups.forEach((sets, i) => {
|
||||
datasets.forEach((sets, i) => {
|
||||
sets.forEach((set, j) => {
|
||||
for (let k = j + 1; k < sets.length; k++) {
|
||||
for (let k = j + 1; k < sets.length; k++)
|
||||
if (set.key === sets[k].key)
|
||||
console.warn(`Ключ датасета "${set.key}" неуникален (группа ${i}, индексы ${j} и ${k})!`)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@ -269,15 +315,15 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
setGroups((oldGroups) => {
|
||||
const groups: ChartGroup<DataType>[] = []
|
||||
|
||||
if (datasetGroups.length < oldGroups.length) {
|
||||
if (datasets.length < oldGroups.length) {
|
||||
// Удаляем неактуальные группы
|
||||
oldGroups.slice(datasetGroups.length).forEach((group) => group().remove())
|
||||
groups.push(...oldGroups.slice(0, datasetGroups.length))
|
||||
oldGroups.slice(datasets.length).forEach((group) => group().remove())
|
||||
groups.push(...oldGroups.slice(0, datasets.length))
|
||||
} else {
|
||||
groups.push(...oldGroups)
|
||||
}
|
||||
|
||||
datasetGroups.forEach((datasets, i) => {
|
||||
datasets.forEach((datasets, i) => {
|
||||
let group: ChartGroup<DataType> = createAxesGroup(i)
|
||||
|
||||
if (group().empty())
|
||||
@ -305,7 +351,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
animDurationMs,
|
||||
...dataset,
|
||||
yAxis: dataset.yAxis ?? yAxisConfig,
|
||||
y: getByAccessor(dataset.yAxis.accessor ?? yAxisConfig.accessor),
|
||||
y: getByAccessor(dataset.yAxis?.accessor ?? yAxisConfig.accessor),
|
||||
x: getByAccessor(dataset.xAxis?.accessor),
|
||||
}
|
||||
)
|
||||
@ -326,10 +372,10 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
|
||||
return groups
|
||||
})
|
||||
}, [yAxisConfig, chartArea, datasetGroups, animDurationMs, createAxesGroup])
|
||||
}, [yAxisConfig, chartArea, datasets, animDurationMs, createAxesGroup])
|
||||
|
||||
useEffect(() => {
|
||||
const axesGroups = d3.select(axesAreaRef)
|
||||
useEffect(() => { /// Обновляем группы и горизонтальные оси
|
||||
const axesGroups = axesArea()
|
||||
.selectAll('.charts-group')
|
||||
.data(groups)
|
||||
|
||||
@ -338,7 +384,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
.append('g')
|
||||
.attr('class', 'charts-group')
|
||||
|
||||
const actualAxesGroups = d3.select(axesAreaRef)
|
||||
const actualAxesGroups = axesArea()
|
||||
.selectAll<SVGGElement | null, ChartGroup<DataType>>('.charts-group')
|
||||
.attr('class', (g) => `charts-group ${getGroupClass(g.key)}`)
|
||||
.attr('transform', (g) => `translate(${sizes.groupLeft(g.key)}, 0)`)
|
||||
@ -391,7 +437,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
.attr('stroke', j === chartsData.length - 1 ? 'gray' : 'currentColor')
|
||||
})
|
||||
})
|
||||
}, [groups, sizes, spaceBetweenGroups, chartDomains])
|
||||
}, [groups, sizes, spaceBetweenGroups, chartDomains, axesArea])
|
||||
|
||||
useEffect(() => { // Рисуем ось Y
|
||||
if (!yAxis) return
|
||||
@ -420,7 +466,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
groups.forEach((group, i) => {
|
||||
group()
|
||||
.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) => {
|
||||
chart()
|
||||
@ -449,7 +495,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
chartData = renderArea<DataType>(xAxis, yAxis, chart, chartData)
|
||||
break
|
||||
case 'rect_area':
|
||||
renderRectArea<DataType>(xAxis, yAxis, chart)
|
||||
renderRectArea<DataType>(xAxis, yAxis, chart, flowData)
|
||||
break
|
||||
default:
|
||||
break
|
||||
@ -463,7 +509,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
chart.afterDraw?.(chart)
|
||||
})
|
||||
})
|
||||
}, [data, groups, height, offset, sizes, chartDomains])
|
||||
}, [data, flowData, groups, height, offset, sizes, chartDomains, yAxis])
|
||||
|
||||
return (
|
||||
<LoaderPortal
|
||||
@ -471,6 +517,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
style={{
|
||||
width: givenWidth,
|
||||
height: givenHeight,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@ -479,10 +526,14 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
className={`asb-d3-chart ${className}`}
|
||||
>
|
||||
{data ? (
|
||||
<D3ContextMenu {...plugins?.menu} svg={svgRef}>
|
||||
<D3ContextMenu
|
||||
onSettingsOpen={() => setSettingsVisible(true)}
|
||||
{...plugins?.menu}
|
||||
svg={svgRef}
|
||||
>
|
||||
<svg ref={setSvgRef} width={'100%'} height={'100%'}>
|
||||
<defs>
|
||||
<clipPath id={`chart-clip`}>
|
||||
<clipPath id={`chart-group-clip`}>
|
||||
{/* Сдвиг во все стороны на 1 px чтобы линии на краях было видно */}
|
||||
<rect x={-1} y={-1} width={sizes.groupWidth + 2} height={sizes.chartsHeight + 2} />
|
||||
</clipPath>
|
||||
@ -500,13 +551,31 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
return <line key={`${i}`} x1={x} x2={x} y1={sizes.chartsTop} y2={offset.top + sizes.inlineHeight} />
|
||||
})}
|
||||
</g>
|
||||
<D3MonitoringLimitChart<DataType>
|
||||
regulators={regulators}
|
||||
data={data}
|
||||
yAxis={yAxis}
|
||||
width={20}
|
||||
height={sizes.chartsHeight}
|
||||
left={sizes.inlineWidth + sizes.left}
|
||||
top={sizes.chartsTop}
|
||||
zoneWidth={sizes.inlineWidth}
|
||||
/>
|
||||
<D3MonitoringCurrentValues<DataType>
|
||||
groups={groups}
|
||||
data={data}
|
||||
sizes={sizes}
|
||||
/>
|
||||
<D3MouseZone width={width} height={height} offset={{ ...offset, top: sizes.chartsTop }}>
|
||||
<D3HorizontalCursor
|
||||
{...plugins?.cursor}
|
||||
yAxis={yAxis}
|
||||
groups={groups}
|
||||
sizes={sizes}
|
||||
spaceBetweenGroups={spaceBetweenGroups}
|
||||
data={data}
|
||||
flowData={flowData}
|
||||
height={height}
|
||||
/>
|
||||
</D3MouseZone>
|
||||
</svg>
|
||||
@ -516,11 +585,19 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</div>
|
||||
)}
|
||||
<D3MonitoringEditor
|
||||
groups={datasets}
|
||||
regulators={regulators}
|
||||
visible={settingsVisible}
|
||||
onChange={onGroupsChange}
|
||||
onCancel={() => setSettingsVisible(false)}
|
||||
onReset={onGroupsReset}
|
||||
/>
|
||||
</div>
|
||||
</LoaderPortal>
|
||||
)
|
||||
}
|
||||
|
||||
export const D3MonitoringCharts = memo(_D3MonitoringCharts)
|
||||
export const D3MonitoringCharts = memo(_D3MonitoringCharts) as typeof _D3MonitoringCharts
|
||||
|
||||
export default D3MonitoringCharts
|
46
src/components/d3/monitoring/D3MonitoringCurrentValues.tsx
Normal file
46
src/components/d3/monitoring/D3MonitoringCurrentValues.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { memo } from 'react'
|
||||
|
||||
import { BaseDataType } from '@components/d3/types'
|
||||
import { ChartGroup, ChartSizes } from '@components/d3/monitoring/D3MonitoringCharts'
|
||||
import { makeDisplayValue } from '@utils'
|
||||
|
||||
export type D3MonitoringCurrentValuesProps<DataType extends BaseDataType> = {
|
||||
/** Группы графиков */
|
||||
groups: ChartGroup<DataType>[]
|
||||
/** Массив данных графика */
|
||||
data: DataType[]
|
||||
/** Объект, хранящий полезные размеры и отступы графика (нужен только groupWidth, chartsTop и groupLeft) */
|
||||
sizes: ChartSizes
|
||||
}
|
||||
|
||||
const display = makeDisplayValue({ def: '---', fixed: 2 })
|
||||
|
||||
/// `Array.at` вместе с `??` возвращает странный тип, поэтому его пока пришлось пометить как `any`
|
||||
/// TODO: Исправить тип
|
||||
const _D3MonitoringCurrentValues = <DataType extends BaseDataType>({ groups, data, sizes }: D3MonitoringCurrentValuesProps<DataType>) => (
|
||||
<g transform={`translate(${sizes.left}, ${sizes.chartsTop})`} pointerEvents={'none'}>
|
||||
{groups.map((group) => (
|
||||
<g key={group.key} transform={`translate(${sizes.groupLeft(group.key)}, 0)`}>
|
||||
{group.charts.filter((chart) => chart.showCurrentValue).map((chart, i) => (
|
||||
<g key={chart.key} stroke={'white'} fill={chart.color} strokeWidth={4} paintOrder={'stroke'} style={{ fontWeight: 600 }}>
|
||||
<text x={sizes.groupWidth / 2 - 10} textAnchor={'end'} y={15 + i * 20}>{chart.shortLabel ?? chart.label}:</text>
|
||||
<text x={sizes.groupWidth / 2 + 10} textAnchor={'start'} y={15 + i * 20}>{display(chart.x((data.at(-1) ?? {}) as any))}</text>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
|
||||
/**
|
||||
* Отрисовывает последние значения графиков
|
||||
*
|
||||
* @typeParam DataType - тип данных для отрисовки графиков
|
||||
*
|
||||
* @param groups - Массив групп графиков
|
||||
* @param data - Массив данных графиков
|
||||
* @param sizes - Объект с полезными размерами и отступами внутри svg
|
||||
*/
|
||||
export const D3MonitoringCurrentValues = memo(_D3MonitoringCurrentValues) as typeof _D3MonitoringCurrentValues
|
||||
|
||||
export default D3MonitoringCurrentValues
|
186
src/components/d3/monitoring/D3MonitoringEditor.tsx
Normal file
186
src/components/d3/monitoring/D3MonitoringEditor.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
import { CSSProperties, Key, memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Divider, Empty, Modal, Popconfirm, Tooltip, Tree, TreeDataNode } from 'antd'
|
||||
import { UndoOutlined } from '@ant-design/icons'
|
||||
import { EventDataNode } from 'antd/lib/tree'
|
||||
|
||||
import { notify } from '@components/factory'
|
||||
import { getChartIcon } from '@utils'
|
||||
|
||||
import { BaseDataType } from '../types'
|
||||
import { ExtendedChartDataset } from './D3MonitoringCharts'
|
||||
import { TelemetryRegulators } from './D3MonitoringLimitChart'
|
||||
import D3MonitoringChartEditor from './D3MonitoringChartEditor'
|
||||
import D3MonitoringLimitEditor from './D3MonitoringLimitEditor'
|
||||
|
||||
export type D3MonitoringGroupsEditorProps<DataType extends BaseDataType> = {
|
||||
visible?: boolean
|
||||
groups: ExtendedChartDataset<DataType>[][]
|
||||
regulators: TelemetryRegulators
|
||||
onChange: (value: ExtendedChartDataset<DataType>[][], regs: TelemetryRegulators) => void
|
||||
onCancel: () => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
const getChartLabel = <DataType extends BaseDataType>(chart: ExtendedChartDataset<DataType>) => (
|
||||
<Tooltip title={chart.label}>
|
||||
{getChartIcon(chart)} {chart.label}
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
const divStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexGrow: 1,
|
||||
}
|
||||
|
||||
const getNodePos = (node: EventDataNode<TreeDataNode>): { group: number, chart?: number } => {
|
||||
const out = node.pos.split('-').map(Number)
|
||||
return { group: out[1], chart: out[2] }
|
||||
}
|
||||
|
||||
type EditingMode = null | 'limit' | 'chart'
|
||||
|
||||
const _D3MonitoringEditor = <DataType extends BaseDataType>({
|
||||
visible,
|
||||
groups: oldGroups,
|
||||
regulators: oldRegulators,
|
||||
onChange,
|
||||
onCancel,
|
||||
onReset,
|
||||
}: D3MonitoringGroupsEditorProps<DataType>) => {
|
||||
const [groups, setGroups] = useState<ExtendedChartDataset<DataType>[][]>([])
|
||||
const [expand, setExpand] = useState<Key[]>([])
|
||||
const [selected, setSelected] = useState<Key[]>([])
|
||||
const [mode, setMode] = useState<EditingMode>(null)
|
||||
const [regulators, setRegulators] = useState<TelemetryRegulators>({})
|
||||
|
||||
useEffect(() => setGroups(oldGroups), [oldGroups])
|
||||
useEffect(() => setRegulators(oldRegulators), [oldRegulators])
|
||||
|
||||
const onModalOk = useCallback(() => onChange(groups, regulators), [groups, regulators])
|
||||
|
||||
const onDrop = useCallback((info: {
|
||||
node: EventDataNode<TreeDataNode>
|
||||
dragNode: EventDataNode<TreeDataNode>
|
||||
dropPosition: number
|
||||
}) => {
|
||||
const { dragNode, dropPosition, node } = info
|
||||
|
||||
const targetNodes = getNodePos(node)
|
||||
const dragNodes = getNodePos(dragNode)
|
||||
const groupPos = dragNodes.group
|
||||
if (!Number.isFinite(groupPos + dropPosition)) return
|
||||
setGroups((prev) => {
|
||||
const chartPos = dragNodes.chart
|
||||
if (typeof chartPos === 'undefined') {
|
||||
const groups = [ ...prev ]
|
||||
const movedGroups = groups.splice(groupPos, 1)
|
||||
groups.splice(Math.max(dropPosition - 1, 0), 0, ...movedGroups)
|
||||
return groups
|
||||
} else if (Number.isFinite(chartPos)) {
|
||||
const targetGroup = targetNodes.group
|
||||
const dragKey = prev[groupPos][chartPos].key
|
||||
if (groupPos !== targetGroup) {
|
||||
if (prev[targetGroup].find((chart) => chart.key === dragKey)) {
|
||||
notify('График с данным ключом уже существует в этой группе. Перемещение невозможно', 'warning')
|
||||
return prev
|
||||
}
|
||||
}
|
||||
const groups = [ ...prev ]
|
||||
const charts = groups[groupPos].filter((chart) => chart.key === dragKey || chart.linkedTo === dragKey)
|
||||
groups[groupPos] = groups[groupPos].filter((chart) => chart.key !== dragKey && chart.linkedTo !== dragKey)
|
||||
groups[targetGroup].splice(Math.max(dropPosition - 1, 0), 0, ...charts)
|
||||
return groups
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}, [])
|
||||
|
||||
const treeItems = useMemo(() => groups.map((group, i) => ({
|
||||
key: `0-${i}`,
|
||||
title: `Группа #${i} (${group.length})`,
|
||||
selectable: false,
|
||||
children: group.map((chart, j) => ({
|
||||
key: `0-${i}-${j}`,
|
||||
title: getChartLabel(chart),
|
||||
selectable: true,
|
||||
}))
|
||||
})), [groups])
|
||||
|
||||
const selectedIdx = useMemo(() => {
|
||||
if (!selected) return null
|
||||
const parts = String(selected[0]).split('-')
|
||||
const group = Number(parts[1])
|
||||
const chart = Number(parts[2])
|
||||
if (!Number.isFinite(group + chart)) return null
|
||||
return { group, chart }
|
||||
}, [selected])
|
||||
|
||||
const selectedGroup = useMemo(() => selectedIdx ? groups[selectedIdx.group] : null, [groups, selectedIdx])
|
||||
const selectedChart = useMemo(() => selectedIdx ? groups[selectedIdx.group][selectedIdx.chart] : null, [groups, selectedIdx])
|
||||
|
||||
const onChartChange = useCallback((chart: ExtendedChartDataset<DataType>) => {
|
||||
if (!selectedIdx) return false
|
||||
setGroups((prev) => {
|
||||
const groups = [ ...prev ]
|
||||
groups[selectedIdx.group][selectedIdx.chart] = chart
|
||||
return groups
|
||||
})
|
||||
return true
|
||||
}, [selectedIdx])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
centered
|
||||
width={800}
|
||||
open={visible}
|
||||
title={'Настройка групп графиков'}
|
||||
onCancel={onCancel}
|
||||
footer={(
|
||||
<>
|
||||
<Popconfirm title={'Вы уверены что хотите сбросить настройки графиков?'} onConfirm={onReset}>
|
||||
<Button icon={<UndoOutlined />}>Сброс</Button>
|
||||
</Popconfirm>
|
||||
<Button onClick={onCancel}>Отмена</Button>
|
||||
<Button type={'primary'} onClick={onModalOk}>Сохранить изменения</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'stretch', height: 250 }}>
|
||||
<div style={{ width: '35%' }}>
|
||||
<Tree
|
||||
draggable
|
||||
selectable
|
||||
onExpand={(keys: Key[]) => setExpand(keys)}
|
||||
expandedKeys={expand}
|
||||
selectedKeys={selected}
|
||||
treeData={treeItems}
|
||||
onDrop={onDrop}
|
||||
onSelect={(value: Key[]) => {
|
||||
setSelected(value)
|
||||
setMode('chart')
|
||||
}}
|
||||
height={250}
|
||||
/>
|
||||
{/* <Button onClick={() => setMode('limit')}>Ограничение подачи</Button> */}
|
||||
</div>
|
||||
<Divider type={'vertical'} style={{ height: '100%', padding: '0 5px' }} />
|
||||
<div style={divStyle}>
|
||||
{mode === 'chart' && selectedGroup && selectedChart ? (
|
||||
<D3MonitoringChartEditor<DataType> group={selectedGroup} chart={selectedChart} onChange={onChartChange} />
|
||||
) : (mode === 'limit' ? (
|
||||
<D3MonitoringLimitEditor<DataType> value={regulators} onChange={setRegulators} />
|
||||
) : (
|
||||
<Empty description={'Выберите график для редактирования'} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export const D3MonitoringEditor = memo(_D3MonitoringEditor) as typeof _D3MonitoringEditor
|
||||
|
||||
export default D3MonitoringEditor
|
200
src/components/d3/monitoring/D3MonitoringLimitChart.tsx
Normal file
200
src/components/d3/monitoring/D3MonitoringLimitChart.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
import { CSSProperties, memo, useEffect, useMemo, useState } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
import { Grid, GridItem } from '@components/Grid'
|
||||
import { formatDate, makeDisplayValue } from '@utils'
|
||||
import { TelemetryDataSaubDto } from '@api'
|
||||
|
||||
type LimitChartData = {
|
||||
id: number
|
||||
dateStart: Date
|
||||
dateEnd: Date
|
||||
depthStart: number | null
|
||||
depthEnd: number | null
|
||||
}
|
||||
|
||||
type LimitChartDataRaw = {
|
||||
id?: number
|
||||
dateStart?: string
|
||||
dateEnd?: string
|
||||
depthStart?: number | null
|
||||
depthEnd?: number | null
|
||||
}
|
||||
|
||||
export type TelemetryRegulators = Record<number, {
|
||||
color: string
|
||||
label: string
|
||||
}>
|
||||
|
||||
const getLast = (out: LimitChartDataRaw[]) => out.at(-1) as LimitChartDataRaw
|
||||
function isDataCorrect(value: LimitChartDataRaw): value is Required<LimitChartDataRaw> {
|
||||
return typeof value.id !== 'undefined'
|
||||
}
|
||||
|
||||
const calcualteData = <DataType extends TelemetryDataSaubDto>(data: DataType[]) => {
|
||||
const out = data.filter((row) => row.dateTime).reduce((out, row) => {
|
||||
const last = getLast(out)
|
||||
if (last.id === row.idFeedRegulator) {
|
||||
if (!row.idFeedRegulator) return out
|
||||
last.dateEnd = row.dateTime
|
||||
last.depthEnd = row.wellDepth
|
||||
} else {
|
||||
let n: LimitChartDataRaw = {}
|
||||
if (row.idFeedRegulator) {
|
||||
n = {
|
||||
id: row.idFeedRegulator,
|
||||
dateStart: row.dateTime,
|
||||
dateEnd: row.dateTime,
|
||||
depthStart: row.wellDepth,
|
||||
depthEnd: row.wellDepth,
|
||||
}
|
||||
}
|
||||
out.push(n)
|
||||
}
|
||||
return out
|
||||
}, [{}] as LimitChartDataRaw[])
|
||||
|
||||
return out.filter(isDataCorrect).map<LimitChartData>((row) => ({
|
||||
id: row.id,
|
||||
dateStart: new Date(row.dateStart),
|
||||
dateEnd: new Date(row.dateEnd),
|
||||
depthStart: row.depthStart,
|
||||
depthEnd: row.depthEnd,
|
||||
}))
|
||||
}
|
||||
|
||||
export type D3MonitoringLimitChartProps<DataType> = {
|
||||
yAxis?: d3.ScaleTime<number, number, never>
|
||||
regulators: TelemetryRegulators
|
||||
data: DataType[]
|
||||
width: number
|
||||
height: number
|
||||
left: number
|
||||
top: number
|
||||
zoneWidth?: number
|
||||
}
|
||||
|
||||
const tooltipWidth = 270
|
||||
const tooltipHeight = 120
|
||||
|
||||
const displayValue = makeDisplayValue()
|
||||
|
||||
const _D3MonitoringLimitChart = <DataType extends TelemetryDataSaubDto>({
|
||||
yAxis,
|
||||
data: chartData,
|
||||
width,
|
||||
height,
|
||||
left,
|
||||
top,
|
||||
regulators,
|
||||
zoneWidth = 0,
|
||||
}: D3MonitoringLimitChartProps<DataType>) => {
|
||||
const [ref, setRef] = useState<SVGGElement | null>(null)
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref || !yAxis) return
|
||||
const elms = d3.select(ref).select('.bars').selectAll<SVGRectElement, unknown>('rect').data(data)
|
||||
|
||||
elms.exit().remove()
|
||||
const newElms = elms.enter().append('rect')
|
||||
|
||||
elms.merge(newElms)
|
||||
.attr('width', width)
|
||||
.attr('height', (d) => Math.max(yAxis(d.dateEnd) - yAxis(d.dateStart), 1))
|
||||
.attr('y', (d) => yAxis(d.dateStart))
|
||||
.attr('fill', (d) => regulators[d.id]?.color || 'black')
|
||||
.on('mouseover', (_, d) => {
|
||||
const y = yAxis(d.dateStart) - tooltipHeight
|
||||
setSelected({ ...d, y, x: -tooltipWidth - 10, visible: true })
|
||||
})
|
||||
.on('mouseout', () => setSelected((pre) => pre ? ({ ...pre, visible: false }) : undefined))
|
||||
}, [yAxis, data, ref, width])
|
||||
|
||||
const zoneY1 = useMemo(() => yAxis && selected ? yAxis(selected.dateStart) : 0, [yAxis, selected])
|
||||
const zoneY2 = useMemo(() => yAxis && selected ? yAxis(selected.dateEnd) : 0, [yAxis, selected])
|
||||
|
||||
const opacityStyle: CSSProperties = useMemo(() => ({
|
||||
transition: 'opacity .1s ease-in-out',
|
||||
opacity: selected?.visible ? 1 : 0,
|
||||
}), [selected])
|
||||
|
||||
const tooltipStyle: CSSProperties = useMemo(() => ({
|
||||
...opacityStyle,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}), [opacityStyle])
|
||||
|
||||
return (
|
||||
<g transform={`translate(${left}, ${top})`} stroke={'#333'} strokeWidth={1} fill={'none'}>
|
||||
<defs>
|
||||
<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 && (
|
||||
<g
|
||||
style={opacityStyle}
|
||||
pointerEvents={'none'}
|
||||
strokeOpacity={0.4}
|
||||
stroke={selectedRegulator?.color}
|
||||
clipPath={`url(#chart-limit-fill-clip)`}
|
||||
>
|
||||
<line x1={-zoneWidth} x2={0} y1={zoneY1} y2={zoneY1} />
|
||||
<line x1={-zoneWidth} x2={0} y1={zoneY2} y2={zoneY2} />
|
||||
<rect
|
||||
opacity={0.1}
|
||||
stroke={'none'}
|
||||
x={-zoneWidth}
|
||||
y={zoneY1}
|
||||
width={zoneWidth}
|
||||
height={zoneY2 - zoneY1}
|
||||
fill={selectedRegulator?.color}
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
<rect x={0} y={0} width={width} height={height} />
|
||||
{selected && (
|
||||
<foreignObject width={tooltipWidth} height={tooltipHeight} x={selected.x} y={selected.y} pointerEvents={'none'}>
|
||||
<div className={'tooltip bottom'} style={tooltipStyle}>
|
||||
<span>Ограничивающий параметр</span>
|
||||
<span>{selectedRegulator?.label}</span>
|
||||
<Grid style={{ margin: 0, padding: 0 }}>
|
||||
<GridItem row={1} col={1}>Начало:</GridItem>
|
||||
<GridItem row={1} col={2}>{formatDate(selected.dateStart)}</GridItem>
|
||||
<GridItem row={1} col={3}>{displayValue(selected.depthStart)}</GridItem>
|
||||
<GridItem row={1} col={4}>м.</GridItem>
|
||||
<GridItem row={2} col={1}>Конец:</GridItem>
|
||||
<GridItem row={2} col={2}>{formatDate(selected.dateEnd)}</GridItem>
|
||||
<GridItem row={2} col={3}>{displayValue(selected.depthEnd)}</GridItem>
|
||||
<GridItem row={2} col={4}>м.</GridItem>
|
||||
</Grid>
|
||||
</div>
|
||||
</foreignObject>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
export const D3MonitoringLimitChart = memo(_D3MonitoringLimitChart) as typeof _D3MonitoringLimitChart
|
||||
|
||||
export default D3MonitoringLimitChart
|
18
src/components/d3/monitoring/D3MonitoringLimitEditor.tsx
Normal file
18
src/components/d3/monitoring/D3MonitoringLimitEditor.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { memo } from 'react'
|
||||
import { TelemetryRegulators } from './D3MonitoringLimitChart'
|
||||
|
||||
export type D3MonitoringLimitEditorProps = {
|
||||
value: TelemetryRegulators
|
||||
onChange: (value: TelemetryRegulators) => void
|
||||
}
|
||||
|
||||
const _D3MonitoringLimitEditor = <DataType,>({ value, onChange }: D3MonitoringLimitEditorProps) => {
|
||||
|
||||
return (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
|
||||
export const D3MonitoringLimitEditor = memo(_D3MonitoringLimitEditor) as typeof _D3MonitoringLimitEditor
|
||||
|
||||
export default D3MonitoringLimitEditor
|
1
src/components/d3/monitoring/index.ts
Normal file
1
src/components/d3/monitoring/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './D3MonitoringCharts'
|
@ -8,7 +8,7 @@ import { BasePluginSettings } from './base'
|
||||
|
||||
export type D3ContextMenuSettings = {
|
||||
/** Метод или объект отрисовки пунктов выпадающего меню */
|
||||
overlay?: FunctionalValue<(svg: SVGSVGElement | null) => ReactElement | null>
|
||||
overlay?: FunctionalValue<(svg: SVGSVGElement | null, onUpdate?: () => void, onSettingsOpen?: () => void) => ReactElement | null>
|
||||
/** Название графика для загрузки */
|
||||
downloadFilename?: string
|
||||
/** Событие, вызываемое при нажатий кнопки "Обновить" */
|
||||
@ -23,6 +23,8 @@ export type D3ContextMenuProps = BasePluginSettings & D3ContextMenuSettings & {
|
||||
children: any
|
||||
/** SVG-элемент */
|
||||
svg: SVGSVGElement | null
|
||||
/** Событие, вызываемое при нажатий кнопки "Настройки" */
|
||||
onSettingsOpen?: () => void
|
||||
}
|
||||
|
||||
export const D3ContextMenu = memo<D3ContextMenuProps>(({
|
||||
@ -30,6 +32,7 @@ export const D3ContextMenu = memo<D3ContextMenuProps>(({
|
||||
downloadFilename = 'chart',
|
||||
additionalMenuItems,
|
||||
onUpdate,
|
||||
onSettingsOpen,
|
||||
trigger = ['contextMenu'],
|
||||
enabled = true,
|
||||
children,
|
||||
@ -43,6 +46,9 @@ export const D3ContextMenu = memo<D3ContextMenuProps>(({
|
||||
if (onUpdate)
|
||||
menuItems.push({ key: 'refresh', label: 'Обновить', onClick: onUpdate })
|
||||
|
||||
if (onSettingsOpen)
|
||||
menuItems.push({ key: 'settings', label: 'Настройки', onClick: onSettingsOpen })
|
||||
|
||||
if (svg)
|
||||
menuItems.push({ key: 'download', label: (
|
||||
<a href={svgToDataURL(svg)} download={`${downloadFilename}.svg`}>Сохранить</a>
|
||||
@ -52,11 +58,11 @@ export const D3ContextMenu = memo<D3ContextMenuProps>(({
|
||||
menuItems.push(...additionalMenuItems)
|
||||
|
||||
return menuItems
|
||||
}, [svg, downloadFilename, onUpdate, additionalMenuItems])
|
||||
}, [svg, downloadFilename, onUpdate, onSettingsOpen, additionalMenuItems])
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlay={overlay(svg) || ( <Menu items={menuItems} /> )}
|
||||
overlay={overlay(svg, onUpdate, onSettingsOpen) || ( <Menu items={menuItems} /> )}
|
||||
disabled={!enabled}
|
||||
trigger={trigger}
|
||||
>
|
||||
|
@ -6,7 +6,7 @@ import { usePartialProps } from '@utils'
|
||||
|
||||
import { wrapPlugin } from './base'
|
||||
|
||||
import '@styles/d3.less'
|
||||
import '@styles/components/d3.less'
|
||||
|
||||
export type D3CursorSettings = {
|
||||
/** Параметры стиля линии */
|
||||
|
@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef } from 'react'
|
||||
import { Property } from 'csstype'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
import { ChartRegistry } from '@components/d3/types'
|
||||
import { BaseDataType, ChartRegistry } from '@components/d3/types'
|
||||
import { useD3MouseZone } from '@components/d3/D3MouseZone'
|
||||
import { usePartialProps } from '@utils'
|
||||
|
||||
@ -32,12 +32,12 @@ export type D3LegendSettings = {
|
||||
|
||||
const defaultOffset = { x: 10, y: 10 }
|
||||
|
||||
export type D3LegendProps<DataType> = D3LegendSettings & {
|
||||
export type D3LegendProps<DataType extends BaseDataType> = D3LegendSettings & {
|
||||
/** Массив графиков */
|
||||
charts: ChartRegistry<DataType>[]
|
||||
}
|
||||
|
||||
const _D3Legend = <DataType,>({
|
||||
const _D3Legend = <DataType extends BaseDataType>({
|
||||
charts,
|
||||
width,
|
||||
height,
|
||||
|
@ -4,15 +4,15 @@ import * as d3 from 'd3'
|
||||
|
||||
import { isDev } from '@utils'
|
||||
|
||||
import { ChartRegistry } from '@components/d3/types'
|
||||
import { BaseDataType, ChartRegistry } from '@components/d3/types'
|
||||
import { D3MouseState, useD3MouseZone } from '@components/d3/D3MouseZone'
|
||||
import { getTouchedElements, wrapPlugin } from './base'
|
||||
|
||||
import '@styles/d3.less'
|
||||
import '@styles/components/d3.less'
|
||||
|
||||
export type D3TooltipPosition = 'bottom' | 'top' | 'left' | 'right' | 'none'
|
||||
|
||||
export type D3RenderData<DataType> = {
|
||||
export type D3RenderData<DataType extends BaseDataType> = {
|
||||
/** Параметры графика */
|
||||
chart: ChartRegistry<DataType>
|
||||
/** Данные графика */
|
||||
@ -21,9 +21,9 @@ export type D3RenderData<DataType> = {
|
||||
selection?: d3.Selection<any, DataType, any, any>
|
||||
}
|
||||
|
||||
export type D3RenderFunction<DataType> = (data: D3RenderData<DataType>[], mouseState: D3MouseState) => ReactNode
|
||||
export type D3RenderFunction<DataType extends BaseDataType> = (data: D3RenderData<DataType>[], mouseState: D3MouseState) => ReactNode
|
||||
|
||||
export type D3TooltipSettings<DataType> = {
|
||||
export type D3TooltipSettings<DataType extends BaseDataType> = {
|
||||
/** Функция отрисоки тултипа */
|
||||
render?: D3RenderFunction<DataType>
|
||||
/** Ширина тултипа */
|
||||
@ -39,7 +39,7 @@ export type D3TooltipSettings<DataType> = {
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export const makeDefaultRender = <DataType,>(): D3RenderFunction<DataType> => (data, mouseState) => (
|
||||
export const makeDefaultRender = <DataType extends BaseDataType>(): D3RenderFunction<DataType> => (data, mouseState) => (
|
||||
<>
|
||||
{data.length > 0 ? data.map(({ chart, data }) => {
|
||||
let Icon
|
||||
@ -74,11 +74,11 @@ export const makeDefaultRender = <DataType,>(): D3RenderFunction<DataType> => (d
|
||||
</>
|
||||
)
|
||||
|
||||
export type D3TooltipProps<DataType> = Partial<D3TooltipSettings<DataType>> & {
|
||||
export type D3TooltipProps<DataType extends BaseDataType> = Partial<D3TooltipSettings<DataType>> & {
|
||||
charts: ChartRegistry<DataType>[],
|
||||
}
|
||||
|
||||
function _D3Tooltip<DataType extends Record<string, unknown>>({
|
||||
function _D3Tooltip<DataType extends BaseDataType>({
|
||||
width = 200,
|
||||
height = 120,
|
||||
render = makeDefaultRender<DataType>(),
|
||||
@ -110,7 +110,7 @@ function _D3Tooltip<DataType extends Record<string, unknown>>({
|
||||
if (!unsubscribe() && isDev())
|
||||
console.warn('Не удалось отвязать эвент')
|
||||
}
|
||||
}, [visible])
|
||||
}, [subscribe, visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!tooltipRef.current || !zoneRect || fixed) return
|
||||
|
@ -3,7 +3,7 @@ import * as d3 from 'd3'
|
||||
|
||||
import { getDistance, TouchType } from '@utils'
|
||||
|
||||
import { ChartRegistry } from '../types'
|
||||
import { BaseDataType, ChartRegistry } from '../types'
|
||||
|
||||
export type BasePluginSettings = {
|
||||
enabled?: boolean
|
||||
@ -16,7 +16,7 @@ export const wrapPlugin = <TProps,>(
|
||||
const wrappedComponent = ({ enabled, ...props }: TProps & BasePluginSettings) => {
|
||||
if (!(enabled ?? defaultEnabled)) return <></>
|
||||
|
||||
return <Component {...(props as TProps)} />
|
||||
return <Component {...(props as (TProps & JSX.IntrinsicAttributes))} /> // IntrinsicAttributes добавлено как необходимое ограничение
|
||||
}
|
||||
|
||||
return wrappedComponent
|
||||
@ -89,7 +89,7 @@ const makeIsRectTouched = (x: number, y: number, limit: number, type: TouchType
|
||||
}
|
||||
}
|
||||
|
||||
export const getTouchedElements = <DataType,>(
|
||||
export const getTouchedElements = <DataType extends BaseDataType>(
|
||||
chart: ChartRegistry<DataType>,
|
||||
x: number,
|
||||
y: number,
|
||||
|
@ -1,6 +1,5 @@
|
||||
export * from './base'
|
||||
export * from './D3ContextMenu'
|
||||
export * from './D3Cursor'
|
||||
export * from './D3HorizontalCursor'
|
||||
export * from './D3Legend'
|
||||
export * from './D3Tooltip'
|
||||
|
@ -1,8 +1,8 @@
|
||||
import * as d3 from 'd3'
|
||||
|
||||
import { ChartRegistry } from '../types'
|
||||
import { BaseDataType, ChartRegistry } from '../types'
|
||||
|
||||
export const appendTransition = <DataType, BaseType extends d3.BaseType, Datum, PElement extends d3.BaseType, PDatum>(
|
||||
export const appendTransition = <DataType extends BaseDataType, BaseType extends d3.BaseType, Datum, PElement extends d3.BaseType, PDatum>(
|
||||
elms: d3.Selection<BaseType, Datum, PElement, PDatum>,
|
||||
chart: ChartRegistry<DataType>
|
||||
): d3.Selection<BaseType, Datum, PElement, PDatum> => {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { ChartRegistry, PointChartDataset } from '@components/d3/types'
|
||||
import { BaseDataType, ChartRegistry, PointChartDataset } from '@components/d3/types'
|
||||
|
||||
import { appendTransition } from './base'
|
||||
|
||||
@ -12,7 +12,7 @@ const defaultConfig: Required<Omit<PointChartDataset, 'type'>> = {
|
||||
fillOpacity: 1,
|
||||
}
|
||||
|
||||
const getPointsRoot = <DataType,>(chart: ChartRegistry<DataType>, embeded?: boolean): d3.Selection<SVGGElement, any, any, any> => {
|
||||
const getPointsRoot = <DataType extends BaseDataType>(chart: ChartRegistry<DataType>, embeded?: boolean): d3.Selection<SVGGElement, any, any, any> => {
|
||||
const root = chart()
|
||||
if (!embeded) return root
|
||||
if (root.select('.points').empty())
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { getByAccessor } from '@components/d3/functions'
|
||||
import { ChartRegistry } from '@components/d3/types'
|
||||
import { BaseDataType, ChartRegistry } from '@components/d3/types'
|
||||
|
||||
import { appendTransition } from './base'
|
||||
|
||||
export const renderRectArea = <DataType extends Record<string, any>>(
|
||||
export const renderRectArea = <DataType extends BaseDataType>(
|
||||
xAxis: (value: d3.NumberValue) => number,
|
||||
yAxis: (value: d3.NumberValue) => number,
|
||||
chart: ChartRegistry<DataType>
|
||||
chart: ChartRegistry<DataType>,
|
||||
data: DataType[] | undefined,
|
||||
) => {
|
||||
if (
|
||||
chart.type !== 'rect_area' ||
|
||||
@ -14,16 +15,18 @@ export const renderRectArea = <DataType extends Record<string, any>>(
|
||||
!chart.maxXAccessor ||
|
||||
!chart.minYAccessor ||
|
||||
!chart.maxYAccessor ||
|
||||
!chart.data
|
||||
!data
|
||||
) return
|
||||
|
||||
const data = chart.data
|
||||
const xMin = getByAccessor(chart.minXAccessor)
|
||||
const xMax = getByAccessor(chart.maxXAccessor)
|
||||
const yMin = getByAccessor(chart.minYAccessor)
|
||||
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)
|
||||
|
||||
@ -31,8 +34,8 @@ export const renderRectArea = <DataType extends Record<string, any>>(
|
||||
rects.enter().append('rect')
|
||||
|
||||
appendTransition(chart().selectAll<SVGRectElement, Record<string, any>>('rect'), chart)
|
||||
.attr('x1', (d) => xAxis(xMin(d)))
|
||||
.attr('x2', (d) => xAxis(xMax(d)))
|
||||
.attr('y1', (d) => yAxis(yMin(d)))
|
||||
.attr('y2', (d) => yAxis(yMax(d)))
|
||||
.attr('x', (d) => xAxis(xMin(d)))
|
||||
.attr('y', (d) => yAxis(yMin(d)))
|
||||
.attr('width', (d) => xAxis(xMax(d)) - xAxis(xMin(d)))
|
||||
.attr('height', (d) => yAxis(yMax(d)) - yAxis(yMin(d)))
|
||||
}
|
||||
|
@ -3,9 +3,11 @@ import { Property } from 'csstype'
|
||||
|
||||
import { D3TooltipSettings } from './plugins'
|
||||
|
||||
export type AxisAccessor<DataType extends Record<string, any>> = keyof DataType | ((d: DataType) => any)
|
||||
export type BaseDataType = Record<string, any>
|
||||
|
||||
export type ChartAxis<DataType> = {
|
||||
export type AxisAccessor<DataType extends BaseDataType> = keyof DataType | ((d: DataType) => any)
|
||||
|
||||
export type ChartAxis<DataType extends BaseDataType> = {
|
||||
/** Тип шкалы */
|
||||
type: 'linear' | 'time',
|
||||
/** Ключ записи или метод по которому будет извлекаться значение оси из массива данных */
|
||||
@ -34,7 +36,7 @@ export type PointChartDataset = {
|
||||
fillOpacity?: number
|
||||
}
|
||||
|
||||
export type BaseChartDataset<DataType> = {
|
||||
export type BaseChartDataset<DataType extends BaseDataType> = {
|
||||
/** Уникальный ключ графика */
|
||||
key: string | number
|
||||
/** Параметры вертикальной оси */
|
||||
@ -62,7 +64,7 @@ export type BaseChartDataset<DataType> = {
|
||||
/** Параметры штриховки графика */
|
||||
dash?: string | number | [string | number, string | number]
|
||||
/** Привязка домена к домену другого графика */
|
||||
bindDomainFrom?: string | number
|
||||
linkedTo?: string | number
|
||||
}
|
||||
|
||||
export type LineChartDataset = {
|
||||
@ -101,7 +103,7 @@ export type NeedleChartDataset = {
|
||||
type: 'needle'
|
||||
}
|
||||
|
||||
export type ChartDataset<DataType> = BaseChartDataset<DataType> & (
|
||||
export type ChartDataset<DataType extends BaseDataType> = BaseChartDataset<DataType> & (
|
||||
AreaChartDataset |
|
||||
LineChartDataset |
|
||||
NeedleChartDataset |
|
||||
@ -154,7 +156,7 @@ export type ChartTicks<DataType> = {
|
||||
y?: ChartTick<DataType>
|
||||
}
|
||||
|
||||
export type ChartRegistry<DataType> = ChartDataset<DataType> & {
|
||||
export type ChartRegistry<DataType extends BaseDataType> = ChartDataset<DataType> & {
|
||||
/** Получить D3 выборку, содержащую корневой G-элемент графика */
|
||||
(): d3.Selection<SVGGElement, DataType, any, any>
|
||||
/** Получить значение по вертикальной оси из предоставленой записи */
|
||||
|
54
src/components/factory.ts → src/components/factory.tsx
Executable file → Normal file
54
src/components/factory.ts → src/components/factory.tsx
Executable file → Normal file
@ -2,9 +2,9 @@ import { notification } from 'antd'
|
||||
import { ArgsProps } from 'antd/lib/notification'
|
||||
import { Dispatch, ReactNode, SetStateAction } from 'react'
|
||||
|
||||
import { FunctionalValue, getFunctionalValue, isDev } from '@utils'
|
||||
import { getUserToken } from '@utils'
|
||||
import { ApiError, FileInfoDto } from '@api'
|
||||
import { WellView } from '@components/views'
|
||||
import { FunctionalValue, getFunctionalValue, getUser, isDev } from '@utils'
|
||||
import { ApiError, FileInfoDto, WellDto } from '@api'
|
||||
|
||||
export type NotifyType = 'error' | 'warning' | 'info'
|
||||
|
||||
@ -21,31 +21,53 @@ const notifyTypes: Record<NotifyType | 'defualt', ArgsProps & { instance: (args:
|
||||
* @param notifyType для параметра типа. Допустимые значение 'error', 'warning', 'info'
|
||||
* @param other прочие возможные аргументы уведомления
|
||||
*/
|
||||
export const notify = (body?: ReactNode, notifyType: NotifyType = 'info', other?: ArgsProps) => {
|
||||
export const notify = (body?: ReactNode, notifyType: NotifyType = 'info', well?: WellDto, other?: ArgsProps) => {
|
||||
if (!body) return
|
||||
|
||||
const instance = notifyTypes[notifyType] ?? notifyTypes.defualt
|
||||
|
||||
const message = (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>{instance.message}</span>
|
||||
<WellView placement={'leftBottom'} well={well} />
|
||||
</div>
|
||||
)
|
||||
|
||||
instance?.instance({
|
||||
...instance,
|
||||
description: body,
|
||||
placement: 'bottomRight',
|
||||
duration: 10,
|
||||
...instance,
|
||||
message,
|
||||
...other
|
||||
})
|
||||
}
|
||||
|
||||
export const copyToClipboard = (value: string, successText?: string, errorText?: string) => {
|
||||
try {
|
||||
navigator.clipboard.writeText(value)
|
||||
notify(successText ?? 'Текст успешно скопирован в буфер обмена', 'info')
|
||||
} catch (ex) {
|
||||
notify(errorText ?? 'Не удалось скопировать текст в буфер обмена', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
type asyncFunction = (...args: any) => Promise<any|void>
|
||||
|
||||
const parseApiEror = (err: unknown, actionName?: string) => {
|
||||
type InvokeOptions = {
|
||||
actionName?: string,
|
||||
well?: WellDto,
|
||||
}
|
||||
|
||||
const parseApiEror = (err: unknown, options?: InvokeOptions) => {
|
||||
if (!(err instanceof ApiError)) return false
|
||||
|
||||
switch (err.status) {
|
||||
case 403:
|
||||
if (actionName)
|
||||
notify(`Недостаточно прав для выполнения действия "${actionName}"`, 'error')
|
||||
if (options?.actionName)
|
||||
notify(`Недостаточно прав для выполнения действия "${options.actionName}"`, 'error', options.well)
|
||||
else
|
||||
notify('Недостаточно прав для выполнения действия', 'error')
|
||||
notify('Недостаточно прав для выполнения действия', 'error', options?.well)
|
||||
return true
|
||||
case 204: return true
|
||||
default: return false
|
||||
@ -56,7 +78,7 @@ export const invokeWebApiWrapperAsync = async (
|
||||
funcAsync: asyncFunction,
|
||||
setShowLoader?: Dispatch<SetStateAction<boolean>>,
|
||||
errorNotifyText?: FunctionalValue<(err: unknown) => ReactNode>,
|
||||
actionName?: string,
|
||||
options?: InvokeOptions,
|
||||
) => {
|
||||
setShowLoader?.(true)
|
||||
try{
|
||||
@ -64,8 +86,8 @@ export const invokeWebApiWrapperAsync = async (
|
||||
} catch (ex) {
|
||||
if(isDev())
|
||||
console.error(ex)
|
||||
if (!parseApiEror(ex, actionName))
|
||||
notify(getFunctionalValue(errorNotifyText)(ex), 'error')
|
||||
if (!parseApiEror(ex, options))
|
||||
notify(getFunctionalValue(errorNotifyText)(ex), 'error', options?.well)
|
||||
} finally {
|
||||
setShowLoader?.(false)
|
||||
}
|
||||
@ -74,7 +96,7 @@ export const invokeWebApiWrapperAsync = async (
|
||||
export const download = async (url: string, fileName?: string) => {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${getUserToken()}`
|
||||
Authorization: `Bearer ${getUser().token}`
|
||||
},
|
||||
method: 'Get'
|
||||
})
|
||||
@ -102,7 +124,7 @@ export const download = async (url: string, fileName?: string) => {
|
||||
export const upload = async (url: string, formData: FormData) => {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${getUserToken()}`
|
||||
Authorization: `Bearer ${getUser().token}`
|
||||
},
|
||||
method: 'Post',
|
||||
body: formData,
|
||||
@ -110,12 +132,12 @@ export const upload = async (url: string, formData: FormData) => {
|
||||
return response
|
||||
}
|
||||
|
||||
export const downloadFile = async (fileInfo: FileInfoDto) => {
|
||||
export const downloadFile = async (fileInfo: FileInfoDto, well?: WellDto) => {
|
||||
try {
|
||||
await download(`/api/well/${fileInfo.idWell}/files/${fileInfo.id}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
notify(`Не удалось скачать файл "${fileInfo.name}" по скважине №${fileInfo.idWell}`, 'error')
|
||||
notify(`Не удалось скачать файл "${fileInfo.name}"`, 'error', well)
|
||||
console.log(error)
|
||||
return false
|
||||
}
|
@ -9,9 +9,7 @@ export type WellIconColors = {
|
||||
unknown?: string
|
||||
}
|
||||
|
||||
export interface WellIconProps {
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
export type WellIconProps = React.SVGProps<SVGSVGElement> & {
|
||||
online?: boolean
|
||||
state?: WellIconState
|
||||
colors?: WellIconColors
|
||||
@ -31,7 +29,7 @@ const defaultProps: WellIconProps = {
|
||||
}
|
||||
|
||||
export const WellIcon = React.memo(({ width, height, state, online, colors, ...other } : WellIconProps = defaultProps) => {
|
||||
colors = {...defaultColors, ...colors}
|
||||
colors = { ...defaultColors, ...colors }
|
||||
|
||||
return (
|
||||
<svg
|
||||
@ -59,9 +57,9 @@ export const WellIcon = React.memo(({ width, height, state, online, colors, ...o
|
||||
</g>
|
||||
{online && ( // Полоски, показывающие наличие свежей телеметрии
|
||||
<g stroke={colors.online}>
|
||||
<path d="m18.4 0.0662a2 2 0 0 1 0.141 1.7 2 2 0 0 1-1.22 1.19"/>
|
||||
<path d="m19.5 0.0402a3 3 0 0 1-1.79 3.85"/>
|
||||
<path d="m20.5 0.031a4 4 0 0 1-2.5 4.79"/>
|
||||
<path d={'m18.4 0.0662a2 2 0 0 1 0.141 1.7 2 2 0 0 1-1.22 1.19'} />
|
||||
<path d={'m19.5 0.0402a3 3 0 0 1-1.79 3.85'} />
|
||||
<path d={'m20.5 0.031a4 4 0 0 1-2.5 4.79'} />
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
|
@ -1,6 +1,3 @@
|
||||
export type { PointerIconColors, PointerIconProps } from './PointerIcon'
|
||||
export type { WellIconColors, WellIconProps, WellIconState } from './WellIcon'
|
||||
|
||||
export { PointerIcon } from './PointerIcon'
|
||||
export { WellIcon } from './WellIcon'
|
||||
export { Loader } from './Loader'
|
||||
export * from './PointerIcon'
|
||||
export * from './WellIcon'
|
||||
export * from './Loader'
|
||||
|
35
src/components/outlets/DepositsOutlet.tsx
Normal file
35
src/components/outlets/DepositsOutlet.tsx
Normal 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
|
30
src/components/outlets/UserOutlet.tsx
Normal file
30
src/components/outlets/UserOutlet.tsx
Normal 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
|
2
src/components/outlets/index.ts
Normal file
2
src/components/outlets/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './DepositsOutlet'
|
||||
export * from './UserOutlet'
|
@ -39,8 +39,8 @@ export const Poprompt = memo<PopromptProps>(({ formProps, buttonProps, footer, c
|
||||
)}
|
||||
trigger={'click'}
|
||||
{...other}
|
||||
visible={visible}
|
||||
onVisibleChange={(visible) => setVisible(visible)}
|
||||
open={visible}
|
||||
onOpenChange={(visible) => setVisible(visible)}
|
||||
>
|
||||
<Button {...buttonProps}>{text}</Button>
|
||||
</Popover>
|
||||
|
@ -1,12 +1,11 @@
|
||||
import { Tag, TreeSelect } from 'antd'
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
|
||||
import { useDepositList } from '@asb/context'
|
||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||
import { hasPermission } from '@utils'
|
||||
import { DepositService } from '@api'
|
||||
|
||||
export const getTreeData = async () => {
|
||||
const deposits = await DepositService.getDeposits()
|
||||
export const getTreeData = async (deposits) => {
|
||||
const wellsTree = deposits.map((deposit, dIdx) => ({
|
||||
title: deposit.caption,
|
||||
key: `0-${dIdx}`,
|
||||
@ -36,28 +35,31 @@ export const getTreeLabels = (treeData) => {
|
||||
return labels
|
||||
}
|
||||
|
||||
export const WellSelector = memo(({ idWell, value, onChange, treeData, treeLabels, ...other }) => {
|
||||
export const WellSelector = memo(({ value, onChange, treeData, treeLabels, ...other }) => {
|
||||
const [wellsTree, setWellsTree] = useState([])
|
||||
const [wellLabels, setWellLabels] = useState([])
|
||||
|
||||
const deposits = useDepositList()
|
||||
|
||||
useEffect(() => {
|
||||
invokeWebApiWrapperAsync(
|
||||
async () => {
|
||||
const wellsTree = treeData ?? await getTreeData()
|
||||
const wellsTree = treeData ?? await getTreeData(deposits)
|
||||
const labels = treeLabels ?? getTreeLabels(wellsTree)
|
||||
setWellsTree(wellsTree)
|
||||
setWellLabels(labels)
|
||||
},
|
||||
null,
|
||||
'Не удалось загрузить список скважин',
|
||||
'Получение списка скважин'
|
||||
{ actionName: 'Получение списка скважин' }
|
||||
)
|
||||
}, [idWell, treeData, treeLabels])
|
||||
}, [deposits, treeData, treeLabels])
|
||||
|
||||
return (
|
||||
<TreeSelect
|
||||
multiple
|
||||
treeCheckable
|
||||
maxTagCount={'responsive'}
|
||||
showCheckedStrategy={TreeSelect.SHOW_CHILD}
|
||||
treeDefaultExpandAll
|
||||
treeData={wellsTree}
|
||||
|
@ -1,61 +1,62 @@
|
||||
import { Button, Drawer, Skeleton, Tree, TreeProps, Typography } from 'antd'
|
||||
import { DefaultValueType } from 'rc-tree-select/lib/interface'
|
||||
import { useState, useEffect, ReactNode, useCallback, memo, Key } from 'react'
|
||||
import { Drawer, Tree, TreeDataNode, TreeProps } from 'antd'
|
||||
import { useState, useEffect, useCallback, memo, Key, useMemo } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
|
||||
import { useDepositList } from '@asb/context'
|
||||
import { WellIcon, WellIconState } from '@components/icons'
|
||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||
import { DepositService, DepositDto, WellDto } from '@api'
|
||||
import { DepositDto, WellDto } from '@api'
|
||||
import { isRawDate } from '@utils'
|
||||
|
||||
import { ReactComponent as DepositIcon } from '@images/DepositIcon.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 checkIsWellOnline = (lastTelemetryDate: unknown): boolean =>
|
||||
isRawDate(lastTelemetryDate) && (Date.now() - +new Date(lastTelemetryDate) < 600_000)
|
||||
|
||||
export type TreeNodeData = {
|
||||
title?: string | null
|
||||
key?: string
|
||||
value?: DefaultValueType
|
||||
icon?: ReactNode
|
||||
children?: TreeNodeData[]
|
||||
const getKeyByUrl = (url?: string): [Key | null, string | null, number | null] => {
|
||||
const result = url?.match(URL_REGEX) // pattern "/:type/:id"
|
||||
if (!result) return [null, null, null]
|
||||
return [result[0], result[1], result[2] && result[2] !== 'null' ? Number(result[2]) : null]
|
||||
}
|
||||
|
||||
const getKeyByUrl = (url?: string): [Key | null, string | null] => {
|
||||
const result = url?.match(/^\/([^\/]+)\/([^\/?]+)/) // pattern "/:type/:id"
|
||||
if (!result) return [null, null]
|
||||
return [result[0], result[1]]
|
||||
}
|
||||
|
||||
const getLabel = (wellsTree: TreeNodeData[], value?: string): string | undefined => {
|
||||
const [url, type] = getKeyByUrl(value)
|
||||
const getLabel = (wellsTree: TreeDataNode[], value?: string): string | undefined => {
|
||||
const [url, type, key] = getKeyByUrl(value)
|
||||
if (!url) return
|
||||
let deposit: TreeNodeData | undefined
|
||||
let cluster: TreeNodeData | undefined
|
||||
let well: TreeNodeData | undefined
|
||||
let deposit: TreeDataNode | undefined
|
||||
let cluster: TreeDataNode | undefined
|
||||
let well: TreeDataNode | undefined
|
||||
switch (type) {
|
||||
case 'deposit':
|
||||
if (key === null) return 'Месторождение не выбрано'
|
||||
deposit = wellsTree.find((deposit) => deposit.key === url)
|
||||
if (deposit)
|
||||
return `${deposit.title}`
|
||||
return 'Ошибка! Месторождение не найдено!'
|
||||
|
||||
case 'cluster':
|
||||
if (key === null) return 'Куст не выбран'
|
||||
deposit = wellsTree.find((deposit) => (
|
||||
cluster = deposit.children?.find((cluster: TreeNodeData) => cluster.key === url)
|
||||
cluster = deposit.children?.find((cluster: TreeDataNode) => cluster.key === url)
|
||||
))
|
||||
if (deposit && cluster)
|
||||
return `${deposit.title} / ${cluster.title}`
|
||||
return 'Ошибка! Куст не найден!'
|
||||
|
||||
case 'well':
|
||||
if (key === null) return 'Скважина не выбрана'
|
||||
deposit = wellsTree.find((deposit) => (
|
||||
cluster = deposit.children?.find((cluster: TreeNodeData) => (
|
||||
well = cluster.children?.find((well: TreeNodeData) => well.key === url)
|
||||
cluster = deposit.children?.find((cluster: TreeDataNode) => (
|
||||
well = cluster.children?.find((well: TreeDataNode) => well.key === url)
|
||||
))
|
||||
))
|
||||
if (deposit && cluster && well)
|
||||
@ -79,38 +80,28 @@ const sortWellsByActive = (a: WellDto, b: WellDto): number => {
|
||||
return (a.caption || '')?.localeCompare(b.caption || '')
|
||||
}
|
||||
|
||||
export const WellTreeSelector = memo(({ show, ...other }: TreeProps<TreeNodeData> & { show?: boolean }) => {
|
||||
const [wellsTree, setWellsTree] = useState<TreeNodeData[]>([])
|
||||
const [showLoader, setShowLoader] = useState<boolean>(false)
|
||||
const [visible, setVisible] = useState<boolean>(false)
|
||||
const [expanded, setExpanded] = useState<Key[]>([])
|
||||
const [selected, setSelected] = useState<Key[]>([])
|
||||
const [value, setValue] = useState<string>()
|
||||
export type WellTreeSelectorProps = TreeProps<TreeDataNode> & {
|
||||
show?: boolean
|
||||
expand?: boolean | Key[]
|
||||
current?: Key
|
||||
onClose?: () => void
|
||||
onChange?: (value: string | undefined) => void
|
||||
open?: boolean
|
||||
}
|
||||
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
setVisible((prev) => show ?? prev)
|
||||
setExpanded((prev) => {
|
||||
if (typeof show === 'undefined') return prev
|
||||
if (!show) return []
|
||||
const getExpandKeys = (treeData: TreeDataNode[], depositKeys?: Key[] | boolean): Key[] => {
|
||||
const out: Key[] = []
|
||||
wellsTree.forEach((deposit) => {
|
||||
treeData.forEach((deposit) => {
|
||||
if (Array.isArray(depositKeys) && !depositKeys.includes(deposit.key)) return
|
||||
if (deposit.key) out.push(deposit.key)
|
||||
deposit.children?.forEach((cluster) => {
|
||||
if (cluster.key) out.push(cluster.key)
|
||||
})
|
||||
})
|
||||
return out
|
||||
})
|
||||
}, [wellsTree, show])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
invokeWebApiWrapperAsync(
|
||||
async () => {
|
||||
const deposits: Array<DepositDto> = await DepositService.getDeposits()
|
||||
const wellsTree: TreeNodeData[] = deposits.map(deposit =>({
|
||||
const makeWellsTreeData = (deposits: DepositDto[]): TreeDataNode[] => deposits.map(deposit =>({
|
||||
title: deposit.caption,
|
||||
key: `/deposit/${deposit.id}`,
|
||||
value: `/deposit/${deposit.id}`,
|
||||
@ -137,33 +128,51 @@ export const WellTreeSelector = memo(({ show, ...other }: TreeProps<TreeNodeData
|
||||
})),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
setWellsTree(wellsTree)
|
||||
},
|
||||
setShowLoader,
|
||||
`Не удалось загрузить список скважин`,
|
||||
'Получить список скважин'
|
||||
)
|
||||
}, [])
|
||||
}))
|
||||
|
||||
const onChange = useCallback((value?: string): void => {
|
||||
export const WellTreeSelector = memo<WellTreeSelectorProps>(({ expand, current, onChange, onClose, open, ...other }) => {
|
||||
const [expanded, setExpanded] = useState<Key[]>([])
|
||||
const [selected, setSelected] = useState<Key[]>([])
|
||||
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const deposits = useDepositList()
|
||||
|
||||
const wellsTree = useMemo(() => makeWellsTreeData(deposits), [deposits])
|
||||
|
||||
const onValueChange = useCallback((value?: string): void => {
|
||||
const key = getKeyByUrl(value)[0]
|
||||
setSelected(key ? [key] : [])
|
||||
setValue(getLabel(wellsTree, value))
|
||||
onChange?.(getLabel(wellsTree, value))
|
||||
}, [wellsTree])
|
||||
|
||||
const onSelect = useCallback((value: Key[]): void => {
|
||||
navigate(String(value), { state: { from: location.pathname }})
|
||||
const newRoot = URL_REGEX.exec(String(value))
|
||||
const oldRoot = URL_REGEX.exec(location.pathname)
|
||||
if (!newRoot || !oldRoot) return
|
||||
|
||||
let newPath = newRoot[0]
|
||||
if (oldRoot[1] === newRoot[1]) {
|
||||
/// Если типы страницы одинаковые (deposit, cluster, well), добавляем остаток старого пути
|
||||
const url = location.pathname.substring(oldRoot[0].length)
|
||||
newPath = newPath + url
|
||||
}
|
||||
|
||||
navigate(newPath, { state: { from: location.pathname }})
|
||||
}, [navigate, location])
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Button loading={showLoader} onClick={() => setVisible(true)}>{value ?? 'Выберите месторождение'}</Button>
|
||||
<Drawer visible={visible} mask={false} onClose={() => setVisible(false)}>
|
||||
<Typography.Title level={3}>Список скважин</Typography.Title>
|
||||
<Skeleton active loading={showLoader}>
|
||||
<Drawer open={open} mask={false} onClose={onClose} title={'Список скважин'}>
|
||||
<Tree
|
||||
{...other}
|
||||
showIcon
|
||||
@ -173,9 +182,7 @@ export const WellTreeSelector = memo(({ show, ...other }: TreeProps<TreeNodeData
|
||||
onExpand={setExpanded}
|
||||
expandedKeys={expanded}
|
||||
/>
|
||||
</Skeleton>
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
|
@ -9,6 +9,7 @@ export type CompanyViewProps = {
|
||||
company?: CompanyDto
|
||||
}
|
||||
|
||||
/** Компонент для отображения информации о компании */
|
||||
export const CompanyView = memo<CompanyViewProps>(({ company }) => company ? (
|
||||
<Tooltip title={
|
||||
<Grid style={{ columnGap: '8px' }}>
|
||||
|
@ -8,6 +8,7 @@ export type PermissionViewProps = {
|
||||
info?: PermissionDto
|
||||
}
|
||||
|
||||
/** Компонент для отображения информации о разрешении */
|
||||
export const PermissionView = memo<PermissionViewProps>(({ info }) => info ? (
|
||||
<Tooltip overlayInnerStyle={{ width: '400px' }} title={
|
||||
<Grid>
|
||||
|
@ -9,6 +9,7 @@ export type RoleViewProps = {
|
||||
role?: UserRoleDto
|
||||
}
|
||||
|
||||
/** Компонент для отображения информации о роли */
|
||||
export const RoleView = memo<RoleViewProps>(({ role }) => {
|
||||
if (!role) return ( <Tooltip title={'нет данных'}>-</Tooltip> )
|
||||
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { Fragment, memo } from 'react'
|
||||
import { Tooltip } from 'antd'
|
||||
|
||||
import { TelemetryDto, TelemetryInfoDto } from '@api'
|
||||
import { Grid, GridItem } from '@components/Grid'
|
||||
import { formatDate } from '@utils'
|
||||
import { TelemetryDto, TelemetryInfoDto } from '@api'
|
||||
|
||||
export const lables: Record<string, string> = {
|
||||
timeZoneId: 'Временная зона',
|
||||
@ -18,6 +19,12 @@ export const lables: Record<string, string> = {
|
||||
spinPlcVersion: 'Версия Спин Мастер',
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит название для телеметрии
|
||||
*
|
||||
* @param telemetry Объект телеметрии
|
||||
* @returns Название
|
||||
*/
|
||||
export const getTelemetryLabel = (telemetry?: TelemetryDto) =>
|
||||
`${telemetry?.id ?? '-'} / ${telemetry?.info?.deposit ?? '-'} / ${telemetry?.info?.cluster ?? '-'} / ${telemetry?.info?.well ?? '-'}`
|
||||
|
||||
@ -25,17 +32,23 @@ export type TelemetryViewProps = {
|
||||
telemetry?: TelemetryDto
|
||||
}
|
||||
|
||||
/** Компонент для отображения информации о телеметрии */
|
||||
export const TelemetryView = memo<TelemetryViewProps>(({ telemetry }) => telemetry?.info ? (
|
||||
<Tooltip
|
||||
overlayInnerStyle={{ width: '400px' }}
|
||||
title={
|
||||
<Grid>
|
||||
{(Object.keys(telemetry.info) as Array<keyof TelemetryInfoDto>).map((key, i) => (
|
||||
{(Object.keys(telemetry.info) as Array<keyof TelemetryInfoDto>).map((key, i) => {
|
||||
let value = telemetry.info?.[key]
|
||||
value = key === 'drillingStartDate' ? formatDate(value) : value
|
||||
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<GridItem row={i+1} col={1}>{lables[key] ?? key}:</GridItem>
|
||||
<GridItem row={i+1} col={2}>{telemetry.info?.[key]}</GridItem>
|
||||
<GridItem row={i+1} col={2}>{value}</GridItem>
|
||||
</Fragment>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</Grid>
|
||||
}
|
||||
>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { memo } from 'react'
|
||||
import { HTMLProps, memo } from 'react'
|
||||
import { Tooltip } from 'antd'
|
||||
import { UserOutlined } from '@ant-design/icons'
|
||||
|
||||
@ -6,33 +6,59 @@ import { UserDto } from '@api'
|
||||
import { Grid, GridItem } from '@components/Grid'
|
||||
import { CompanyView } from './CompanyView'
|
||||
|
||||
export type UserViewProps = {
|
||||
export type UserViewProps = HTMLProps<HTMLSpanElement> & {
|
||||
user?: UserDto
|
||||
}
|
||||
|
||||
export const UserView = memo<UserViewProps>(({ user }) => user ? (
|
||||
<Tooltip title={(
|
||||
/** Компонент для отображения информации о пользователе */
|
||||
export const UserView = memo<UserViewProps>(({ user, ...other }) =>
|
||||
user ? (
|
||||
<Tooltip
|
||||
title={
|
||||
<Grid style={{ columnGap: '8px' }}>
|
||||
<GridItem row={1} col={1}>Фамилия:</GridItem>
|
||||
<GridItem row={1} col={2}>{user?.surname}</GridItem>
|
||||
<GridItem row={1} col={1}>
|
||||
Фамилия:
|
||||
</GridItem>
|
||||
<GridItem row={1} col={2}>
|
||||
{user?.surname}
|
||||
</GridItem>
|
||||
|
||||
<GridItem row={2} col={1}>Имя:</GridItem>
|
||||
<GridItem row={2} col={2}>{user?.name}</GridItem>
|
||||
<GridItem row={2} col={1}>
|
||||
Имя:
|
||||
</GridItem>
|
||||
<GridItem row={2} col={2}>
|
||||
{user?.name}
|
||||
</GridItem>
|
||||
|
||||
<GridItem row={3} col={1}>Отчество:</GridItem>
|
||||
<GridItem row={3} col={2}>{user?.patronymic}</GridItem>
|
||||
<GridItem row={3} col={1}>
|
||||
Отчество:
|
||||
</GridItem>
|
||||
<GridItem row={3} col={2}>
|
||||
{user?.patronymic}
|
||||
</GridItem>
|
||||
|
||||
<GridItem row={4} col={1}>Компания:</GridItem>
|
||||
<GridItem row={4} col={1}>
|
||||
Компания:
|
||||
</GridItem>
|
||||
<GridItem row={4} col={2}>
|
||||
<CompanyView company={user?.company}/>
|
||||
<CompanyView company={user?.company} />
|
||||
</GridItem>
|
||||
</Grid>
|
||||
)}>
|
||||
<UserOutlined style={{ marginRight: 8 }}/>
|
||||
}
|
||||
>
|
||||
<span {...other}>
|
||||
<UserOutlined style={{ marginRight: 8 }} />
|
||||
{user?.login}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title='нет пользователя'>-</Tooltip>
|
||||
))
|
||||
) : (
|
||||
<Tooltip title={'нет пользователя'}>
|
||||
<span {...other}>
|
||||
<UserOutlined style={{ marginRight: 8 }} />
|
||||
---
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
)
|
||||
|
||||
export default UserView
|
||||
|
71
src/components/views/WellView.tsx
Normal file
71
src/components/views/WellView.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes, memo } from 'react'
|
||||
import { Tooltip, TooltipProps } from 'antd'
|
||||
|
||||
import { Grid, GridItem } from '@components/Grid'
|
||||
import { WellIcon, WellIconState } from '@components/icons'
|
||||
import { WellDto } from '@api'
|
||||
|
||||
import TelemetryView from './TelemetryView'
|
||||
|
||||
const wellState: Record<number, { enum: WellIconState, label: string }> = {
|
||||
0: { enum: 'unknown', label: 'Неизвестно' },
|
||||
1: { enum: 'active', label: 'В работе' },
|
||||
2: { enum: 'inactive', label: 'Завершена' },
|
||||
}
|
||||
|
||||
export type WellViewProps = TooltipProps & {
|
||||
well?: WellDto
|
||||
iconProps?: DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>
|
||||
labelProps?: DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить название скважины
|
||||
* @param well Объект с данными скважины
|
||||
* @returns Название скважины
|
||||
*/
|
||||
export const getWellTitle = (well: WellDto) => `${well.deposit || '-'} / ${well.cluster || '-'} / ${well.caption || '-'}`
|
||||
|
||||
/** Компонент для отображения информации о скважине */
|
||||
export const WellView = memo<WellViewProps>(({ well, iconProps, labelProps, ...other }) => well ? (
|
||||
<Tooltip {...other} title={(
|
||||
<Grid style={{ columnGap: '8px' }}>
|
||||
<GridItem row={1} col={1}>Название:</GridItem>
|
||||
<GridItem row={1} col={2}>{well.caption ?? '---'}</GridItem>
|
||||
|
||||
<GridItem row={2} col={1}>Куст:</GridItem>
|
||||
<GridItem row={2} col={2}>{well.cluster ?? '---'}</GridItem>
|
||||
|
||||
<GridItem row={3} col={1}>Месторождение:</GridItem>
|
||||
<GridItem row={3} col={2}>{well.deposit ?? '---'}</GridItem>
|
||||
|
||||
<GridItem row={4} col={1}>Статус:</GridItem>
|
||||
<GridItem row={4} col={2}>{wellState[well.idState || 0].label}</GridItem>
|
||||
|
||||
<GridItem row={5} col={1}>Широта:</GridItem>
|
||||
<GridItem row={5} col={2}>{well.latitude ?? '---'}</GridItem>
|
||||
|
||||
<GridItem row={6} col={1}>Долгота:</GridItem>
|
||||
<GridItem row={6} col={2}>{well.longitude ?? '---'}</GridItem>
|
||||
|
||||
<GridItem row={7} col={1}>Телеметрия:</GridItem>
|
||||
<GridItem row={7} col={2}>
|
||||
<TelemetryView telemetry={well.telemetry} />
|
||||
</GridItem>
|
||||
|
||||
<GridItem row={8} col={1}>ID:</GridItem>
|
||||
<GridItem row={8} col={2}>{well.id ?? '---'}</GridItem>
|
||||
</Grid>
|
||||
)}>
|
||||
<span role={'img'} style={{ marginRight: 8, lineHeight: 0, verticalAlign: '-0.25em' }} {...iconProps}>
|
||||
<WellIcon state={wellState[well.idState || 0].enum} width={'1em'} height={'1em'} />
|
||||
</span>
|
||||
<span {...labelProps}>
|
||||
{getWellTitle(well)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title={'нет скважины'}>-</Tooltip>
|
||||
))
|
||||
|
||||
export default WellView
|
@ -21,6 +21,7 @@ export type WirelineViewProps = TooltipProps & {
|
||||
buttonProps?: ButtonProps
|
||||
}
|
||||
|
||||
/** Компонент для отображения информации о талевом канате */
|
||||
export const WirelineView = memo<WirelineViewProps>(({ wireline, buttonProps, ...other }) => (
|
||||
<Tooltip
|
||||
{...other}
|
||||
|
@ -1,13 +1,7 @@
|
||||
export type { PermissionViewProps } from './PermissionView'
|
||||
export type { TelemetryViewProps } from './TelemetryView'
|
||||
export type { CompanyViewProps } from './CompanyView'
|
||||
export type { RoleViewProps } from './RoleView'
|
||||
export type { UserViewProps } from './UserView'
|
||||
export type { WirelineViewProps } 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 './PermissionView'
|
||||
export * from './TelemetryView'
|
||||
export * from './CompanyView'
|
||||
export * from './RoleView'
|
||||
export * from './UserView'
|
||||
export * from './WirelineView'
|
||||
export * from './WellView'
|
||||
|
@ -20,7 +20,7 @@ export const WidgetSettingsWindow = memo<WidgetSettingsWindowProps>(({ settings,
|
||||
return (
|
||||
<Modal
|
||||
{...other}
|
||||
visible={!!settings}
|
||||
open={!!settings}
|
||||
title={(
|
||||
<>
|
||||
Настройка виджета {settings?.label ? `"${settings?.label}"` : ''}
|
||||
|
@ -1,5 +1,2 @@
|
||||
export { WidgetSettingsWindow } from './WidgetSettingsWindow'
|
||||
export { BaseWidget } from './BaseWidget'
|
||||
|
||||
export type { WidgetSettingsWindowProps } from './WidgetSettingsWindow'
|
||||
export type { WidgetSettings, BaseWidgetProps } from './BaseWidget'
|
||||
export * from './WidgetSettingsWindow'
|
||||
export * from './BaseWidget'
|
||||
|
@ -1,21 +0,0 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
|
||||
/** Контекст текущего ID скважины */
|
||||
export const IdWellContext = createContext<number | null>(null)
|
||||
/** Контекст текущего корневого пути */
|
||||
export const RootPathContext = createContext<string>('')
|
||||
|
||||
/**
|
||||
* Получает текущий ID скважины
|
||||
*
|
||||
* @returns Текущий ID скважины, либо `null`
|
||||
*/
|
||||
export const useIdWell = () => useContext(IdWellContext)
|
||||
|
||||
/**
|
||||
* Получает текущий корневой путь
|
||||
*
|
||||
* @returns Текущий корневой путь
|
||||
*/
|
||||
export const useRootPath = () => useContext(RootPathContext)
|
23
src/context/deposit.ts
Normal file
23
src/context/deposit.ts
Normal 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
5
src/context/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './deposit'
|
||||
export * from './layout_props'
|
||||
export * from './root_path'
|
||||
export * from './user'
|
||||
export * from './well'
|
31
src/context/layout_props.ts
Normal file
31
src/context/layout_props.ts
Normal 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
11
src/context/root_path.ts
Normal 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
13
src/context/user.ts
Normal 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
13
src/context/well.ts
Normal 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)
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user