Compare commits

..

5 Commits

262 changed files with 4240 additions and 5530 deletions

15
.vscode/settings.json vendored
View File

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

View File

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

View File

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

33
package-lock.json generated
View File

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

View File

@ -11,7 +11,9 @@
"react": "^18.1.0", "react": "^18.1.0",
"react-dom": "^18.1.0", "react-dom": "^18.1.0",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"rxjs": "^7.5.5" "rxjs": "^7.5.5",
"usehooks-ts": "^2.6.0",
"web-vitals": "^2.1.4"
}, },
"scripts": { "scripts": {
"test": "jest", "test": "jest",
@ -24,11 +26,11 @@
"dev": "webpack-dev-server --env=\"ENV=dev\" --open --hot", "dev": "webpack-dev-server --env=\"ENV=dev\" --open --hot",
"oul": "npx openapi -i http://127.0.0.1:5000/swagger/v1/swagger.json -o src/services/api", "oul": "npx openapi -i http://127.0.0.1:5000/swagger/v1/swagger.json -o src/services/api",
"oud": "npx openapi -i http://192.168.1.10:5000/swagger/v1/swagger.json -o src/services/api", "oud": "npx openapi -i http://192.168.1.113:5000/swagger/v1/swagger.json -o src/services/api",
"oug": "npx openapi -i https://cloud.digitaldrilling.ru/swagger/v1/swagger.json -o src/services/api", "oug": "npx openapi -i https://cloud.digitaldrilling.ru/swagger/v1/swagger.json -o src/services/api",
"oug_dev": "npx openapi -i http://46.146.207.184/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"
}, },
"proxy": "http://46.146.207.184", "proxy": "http://46.146.209.148:89",
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
"react-app", "react-app",

View File

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

View File

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

View File

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

View File

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

View File

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

101
src/components/Display.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

5
src/components/Layout/index.ts Executable file
View File

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

View File

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

View File

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

View File

@ -1,45 +0,0 @@
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>
))
}

30
src/components/PageHeader.tsx Executable file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,67 @@
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

13
src/components/Private/index.ts Executable file
View File

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

View File

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

View File

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

View File

@ -1,27 +1,37 @@
import { Key, ReactNode } from 'react' import { ReactNode } from 'react'
import { Rule } from 'antd/lib/form' import { Rule } from 'antd/lib/form'
import { ColumnType } from 'antd/lib/table' import { ColumnProps } from 'antd/lib/table'
import { RenderedCell } from 'rc-table/lib/interface'
import { DataSet } from '../Table' export { makeDateColumn } from './date'
import { OmitExtends } from '@utils/types' 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'
export * from './date' export type { TagInputProps } from './tag'
export * from './time'
export * from './numeric'
export * from './select'
export * from './tag'
export * from './text'
export * from './timezone'
export type DataType<T = any> = Record<string, T> export type DataType<T = any> = Record<string, T>
export type RenderMethod<T = any, DT = DataSet<T>> = (value: T | undefined, dataset: DT, index: number) => ReactNode | RenderedCell<T> | undefined export type RenderMethod<T = any> = (value: T, dataset?: DataType<T>, index?: number) => ReactNode
export type SorterMethod<T = any, DT = DataSet<T> | null | undefined> = (a: DT, b: DT) => number export type SorterMethod<T = any> = (a?: DataType<T> | null, b?: DataType<T> | null) => number
export type FilterMethod<T = any, DT = DataSet<T>> = (value: string | number | T | undefined, record: DT) => boolean
export type FilterGenerator<T, DT = DataSet<T>> = (key: Key) => FilterMethod<T, DT> export type columnPropsOther<T = any> = ColumnProps<DataType<T>> & {
export type ColumnProps<T = any> = OmitExtends<{
// редактируемая колонка // редактируемая колонка
editable?: boolean editable?: boolean
// react компонента редактора // react компонента редактора
@ -35,16 +45,13 @@ export type ColumnProps<T = any> = OmitExtends<{
// дефолтное значение при добавлении новой строки // дефолтное значение при добавлении новой строки
initialValue?: string | number initialValue?: string | number
onFilter?: FilterMethod<T>
sorter?: SorterMethod<T>
render?: RenderMethod<T> render?: RenderMethod<T>
}, ColumnType<DataSet<T>>> }
export const makeColumn = <T = any>(title: ReactNode, key: Key, other?: ColumnProps<T>) => ({ export const makeColumn = (title: ReactNode, key: string, other?: columnPropsOther) => ({
title: title, title: title,
key: key, key: key,
dataIndex: key, dataIndex: key,
render: (value: T) => value,
...other, ...other,
}) })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,39 +1,72 @@
import { memo, useCallback, useState, useEffect, useMemo } from 'react' import { memo, useCallback, useState, useEffect, useMemo, Dispatch, SetStateAction, ReactNode } from 'react'
import { Form, Button, Popconfirm } from 'antd' import { Form, Button, Popconfirm, TableProps } from 'antd'
import { EditOutlined, SaveOutlined, PlusOutlined, CloseCircleOutlined, DeleteOutlined } from '@ant-design/icons' import { EditOutlined, SaveOutlined, PlusOutlined, CloseCircleOutlined, DeleteOutlined } from '@ant-design/icons'
import { invokeWebApiWrapperAsync } from '@components/factory' import { invokeWebApiWrapperAsync } from '@components/factory'
import { hasPermission } from '@utils' import { hasPermission, isDev } from '@utils'
import { Table } from '.' import { Table, TableColumns } from '.'
import { EditableCell } from './EditableCell' import { EditableCell } from './EditableCell'
import { ColumnsType } from 'antd/lib/table'
import { ArrayElement } from '@asb/utils/types'
const newRowKeyValue = 'newRow' const newRowKeyValue = 'newRow'
const actions = { type CrudObject = {
id?: string | number
key?: string | number
}
interface CrudService<T extends CrudObject> {
insert: (((data: T) => Promise<number>) | ((idWell: number, data: T) => Promise<number>))
insertRange: (((data: T[]) => Promise<number>) | ((idWell: number, data: T[]) => Promise<number>))
update: (((data: T) => Promise<number> | ((idWell: number, data: T) => Promise<number>) | ((idWell: number, id: number, data: T) => Promise<number>)))
delete: (((id: number) => Promise<number>) | ((idWell: number, id: number) => Promise<number>))
new(...args: any[]): any
}
type TableAction<T extends CrudObject> = Record<keyof CrudService<T>, (data: T, idWell?: number, idRecord?: number) => any[]>
const makeActions = <T extends CrudObject>(): TableAction<T> => ({
insert: (data, idWell) => [idWell, data], insert: (data, idWell) => [idWell, data],
insertRange: (data, idWell) => [idWell, [data].flat(1)], insertRange: (data, idWell) => [idWell, [data].flat(1)],
update: (data, idWell, idRecord) => [idWell, idRecord && data.id, data], update: (data, idWell, idRecord) => [idWell, idRecord && data.id, data],
delete: (data, idWell) => [idWell, data.id], delete: (data, idWell) => [idWell, data.id],
})
type TableActionProps<T extends CrudObject, S extends CrudService<T>> = {
service?: S
permission?: string | string[]
action?: keyof CrudService<T>
actionName?: string
recordParser?: (record: T) => T
idWell?: number
idRecord?: number
setLoader?: Dispatch<SetStateAction<boolean>>
errorMsg?: string
onComplete?: () => Promise<void>
} }
export const makeTableAction = ({ export const makeTableAction = <T extends CrudObject, S extends CrudService<T>>({
service, service,
permission, permission,
action, action,
actionName, actionName,
recordParser, recordParser,
idWell, idWell,
idRecord = false, idRecord,
setLoader, setLoader,
errorMsg = 'Не удалось выполнить операцию', errorMsg = 'Не удалось выполнить операцию',
onComplete, onComplete,
}) => hasPermission(permission) && service && action && ( }: TableActionProps<T, S>) => {
(record) => invokeWebApiWrapperAsync( if (!hasPermission(permission) || !service || !action) return async (record: T) => {}
const actions = makeActions<T>()
return async (record: T) => await invokeWebApiWrapperAsync(
async () => { async () => {
const data = recordParser?.(record) ?? record const data = recordParser?.(record) ?? record
const params = actions[action]?.(data, idWell, idRecord).filter(Boolean) const params = actions[action]?.(data, idWell, idRecord).filter(Boolean)
if (params?.length > 0) if (params?.length > 0)
//@ts-ignore
await service[action](...params) await service[action](...params)
await onComplete?.() await onComplete?.()
}, },
@ -41,25 +74,34 @@ export const makeTableAction = ({
errorMsg, errorMsg,
{ actionName } { actionName }
) )
) }
export const tryAddKeys = (items) => { export const tryAddKeys = <T extends object & Record<string, any>>(items?: T[]): T[] => {
if (!items?.length || !items[0]) if (!items?.length || !items[0]) return items ?? []
return [] if (items[0].key) return items
if (items[0].key)
return items
return items.map((item, index) => ({...item, key: item.key ?? item.id ?? index })) return items.map((item, index) => ({...item, key: item.key ?? item.id ?? index }))
} }
const EditableTableComponents = { body: { cell: EditableCell }} const EditableTableComponents = { body: { cell: EditableCell }}
export type EditableTableProps<T extends CrudObject, S extends CrudService<T>> = TableProps<T> & {
columns: ColumnsType<T> & TableColumns<T>
dataSource?: T[]
onChange?: ((data: T[]) => Promise<void>)
onRowAdd?: TableActionProps<T, S> | ((record: T) => Promise<void>)
onRowEdit?: TableActionProps<T, S> | ((record: T) => Promise<void>)
onRowDelete?: TableActionProps<T, S> | ((record: T) => Promise<void>)
additionalButtons?: (record: T, editingKey: string | number) => ReactNode
buttonsWidth
}
/** /**
* @param onChange - Метод вызывается со всем dataSource с измененными элементами после любого действия * @param onChange - Метод вызывается со всем dataSource с измененными элементами после любого действия
* @param onRowAdd - Метод вызывается с новой добавленной записью. Если метод не определен, то кнопка добавления строки не показывается * @param onRowAdd - Метод вызывается с новой добавленной записью. Если метод не определен, то кнопка добавления строки не показывается
* @param onRowEdit - Метод вызывается с новой отредактированной записью. Если метод не поределен, то кнопка редактирования строки не показывается * @param onRowEdit - Метод вызывается с новой отредактированной записью. Если метод не поределен, то кнопка редактирования строки не показывается
* @param onRowDelete - Метод вызывается с удаленной записью. Если метод не поределен, то кнопка удаления строки не показывается * @param onRowDelete - Метод вызывается с удаленной записью. Если метод не поределен, то кнопка удаления строки не показывается
*/ */
export const EditableTable = memo(({ const _EditableTable = <T extends CrudObject, S extends CrudService<T>>({
columns, columns,
dataSource, dataSource,
onChange, onChange,
@ -69,47 +111,55 @@ export const EditableTable = memo(({
additionalButtons, additionalButtons,
buttonsWidth, buttonsWidth,
...otherTableProps ...otherTableProps
}) => { }: EditableTableProps<T, S>) => {
const [form] = Form.useForm() const [form] = Form.useForm()
const [data, setData] = useState(tryAddKeys(dataSource)) const [data, setData] = useState<T[]>(tryAddKeys(dataSource))
const [editingKey, setEditingKey] = useState('') const [editingKey, setEditingKey] = useState<string | number>('')
const onAdd = useMemo(() => onRowAdd && typeof onRowAdd !== 'function' ? makeTableAction(onRowAdd) : onRowAdd, [onRowAdd]) const onAdd = useMemo(() => onRowAdd && typeof onRowAdd !== 'function' ? makeTableAction(onRowAdd) : onRowAdd, [onRowAdd])
const onEdit = useMemo(() => onRowEdit && typeof onRowEdit !== 'function' ? makeTableAction(onRowEdit) : onRowEdit, [onRowEdit]) const onEdit = useMemo(() => onRowEdit && typeof onRowEdit !== 'function' ? makeTableAction(onRowEdit) : onRowEdit, [onRowEdit])
const onDelete = useMemo(() => onRowDelete && typeof onRowDelete !== 'function' ? makeTableAction(onRowDelete) : onRowDelete, [onRowDelete]) const onDelete = useMemo(() => onRowDelete && typeof onRowDelete !== 'function' ? makeTableAction(onRowDelete) : onRowDelete, [onRowDelete])
const isEditing = useCallback((record) => record?.key === editingKey, [editingKey]) const isEditing = useCallback((record: T) => record?.key === editingKey, [editingKey])
const edit = useCallback((record) => { const edit = useCallback((record: T) => {
if (!record.key) return
form.setFieldsValue({...record}) form.setFieldsValue({...record})
setEditingKey(record.key) setEditingKey(record.key)
}, [form]) }, [form])
const cancel = useCallback(() => { const cancel = useCallback(() => {
if (editingKey === newRowKeyValue) { setEditingKey((prev) => {
const newData = [...data] if (prev === newRowKeyValue) {
setData((prev) => {
const newData = [...prev]
const index = newData.findIndex((item) => newRowKeyValue === item.key) const index = newData.findIndex((item) => newRowKeyValue === item.key)
newData.splice(index, 1) newData.splice(index, 1)
setData(newData) return newData
})
} }
setEditingKey('')
}, [data, editingKey]) return ''
})
}, [])
const addNewRow = useCallback(async () => { const addNewRow = useCallback(async () => {
let newRow = { const newRow: any = { key: newRowKeyValue } // TODO: Исправить тип
...form.initialValues, setData((prevData) => [newRow, ...prevData])
key:newRowKeyValue edit(newRow)
}, [edit])
const save = useCallback(async (record: T) => {
let row
try {
row = await form.validateFields()
} catch (errInfo) {
if (isDev())
console.warn('Validate Failed:', errInfo)
return
} }
const newData = [newRow, ...data]
setData(newData)
edit(newRow)
}, [data, edit, form.initialValues])
const save = useCallback(async (record) => {
try {
const row = await form.validateFields()
const newData = [...data] const newData = [...data]
const index = newData.findIndex((item) => record.key === item.key) const index = newData.findIndex((item) => record.key === item.key)
const item = newData[index] const item = newData[index]
@ -118,47 +168,35 @@ export const EditableTable = memo(({
newData.splice(index, 1, newItem) newData.splice(index, 1, newItem)
if (item.key === newRowKeyValue) if (item.key === newRowKeyValue)
item.key = newRowKeyValue + newData.length item.key += newData.length
try {
if (editingKey === newRowKeyValue)
await onAdd?.(newItem)
else
await onEdit?.(newItem)
await onChange?.(newData)
} catch (err) {
if (isDev())
console.warn(err)
}
const isAdding = editingKey === newRowKeyValue
setEditingKey('') setEditingKey('')
setData(newData) setData(newData)
if (isAdding)
try {
onAdd(newItem)
} catch (err) {
console.log('callback onRowAdd fault:', err)
}
else
try {
onEdit(newItem)
} catch (err) {
console.log('callback onRowEdit fault:', err)
}
try {
onChange?.(newData)
} catch (err) {
console.log('callback onChange fault:', err)
}
} catch (errInfo) {
console.log('Validate Failed:', errInfo)
}
}, [data, editingKey, form, onChange, onAdd, onEdit]) }, [data, editingKey, form, onChange, onAdd, onEdit])
const deleteRow = useCallback((record) => { const deleteRow = useCallback((record: T) => {
const newData = [...data] const newData = [...data]
const index = newData.findIndex((item) => record.key === item.key) const index = newData.findIndex((item) => record.key === item.key)
newData.splice(index, 1) newData.splice(index, 1)
setData(newData) setData(newData)
onDelete(record) onDelete?.(record)
onChange?.(newData) onChange?.(newData)
}, [data, onChange, onDelete]) }, [data, onChange, onDelete])
const handleColumn = useCallback((col) => { const handleColumn = useCallback((col: any) => { // TODO: Исправить тип
if (col.children) if (col.children)
col.children = col.children.map(handleColumn) col.children = col.children.map(handleColumn)
@ -167,7 +205,7 @@ export const EditableTable = memo(({
return { return {
...col, ...col,
onCell: (record) => ({ onCell: (record: T) => ({
...col.onCell?.(record), ...col.onCell?.(record),
editing: isEditing(record), editing: isEditing(record),
record, record,
@ -194,7 +232,7 @@ export const EditableTable = memo(({
/> />
), ),
dataIndex: 'operation', dataIndex: 'operation',
render: (_, record) => isEditing(record) ? ( render: (_: any, record: T) => isEditing(record) ? (
<span> <span>
<Button onClick={() => save(record)} icon={<SaveOutlined/>}/> <Button onClick={() => save(record)} icon={<SaveOutlined/>}/>
<Button onClick={cancel} icon={<CloseCircleOutlined/>}/> <Button onClick={cancel} icon={<CloseCircleOutlined/>}/>
@ -219,13 +257,13 @@ export const EditableTable = memo(({
), ),
}), [onAdd, onEdit, onDelete, isEditing, editingKey, save, cancel, edit, deleteRow]) }), [onAdd, onEdit, onDelete, isEditing, editingKey, save, cancel, edit, deleteRow])
const mergedColumns = useMemo(() => [...columns.map(handleColumn), operationColumn], [columns, handleColumn, operationColumn]) const mergedColumns: TableColumns<T> = useMemo(() => [...columns.map(handleColumn), operationColumn], [columns, handleColumn, operationColumn])
useEffect(() => setData(tryAddKeys(dataSource)), [dataSource]) useEffect(() => setData(tryAddKeys(dataSource)), [dataSource])
return ( return (
<Form form={form}> <Form form={form}>
<Table <Table<T>
components={EditableTableComponents} components={EditableTableComponents}
columns={mergedColumns} columns={mergedColumns}
dataSource={data} dataSource={data}
@ -233,4 +271,8 @@ export const EditableTable = memo(({
/> />
</Form> </Form>
) )
}) }
export const EditableTable = memo(_EditableTable) as typeof _EditableTable
export default EditableTable

View File

@ -1,96 +1,74 @@
import { Key, memo, useCallback, useEffect, useState } from 'react' import { memo, ReactNode, useCallback, useEffect, useState } from 'react'
import { ColumnGroupType, ColumnType } from 'antd/lib/table' import { ColumnGroupType, ColumnType } from 'antd/lib/table'
import { Table as RawTable, TableProps as RawTableProps } from 'antd' import { Table as RawTable, TableProps } from 'antd'
import { NamePath } from 'antd/lib/form/interface'
import { RenderMethod } from './Columns'
import { tryAddKeys } from './EditableTable'
import TableSettingsChanger from './TableSettingsChanger'
import type { OmitExtends } from '@utils/types' import type { OmitExtends } from '@utils/types'
import { applyTableSettings, getTableSettings, setTableSettings, TableColumnSettings, TableSettings } from '@utils' import { applyTableSettings, getTableSettings, setTableSettings, TableColumnSettings, TableSettings } from '@utils'
import TableSettingsChanger from './TableSettingsChanger'
import { DataType, RenderMethod } from './Columns'
import { tryAddKeys } from './EditableTable'
import '@styles/index.css' import '@styles/index.css'
export type BaseTableColumn<T> = ColumnGroupType<T> | ColumnType<T> export type BaseTableColumn<T> = ColumnGroupType<T> | ColumnType<T>
export type TableColumn<T> = OmitExtends<BaseTableColumn<T>, TableColumnSettings> export type TableColumns<T> = (OmitExtends<Omit<BaseTableColumn<T>, 'key' | 'render'>, TableColumnSettings> & {
export type TableColumns<T> = TableColumn<T>[] key: NamePath
render: RenderMethod<T | null>
})[]
export type TableProps<T> = RawTableProps<T> & { type MergedTableColumns<T> = (OmitExtends<Omit<BaseTableColumn<T>, 'render'>, TableColumnSettings> & {
/** Массив колонок таблицы с настройками (описаны в `TableColumnSettings`) */ render: RenderMethod<T | null>
columns: TableColumn<T>[] })[]
/** Название таблицы для сохранения настроек */
export type TableContainer<T> = Omit<TableProps<T>, 'columns' | 'dataSource'> & {
columns: TableColumns<T>
tableName?: string tableName?: string
/** Отображать ли кнопку настроек */
showSettingsChanger?: boolean showSettingsChanger?: boolean
dataSource: DataType<T>[]
} }
export interface DataSet<T, D = any> { export const keyToString = (key: NamePath): string => Array.isArray(key) ? key.join('.') : String(key)
[k: Key]: DataSet<T, D> | T | D
const defaultRender = <T,>(data: T): ReactNode => <>{data}</>
const getValueFromKey = <T,>(key: NamePath, record: DataType<T>): T | null => {
if (!key) return null
if (!Array.isArray(key))
return record[String(key)]
if (key.length <= 0) return null
const child = record[key[0]]
if (key.length === 1) return child
if (typeof child !== 'object') return null
return getValueFromKey(key.slice(1), child as DataType<T>)
} }
/** const columnRenderWrapper = <T,>(key: NamePath, render?: RenderMethod<T | null>): RenderMethod<T | null> => (_: T | null, record?: DataType<T | null>, index?: number) => {
* Получить значение из объекта по составному ключу if (!record) return '-'
* if (render)
* Составной ключ имеет вид: `<поле 1>[.<поле 2>...]` return render(getValueFromKey(key, record), record, index)
* return defaultRender(getValueFromKey(key, record))
* @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
} }
/** const _Table = <T extends Record<string, any>>({ columns, dataSource, tableName, showSettingsChanger, ...other }: TableContainer<T>) => {
* Фабрика обёрток render-функций ячеек с поддержкой составных ключей const [newColumns, setNewColumns] = useState<MergedTableColumns<T>>([])
* @param key Составной ключ
* @param render Стандартная render-функция
* @returns Обёрнутая render-функция
*/
export const makeColumnRenderWrapper = <T extends DataSet<any>>(key: Key | undefined, render: RenderMethod<T, T> | undefined): RenderMethod<T, T> =>
(_: any, dataset: T, index: number) => {
const renderFunc: RenderMethod<T, T> = typeof render === 'function' ? render : (record) => String(record)
return renderFunc(getObjectByDeepKey<T>(key, dataset), dataset, index)
}
/**
* Применяет необходимые обёртки ко всем столбцам таблицы
* @param columns Исходные столбцы
* @returns Обёрнутые столбцы
*/
const applyColumnWrappers = <T extends DataSet<any>>(columns: TableColumns<T>): TableColumns<T> => columns.map((column) => {
if ('children' in column) {
return {
...column,
children: applyColumnWrappers(column.children),
}
}
return {
...column,
render: makeColumnRenderWrapper<T>(column.key, column.render),
}
})
function _Table<T extends DataSet<any>>({ columns, dataSource, tableName, showSettingsChanger, ...other }: TableProps<T>) {
const [newColumns, setNewColumns] = useState<TableColumn<T>[]>([])
const [settings, setSettings] = useState<TableSettings>({}) const [settings, setSettings] = useState<TableSettings>({})
const onSettingsChanged = useCallback((settings?: TableSettings | null) => { const onSettingsChanged = useCallback((settings?: TableSettings | null) => {
if (tableName) if (tableName)
setTableSettings(tableName, settings) setTableSettings(tableName, settings)
setSettings(settings || {}) setSettings(settings ?? {})
}, [tableName]) }, [tableName])
useEffect(() => setSettings(tableName ? getTableSettings(tableName) : {}), [tableName]) useEffect(() => setSettings(tableName ? getTableSettings(tableName) : {}), [tableName])
useEffect(() => setNewColumns(() => { useEffect(() => setNewColumns(() => {
const newColumns = applyTableSettings(applyColumnWrappers(columns), settings) const mergedColumns = applyTableSettings(columns, settings)
const newColumns = mergedColumns.map((column) => ({
...column,
render: columnRenderWrapper(column.key, column.render),
key: keyToString(column.key),
}))
if (tableName && showSettingsChanger) { if (tableName && showSettingsChanger) {
const oldTitle = newColumns[0].title const oldTitle = newColumns[0].title
newColumns[0].title = (props) => ( newColumns[0].title = (props) => (
@ -104,21 +82,14 @@ function _Table<T extends DataSet<any>>({ columns, dataSource, tableName, showSe
}), [settings, columns, onSettingsChanged, showSettingsChanger, tableName]) }), [settings, columns, onSettingsChanged, showSettingsChanger, tableName])
return ( return (
<RawTable <RawTable<DataType<T>>
columns={newColumns} columns={newColumns}
dataSource={tryAddKeys(dataSource)} dataSource={tryAddKeys<DataType<T>>(dataSource)}
{...other} {...other}
/> />
) )
} }
/**
* Обёртка над компонентом таблицы AntD
*
* Особенности:
* * Поддержка составных ключей столбцов
* * Работа с настройками столбцов таблицы
*/
export const Table = memo(_Table) as typeof _Table export const Table = memo(_Table) as typeof _Table
export default Table export default Table

View File

@ -1,11 +1,11 @@
import { memo, useCallback, useEffect, useState } from 'react' import { memo, useCallback, useEffect, useState } from 'react'
import { ColumnsType } from 'antd/lib/table' import { ColumnsType } from 'antd/lib/table'
import { Button, Modal, Switch } from 'antd' import { Button, Modal, Switch, Table } from 'antd'
import { SettingOutlined } from '@ant-design/icons' import { SettingOutlined } from '@ant-design/icons'
import { TableColumnSettings, makeTableSettings, mergeTableSettings, TableSettings } from '@utils' import { TableColumnSettings, makeTableSettings, mergeTableSettings, TableSettings } from '@utils'
import { Table, TableColumns } from './Table' import { TableColumns } from './Table'
import { makeColumn, makeTextColumn } from '.' import { makeColumn } from '.'
const parseSettings = <T extends object>(columns?: TableColumns<T>, settings?: TableSettings | null): TableColumnSettings[] => { const parseSettings = <T extends object>(columns?: TableColumns<T>, settings?: TableSettings | null): TableColumnSettings[] => {
const newSettings = mergeTableSettings(makeTableSettings(columns ?? []), settings ?? {}) const newSettings = mergeTableSettings(makeTableSettings(columns ?? []), settings ?? {})
@ -46,8 +46,8 @@ const _TableSettingsChanger = <T extends object>({ title, columns, settings, onC
useEffect(() => { useEffect(() => {
setTableColumns([ setTableColumns([
makeTextColumn<string>('Название', 'title'), makeColumn('Название', 'title'),
makeColumn<any>(null, 'visible', { makeColumn(null, 'visible', {
title: () => ( title: () => (
<> <>
Показать Показать
@ -56,7 +56,7 @@ const _TableSettingsChanger = <T extends object>({ title, columns, settings, onC
</Button> </Button>
</> </>
), ),
render: (visible, _, index = NaN) => ( render: (visible: boolean, _?: TableColumnSettings, index: number = NaN) => (
<Switch <Switch
checked={visible} checked={visible}
checkedChildren={'Отображён'} checkedChildren={'Отображён'}
@ -84,7 +84,7 @@ const _TableSettingsChanger = <T extends object>({ title, columns, settings, onC
<> <>
<Modal <Modal
centered centered
open={visible} visible={visible}
onCancel={onModalCancel} onCancel={onModalCancel}
onOk={onModalOk} onOk={onModalOk}
title={title ?? 'Настройка отображения таблицы'} title={title ?? 'Настройка отображения таблицы'}

View File

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

View File

@ -1,30 +1,59 @@
export * from './EditableTable' export { makeDateSorter, makeNumericSorter, makeStringSorter, makeTimeSorter } from './sorters'
export * from './DatePickerWrapper' export { EditableTable, makeTableAction } from './EditableTable'
export * from './TimePickerWrapper' export { DatePickerWrapper } from './DatePickerWrapper'
export * from './DateRangeWrapper' export { TimePickerWrapper } from './TimePickerWrapper'
export * from './Table' export { DateRangeWrapper } from './DateRangeWrapper'
export * from './Columns' 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 const defaultPagination = { export const defaultPagination = {
defaultPageSize: 14, defaultPageSize: 14,
showSizeChanger: true, showSizeChanger: true,
} }
export type PaginationContainer<T> = { type PaginationContainer = {
skip?: number skip?: number
take?: number take?: number
count?: number count?: number
items?: T[] | null items?: any[] | null
} }
/** export const makePaginationObject = (сontainer: PaginationContainer, ...other: any) => ({
* Генерирует объект пагинации для компонента `Table` из данных от сервисов
*
* @param сontainer данные от сервиса
* @param other Дополнительные поля (передаются в объект напрямую в приоритете)
* @returns Объект пагинации
*/
export const makePaginationObject = <T, M extends object>(сontainer: PaginationContainer<T>, other: M) => ({
...other, ...other,
pageSize: сontainer.take, pageSize: сontainer.take,
total: сontainer.count ?? сontainer.items?.length ?? 0, total: сontainer.count ?? сontainer.items?.length ?? 0,

42
src/components/Table/sorters.ts Executable file
View File

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

View File

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

View File

@ -1,10 +1,11 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useElementSize } from 'usehooks-ts'
import { Property } from 'csstype' import { Property } from 'csstype'
import { Empty } from 'antd' import { Empty } from 'antd'
import * as d3 from 'd3' import * as d3 from 'd3'
import LoaderPortal from '@components/LoaderPortal' import LoaderPortal from '@components/LoaderPortal'
import { isDev, useElementSize, usePartialProps } from '@utils' import { isDev, usePartialProps } from '@utils'
import D3MouseZone from './D3MouseZone' import D3MouseZone from './D3MouseZone'
import { getChartClass } from './functions' import { getChartClass } from './functions'
@ -35,7 +36,7 @@ import type {
ChartTicks ChartTicks
} from './types' } from './types'
import '@styles/components/d3.less' import '@styles/d3.less'
const defaultOffsets: ChartOffset = { const defaultOffsets: ChartOffset = {
top: 10, top: 10,
@ -130,7 +131,7 @@ const _D3Chart = <DataType extends Record<string, unknown>>({
const [charts, setCharts] = useState<ChartRegistry<DataType>[]>([]) const [charts, setCharts] = useState<ChartRegistry<DataType>[]>([])
const [rootRef, { width, height }] = useElementSize<HTMLDivElement>() const [rootRef, { width, height }] = useElementSize()
const xAxis = useMemo(() => { const xAxis = useMemo(() => {
if (!data) return if (!data) return

View File

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

View File

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

View File

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

View File

@ -9,9 +9,9 @@ import { getChartIcon, isDev, usePartialProps } from '@utils'
import { BaseDataType } from '../types' import { BaseDataType } from '../types'
import { ChartGroup, ChartSizes } from './D3MonitoringCharts' import { ChartGroup, ChartSizes } from './D3MonitoringCharts'
import '@styles/components/d3.less' import '@styles/d3.less'
type D3GroupRenderFunction<DataType extends BaseDataType> = (group: ChartGroup<DataType>, data: DataType[], flowData: DataType[] | undefined) => ReactNode type D3GroupRenderFunction<DataType extends BaseDataType> = (group: ChartGroup<DataType>, data: DataType[]) => ReactNode
export type D3HorizontalCursorSettings<DataType extends BaseDataType> = { export type D3HorizontalCursorSettings<DataType extends BaseDataType> = {
width?: number width?: number
@ -27,7 +27,6 @@ export type D3HorizontalCursorSettings<DataType extends BaseDataType> = {
export type D3HorizontalCursorProps<DataType extends BaseDataType> = D3HorizontalCursorSettings<DataType> & { export type D3HorizontalCursorProps<DataType extends BaseDataType> = D3HorizontalCursorSettings<DataType> & {
groups: ChartGroup<DataType>[] groups: ChartGroup<DataType>[]
data: DataType[] data: DataType[]
flowData: DataType[] | undefined
sizes: ChartSizes sizes: ChartSizes
yAxis?: d3.ScaleTime<number, number> yAxis?: d3.ScaleTime<number, number>
spaceBetweenGroups?: number spaceBetweenGroups?: number
@ -39,7 +38,7 @@ const defaultLineStyle: SVGProps<SVGLineElement> = {
const offsetY = 5 const offsetY = 5
const makeDefaultRender = <DataType extends BaseDataType>(): D3GroupRenderFunction<DataType> => (group, data, flowData) => ( const makeDefaultRender = <DataType extends BaseDataType>(): D3GroupRenderFunction<DataType> => (group, data) => (
<> <>
{data.length > 0 ? group.charts.map((chart) => { {data.length > 0 ? group.charts.map((chart) => {
const xFormat = (d: number | Date) => chart.xAxis.format?.(d) ?? `${(+d).toFixed(2)} ${chart.xAxis.unit ?? ''}` const xFormat = (d: number | Date) => chart.xAxis.format?.(d) ?? `${(+d).toFixed(2)} ${chart.xAxis.unit ?? ''}`
@ -75,7 +74,6 @@ const _D3HorizontalCursor = <DataType extends BaseDataType>({
lineStyle: _lineStyle, lineStyle: _lineStyle,
data, data,
flowData,
groups, groups,
sizes, sizes,
yAxis, yAxis,
@ -169,7 +167,7 @@ const _D3HorizontalCursor = <DataType extends BaseDataType>({
return (date >= currentDate - limitInS) && (date <= currentDate + limitInS) return (date >= currentDate - limitInS) && (date <= currentDate + limitInS)
}) })
const bodies = groups.map((group) => render(group, chartData, flowData)) const bodies = groups.map((group) => render(group, chartData))
setTooltipBodies(bodies) setTooltipBodies(bodies)
}, [groups, data, yAxis, lineY, fixed, mouseState.visible]) }, [groups, data, yAxis, lineY, fixed, mouseState.visible])

View File

@ -1,10 +1,11 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useElementSize } from 'usehooks-ts'
import { Property } from 'csstype' import { Property } from 'csstype'
import { Empty } from 'antd' import { Empty } from 'antd'
import * as d3 from 'd3' import * as d3 from 'd3'
import LoaderPortal from '@components/LoaderPortal' import LoaderPortal from '@components/LoaderPortal'
import { isDev, useElementSize, usePartialProps, useUserSettings } from '@utils' import { isDev, usePartialProps, useUserSettings } from '@utils'
import { import {
BaseDataType, BaseDataType,
@ -34,14 +35,13 @@ const roundTo = (v: number, to: number = 50) => {
return (v > 0 ? Math.ceil : Math.round)(v / to) * to return (v > 0 ? Math.ceil : Math.round)(v / to) * to
} }
const getNear = (n: number) => {
let k = 0
for (let c = Math.abs(n); c >= 1; c /= 10) k++
return Math.pow(10, k) * Math.sign(n)
}
const calculateDomain = (mm: MinMax): Required<MinMax> => { const calculateDomain = (mm: MinMax): Required<MinMax> => {
const round = getNear(Math.abs((mm.max ?? 0) - (mm.min ?? 0))) || 10 let round = Math.abs((mm.max ?? 0) - (mm.min ?? 0))
if (round < 10) round = 10
else if (round < 100) round = roundTo(round, 10)
else if (round < 1000) round = roundTo(round, 100)
else if (round < 10000) round = roundTo(round, 1000)
else round = 0
let min = roundTo(mm.min ?? 0, round) let min = roundTo(mm.min ?? 0, round)
let max = roundTo(mm.max ?? round, round) let max = roundTo(mm.max ?? round, round)
if (round && Math.abs(min - max) < round) { if (round && Math.abs(min - max) < round) {
@ -73,8 +73,8 @@ export type ChartGroup<DataType extends BaseDataType> = {
} }
const defaultOffsets: ChartOffset = { const defaultOffsets: ChartOffset = {
top: 0, top: 10,
bottom: 0, bottom: 10,
left: 100, left: 100,
right: 20, right: 20,
} }
@ -115,8 +115,6 @@ export type D3MonitoringChartsProps<DataType extends BaseDataType> = Omit<React.
loading?: boolean loading?: boolean
/** Массив отображаемых данных */ /** Массив отображаемых данных */
data?: DataType[] data?: DataType[]
/** Массив данных для прямоугольников */
flowData?: DataType[]
/** Отступы графика от края SVG */ /** Отступы графика от края SVG */
offset?: Partial<ChartOffset> offset?: Partial<ChartOffset>
/** Цвет фона в формате CSS-значения */ /** Цвет фона в формате CSS-значения */
@ -182,7 +180,6 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
loading = false, loading = false,
datasetGroups, datasetGroups,
data, data,
flowData,
plugins, plugins,
offset: _offset, offset: _offset,
yAxis: _yAxisConfig, yAxis: _yAxisConfig,
@ -196,7 +193,6 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
methods, methods,
className = '', className = '',
style,
...other ...other
}: D3MonitoringChartsProps<DataType>) => { }: D3MonitoringChartsProps<DataType>) => {
const [datasets, setDatasets, resetDatasets] = useUserSettings(chartName, datasetGroups) const [datasets, setDatasets, resetDatasets] = useUserSettings(chartName, datasetGroups)
@ -212,7 +208,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
const yTicks = usePartialProps<Required<ChartTick<DataType>>>(_yTicks, getDefaultYTicks) const yTicks = usePartialProps<Required<ChartTick<DataType>>>(_yTicks, getDefaultYTicks)
const yAxisConfig = usePartialProps<ChartAxis<DataType>>(_yAxisConfig, getDefaultYAxisConfig) const yAxisConfig = usePartialProps<ChartAxis<DataType>>(_yAxisConfig, getDefaultYAxisConfig)
const [rootRef, { width, height }] = useElementSize<HTMLDivElement>() const [rootRef, { width, height }] = useElementSize()
const yAxisArea = useCallback(() => d3.select(yAxisRef), [yAxisRef]) const yAxisArea = useCallback(() => d3.select(yAxisRef), [yAxisRef])
const axesArea = useCallback(() => d3.select(axesAreaRef), [axesAreaRef]) const axesArea = useCallback(() => d3.select(axesAreaRef), [axesAreaRef])
@ -245,11 +241,11 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
if (!data) return if (!data) return
const yAxis = d3.scaleTime() const yAxis = d3.scaleTime()
.domain([yDomain?.min || 0, yDomain?.max || 0]) .domain([yDomain?.min ?? 0, yDomain?.max ?? 0])
.range([0, sizes.chartsHeight]) .range([0, sizes.chartsHeight])
return yAxis return yAxis
}, [groups, data, yDomain, sizes]) }, [groups, data, yDomain, sizes.chartsHeight])
const chartDomains = useMemo(() => groups.map((group) => { const chartDomains = useMemo(() => groups.map((group) => {
const out: [string | number, ChartDomain][] = group.charts.map((chart) => { const out: [string | number, ChartDomain][] = group.charts.map((chart) => {
@ -466,7 +462,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
groups.forEach((group, i) => { groups.forEach((group, i) => {
group() group()
.attr('transform', `translate(${sizes.groupLeft(group.key)}, 0)`) .attr('transform', `translate(${sizes.groupLeft(group.key)}, 0)`)
.attr('clip-path', `url(#chart-group-clip)`) .attr('clip-path', `url(#chart-clip)`)
group.charts.forEach((chart) => { group.charts.forEach((chart) => {
chart() chart()
@ -495,7 +491,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
chartData = renderArea<DataType>(xAxis, yAxis, chart, chartData) chartData = renderArea<DataType>(xAxis, yAxis, chart, chartData)
break break
case 'rect_area': case 'rect_area':
renderRectArea<DataType>(xAxis, yAxis, chart, flowData) renderRectArea<DataType>(xAxis, yAxis, chart)
break break
default: default:
break break
@ -509,7 +505,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
chart.afterDraw?.(chart) chart.afterDraw?.(chart)
}) })
}) })
}, [data, flowData, groups, height, offset, sizes, chartDomains, yAxis]) }, [data, groups, height, offset, sizes, chartDomains])
return ( return (
<LoaderPortal <LoaderPortal
@ -517,7 +513,6 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
style={{ style={{
width: givenWidth, width: givenWidth,
height: givenHeight, height: givenHeight,
...style,
}} }}
> >
<div <div
@ -533,7 +528,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
> >
<svg ref={setSvgRef} width={'100%'} height={'100%'}> <svg ref={setSvgRef} width={'100%'} height={'100%'}>
<defs> <defs>
<clipPath id={`chart-group-clip`}> <clipPath id={`chart-clip`}>
{/* Сдвиг во все стороны на 1 px чтобы линии на краях было видно */} {/* Сдвиг во все стороны на 1 px чтобы линии на краях было видно */}
<rect x={-1} y={-1} width={sizes.groupWidth + 2} height={sizes.chartsHeight + 2} /> <rect x={-1} y={-1} width={sizes.groupWidth + 2} height={sizes.chartsHeight + 2} />
</clipPath> </clipPath>
@ -574,7 +569,6 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
sizes={sizes} sizes={sizes}
spaceBetweenGroups={spaceBetweenGroups} spaceBetweenGroups={spaceBetweenGroups}
data={data} data={data}
flowData={flowData}
height={height} height={height}
/> />
</D3MouseZone> </D3MouseZone>

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@ import { BaseDataType, ChartRegistry } from '@components/d3/types'
import { D3MouseState, useD3MouseZone } from '@components/d3/D3MouseZone' import { D3MouseState, useD3MouseZone } from '@components/d3/D3MouseZone'
import { getTouchedElements, wrapPlugin } from './base' import { getTouchedElements, wrapPlugin } from './base'
import '@styles/components/d3.less' import '@styles/d3.less'
export type D3TooltipPosition = 'bottom' | 'top' | 'left' | 'right' | 'none' export type D3TooltipPosition = 'bottom' | 'top' | 'left' | 'right' | 'none'

View File

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

View File

@ -3,7 +3,8 @@ import { ArgsProps } from 'antd/lib/notification'
import { Dispatch, ReactNode, SetStateAction } from 'react' import { Dispatch, ReactNode, SetStateAction } from 'react'
import { WellView } from '@components/views' import { WellView } from '@components/views'
import { FunctionalValue, getFunctionalValue, getUser, isDev } from '@utils' import { getUserToken } from '@utils'
import { FunctionalValue, getFunctionalValue, isDev } from '@utils'
import { ApiError, FileInfoDto, WellDto } from '@api' import { ApiError, FileInfoDto, WellDto } from '@api'
export type NotifyType = 'error' | 'warning' | 'info' export type NotifyType = 'error' | 'warning' | 'info'
@ -96,7 +97,7 @@ export const invokeWebApiWrapperAsync = async (
export const download = async (url: string, fileName?: string) => { export const download = async (url: string, fileName?: string) => {
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
Authorization: `Bearer ${getUser().token}` Authorization: `Bearer ${getUserToken()}`
}, },
method: 'Get' method: 'Get'
}) })
@ -124,7 +125,7 @@ export const download = async (url: string, fileName?: string) => {
export const upload = async (url: string, formData: FormData) => { export const upload = async (url: string, formData: FormData) => {
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
Authorization: `Bearer ${getUser().token}` Authorization: `Bearer ${getUserToken()}`
}, },
method: 'Post', method: 'Post',
body: formData, body: formData,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,50 +1,41 @@
import { Drawer, Tree, TreeDataNode, TreeProps } from 'antd' import { Button, Drawer, Skeleton, Tree, TreeDataNode, TreeProps, Typography } from 'antd'
import { useState, useEffect, useCallback, memo, Key, useMemo } from 'react' import { useState, useEffect, useCallback, memo, Key } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { useDepositList } from '@asb/context'
import { WellIcon, WellIconState } from '@components/icons' import { WellIcon, WellIconState } from '@components/icons'
import { DepositDto, WellDto } from '@api' import { invokeWebApiWrapperAsync } from '@components/factory'
import { DepositService, DepositDto, WellDto } from '@api'
import { isRawDate } from '@utils' import { isRawDate } from '@utils'
import { ReactComponent as DepositIcon } from '@images/DepositIcon.svg' import { ReactComponent as DepositIcon } from '@images/DepositIcon.svg'
import { ReactComponent as ClusterIcon } from '@images/ClusterIcon.svg' import { ReactComponent as ClusterIcon } from '@images/ClusterIcon.svg'
import '@styles/components/well_tree_select.css' import '@styles/wellTreeSelect.css'
/**
* Для поиска в URL текущего раздела по шаблону `/{type}/{id}`
*
* Если найдено совпадение может вернуть 1 или 2 группы соответственно
*/
const URL_REGEX = /^\/([^\/?#]+)(?:\/([^\/?#]+))?/
export const getWellState = (idState?: number): WellIconState => idState === 1 ? 'active' : 'unknown' export const getWellState = (idState?: number): WellIconState => idState === 1 ? 'active' : 'unknown'
export const checkIsWellOnline = (lastTelemetryDate: unknown): boolean => export const checkIsWellOnline = (lastTelemetryDate: unknown): boolean =>
isRawDate(lastTelemetryDate) && (Date.now() - +new Date(lastTelemetryDate) < 600_000) isRawDate(lastTelemetryDate) && (Date.now() - +new Date(lastTelemetryDate) < 600_000)
const getKeyByUrl = (url?: string): [Key | null, string | null, number | null] => { const getKeyByUrl = (url?: string): [Key | null, string | null] => {
const result = url?.match(URL_REGEX) // pattern "/:type/:id" const result = url?.match(/^\/([^\/]+)\/([^\/?]+)/) // pattern "/:type/:id"
if (!result) return [null, null, null] if (!result) return [null, null]
return [result[0], result[1], result[2] && result[2] !== 'null' ? Number(result[2]) : null] return [result[0], result[1]]
} }
const getLabel = (wellsTree: TreeDataNode[], value?: string): string | undefined => { const getLabel = (wellsTree: TreeDataNode[], value?: string): string | undefined => {
const [url, type, key] = getKeyByUrl(value) const [url, type] = getKeyByUrl(value)
if (!url) return if (!url) return
let deposit: TreeDataNode | undefined let deposit: TreeDataNode | undefined
let cluster: TreeDataNode | undefined let cluster: TreeDataNode | undefined
let well: TreeDataNode | undefined let well: TreeDataNode | undefined
switch (type) { switch (type) {
case 'deposit': case 'deposit':
if (key === null) return 'Месторождение не выбрано'
deposit = wellsTree.find((deposit) => deposit.key === url) deposit = wellsTree.find((deposit) => deposit.key === url)
if (deposit) if (deposit)
return `${deposit.title}` return `${deposit.title}`
return 'Ошибка! Месторождение не найдено!' return 'Ошибка! Месторождение не найдено!'
case 'cluster': case 'cluster':
if (key === null) return 'Куст не выбран'
deposit = wellsTree.find((deposit) => ( deposit = wellsTree.find((deposit) => (
cluster = deposit.children?.find((cluster: TreeDataNode) => cluster.key === url) cluster = deposit.children?.find((cluster: TreeDataNode) => cluster.key === url)
)) ))
@ -53,7 +44,6 @@ const getLabel = (wellsTree: TreeDataNode[], value?: string): string | undefined
return 'Ошибка! Куст не найден!' return 'Ошибка! Куст не найден!'
case 'well': case 'well':
if (key === null) return 'Скважина не выбрана'
deposit = wellsTree.find((deposit) => ( deposit = wellsTree.find((deposit) => (
cluster = deposit.children?.find((cluster: TreeDataNode) => ( cluster = deposit.children?.find((cluster: TreeDataNode) => (
well = cluster.children?.find((well: TreeDataNode) => well.key === url) well = cluster.children?.find((well: TreeDataNode) => well.key === url)
@ -84,9 +74,6 @@ export type WellTreeSelectorProps = TreeProps<TreeDataNode> & {
show?: boolean show?: boolean
expand?: boolean | Key[] expand?: boolean | Key[]
current?: Key current?: Key
onClose?: () => void
onChange?: (value: string | undefined) => void
open?: boolean
} }
const getExpandKeys = (treeData: TreeDataNode[], depositKeys?: Key[] | boolean): Key[] => { const getExpandKeys = (treeData: TreeDataNode[], depositKeys?: Key[] | boolean): Key[] => {
@ -101,7 +88,34 @@ const getExpandKeys = (treeData: TreeDataNode[], depositKeys?: Key[] | boolean):
return out return out
} }
const makeWellsTreeData = (deposits: DepositDto[]): TreeDataNode[] => deposits.map(deposit =>({ export const WellTreeSelector = memo<WellTreeSelectorProps>(({ show, expand, current, ...other }) => {
const [wellsTree, setWellsTree] = useState<TreeDataNode[]>([])
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>()
const navigate = useNavigate()
const location = useLocation()
console.log(location.pathname)
useEffect(() => {
if (current) setSelected([current])
}, [current])
useEffect(() => setVisible((prev) => show ?? prev), [show])
useEffect(() => {
setExpanded((prev) => expand ? getExpandKeys(wellsTree, expand) : prev)
}, [wellsTree, expand])
useEffect(() => {
invokeWebApiWrapperAsync(
async () => {
const deposits: Array<DepositDto> = await DepositService.getDeposits()
const wellsTree: TreeDataNode[] = deposits.map(deposit =>({
title: deposit.caption, title: deposit.caption,
key: `/deposit/${deposit.id}`, key: `/deposit/${deposit.id}`,
value: `/deposit/${deposit.id}`, value: `/deposit/${deposit.id}`,
@ -128,27 +142,24 @@ const makeWellsTreeData = (deposits: DepositDto[]): TreeDataNode[] => deposits.m
})), })),
} }
}), }),
})) }))
setWellsTree(wellsTree)
},
setShowLoader,
`Не удалось загрузить список скважин`,
{ actionName: 'Получить список скважин' }
)
}, [])
export const WellTreeSelector = memo<WellTreeSelectorProps>(({ expand, current, onChange, onClose, open, ...other }) => { const onChange = useCallback((value?: string): void => {
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] const key = getKeyByUrl(value)[0]
setSelected(key ? [key] : []) setSelected(key ? [key] : [])
onChange?.(getLabel(wellsTree, value)) setValue(getLabel(wellsTree, value))
}, [wellsTree]) }, [wellsTree])
const onSelect = useCallback((value: Key[]): void => { const onSelect = useCallback((value: Key[]): void => {
const newRoot = URL_REGEX.exec(String(value)) const newRoot = /\/(\w+)\/\d+/.exec(String(value))
const oldRoot = URL_REGEX.exec(location.pathname) const oldRoot = /\/(\w+)(?:\/\d+)?/.exec(location.pathname)
if (!newRoot || !oldRoot) return if (!newRoot || !oldRoot) return
let newPath = newRoot[0] let newPath = newRoot[0]
@ -161,18 +172,14 @@ export const WellTreeSelector = memo<WellTreeSelectorProps>(({ expand, current,
navigate(newPath, { state: { from: location.pathname }}) navigate(newPath, { state: { from: location.pathname }})
}, [navigate, location]) }, [navigate, location])
useEffect(() => { useEffect(() => onChange(location.pathname), [onChange, location])
if (current) setSelected([current])
}, [current])
useEffect(() => {
setExpanded((prev) => expand ? getExpandKeys(wellsTree, expand) : prev)
}, [wellsTree, expand])
useEffect(() => onValueChange(location.pathname), [onValueChange, location.pathname])
return ( return (
<Drawer open={open} mask={false} onClose={onClose} title={'Список скважин'}> <>
<Button loading={showLoader} onClick={() => setVisible(true)}>{value ?? 'Выберите месторождение'}</Button>
<Drawer visible={visible} mask={false} onClose={() => setVisible(false)}>
<Typography.Title level={3}>Список скважин</Typography.Title>
<Skeleton active loading={showLoader}>
<Tree <Tree
{...other} {...other}
showIcon showIcon
@ -182,7 +189,9 @@ export const WellTreeSelector = memo<WellTreeSelectorProps>(({ expand, current,
onExpand={setExpanded} onExpand={setExpanded}
expandedKeys={expanded} expandedKeys={expanded}
/> />
</Skeleton>
</Drawer> </Drawer>
</>
) )
}) })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,14 @@
export * from './PermissionView' export type { PermissionViewProps } from './PermissionView'
export * from './TelemetryView' export type { TelemetryViewProps } from './TelemetryView'
export * from './CompanyView' export type { CompanyViewProps } from './CompanyView'
export * from './RoleView' export type { RoleViewProps } from './RoleView'
export * from './UserView' export type { UserViewProps } from './UserView'
export * from './WirelineView' 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 './WellView' export * from './WellView'

View File

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

View File

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

22
src/context.ts Normal file
View File

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

View File

@ -1,23 +0,0 @@
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)

View File

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

View File

@ -1,31 +0,0 @@
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)

View File

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

View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

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

Before

Width:  |  Height:  |  Size: 8.7 KiB

View File

@ -1,14 +1,11 @@
import { memo } from 'react' import { memo } from 'react'
import { ReactComponent as RawLogo } from './Logo.svg' import { ReactComponent as AsbLogo } from '@images/dd_logo_white_opt.svg'
export type LogoProps = React.SVGProps<SVGSVGElement> & { export type LogoProps = React.SVGProps<SVGSVGElement> & { size?: number }
size?: number
onlyIcon?: boolean
}
export const Logo = memo<LogoProps>(({ size = 170, onlyIcon, ...props }) => ( export const Logo = memo<LogoProps>(({ size = 200, ...props }) => (
<RawLogo style={{ width: size, height: 282/896*size }} {...props} /> <AsbLogo className={'logo'} height={'100%'} {...props} />
)) ))
export default Logo export default Logo

View File

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

View File

@ -11,7 +11,7 @@ export const AccessDenied = memo(() => {
return ( return (
<Result <Result
status={'error'} status={'error'}
title={'Доступ запрещён'} title={'Доступ запрешён'}
subTitle={'Страницы не существует или у вас отсутствует к ней доступ.'} subTitle={'Страницы не существует или у вас отсутствует к ней доступ.'}
> >
<div className={'desc'}> <div className={'desc'}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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