forked from ddrilling/asb_cloud_front
Merge branch 'dev'
This commit is contained in:
commit
ff520626cf
1
__mocks__/fileMock.js
Normal file
1
__mocks__/fileMock.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
module.exports = 'test-file-stub'
|
1
__mocks__/styleMock.js
Normal file
1
__mocks__/styleMock.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
module.exports = {}
|
7
babel.config.js
Normal file
7
babel.config.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [
|
||||||
|
'@babel/preset-env',
|
||||||
|
['@babel/preset-react', {runtime: 'automatic'}],
|
||||||
|
'@babel/preset-typescript',
|
||||||
|
],
|
||||||
|
}
|
@ -1,25 +0,0 @@
|
|||||||
const CracoLessPlugin = require('craco-less')
|
|
||||||
const CracoAlias = require('craco-alias')
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
plugins: [
|
|
||||||
{
|
|
||||||
plugin: CracoLessPlugin,
|
|
||||||
options: {
|
|
||||||
lessLoaderOptions: {
|
|
||||||
lessOptions: {
|
|
||||||
//modifyVars: { '@primary-color': '#E20000' },
|
|
||||||
javascriptEnabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
plugin: CracoAlias,
|
|
||||||
options: {
|
|
||||||
source: 'tsconfig',
|
|
||||||
baseUrl: './src',
|
|
||||||
tsConfigPath: './tsconfig.paths.json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
30
custom.d.ts
vendored
Normal file
30
custom.d.ts
vendored
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
declare module '*.gif' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.jpg' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.jpeg' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.png' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.svg' {
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
export const ReactComponent: React.FunctionComponent<
|
||||||
|
React.SVGProps<SVGSVGElement> & { title?: string }
|
||||||
|
>
|
||||||
|
|
||||||
|
const src: string
|
||||||
|
export default src
|
||||||
|
}
|
34690
package-lock.json
generated
34690
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
109
package.json
109
package.json
@ -3,41 +3,30 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@craco/craco": "^6.1.2",
|
"@microsoft/signalr": "^6.0.5",
|
||||||
"@microsoft/signalr": "^6.0.4",
|
"antd": "^4.20.7",
|
||||||
"@testing-library/jest-dom": "^5.11.10",
|
"chart.js": "^3.8.0",
|
||||||
"@testing-library/react": "^11.2.6",
|
|
||||||
"@testing-library/user-event": "^12.8.3",
|
|
||||||
"@types/react-dom": "^18.0.3",
|
|
||||||
"antd": "^4.15.0",
|
|
||||||
"chart.js": "^3.6.0",
|
|
||||||
"chartjs-adapter-moment": "^1.0.0",
|
"chartjs-adapter-moment": "^1.0.0",
|
||||||
"chartjs-plugin-datalabels": "^2.0.0-rc.1",
|
"chartjs-plugin-datalabels": "^2.0.0",
|
||||||
"chartjs-plugin-zoom": "^1.1.1",
|
"chartjs-plugin-zoom": "^1.2.1",
|
||||||
"craco-less": "^1.17.1",
|
|
||||||
"d3": "^7.4.4",
|
"d3": "^7.4.4",
|
||||||
"moment": "^2.29.1",
|
"moment": "^2.29.3",
|
||||||
"pigeon-maps": "^0.19.7",
|
"pigeon-maps": "^0.21.0",
|
||||||
"react": "^17.0.2",
|
"react": "^18.1.0",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^18.1.0",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^6.3.0",
|
||||||
"react-scripts": "4.0.3",
|
"rxjs": "^7.5.5",
|
||||||
"rxjs": "^7.5.4",
|
"usehooks-ts": "^2.6.0",
|
||||||
"typescript": "^4.2.3",
|
"web-vitals": "^2.1.4"
|
||||||
"web-vitals": "^1.1.1"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "craco start",
|
"start": "webpack-dev-server --mode=development --open --hot",
|
||||||
"build": "craco build",
|
"build": "webpack --mode=production",
|
||||||
"test": "craco test",
|
"test": "jest",
|
||||||
"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.70:5000/swagger/v1/swagger.json -o src/services/api",
|
"oud": "npx openapi -i http://192.168.1.70:5000/swagger/v1/swagger.json -o src/services/api",
|
||||||
"oug": "npx openapi -i http://46.146.209.148/swagger/v1/swagger.json -o src/services/api",
|
"oug": "npx openapi -i http://46.146.209.148/swagger/v1/swagger.json -o src/services/api",
|
||||||
"oug_dev": "npx openapi -i http://46.146.209.148:89/swagger/v1/swagger.json -o src/services/api",
|
"oug_dev": "npx openapi -i http://46.146.209.148:89/swagger/v1/swagger.json -o src/services/api"
|
||||||
"react_start": "react-scripts start",
|
|
||||||
"react_build": "react-scripts build",
|
|
||||||
"react_test": "react-scripts test",
|
|
||||||
"eject": "react-scripts eject"
|
|
||||||
},
|
},
|
||||||
"proxy": "http://46.146.209.148:89",
|
"proxy": "http://46.146.209.148:89",
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
@ -58,12 +47,64 @@
|
|||||||
"last 1 safari version"
|
"last 1 safari version"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"jsx",
|
||||||
|
"ts",
|
||||||
|
"tsx"
|
||||||
|
],
|
||||||
|
"moduleDirectories": [
|
||||||
|
"node_modules",
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
|
||||||
|
"\\.(css|less)$": "<rootDir>/__mocks__/styleMock.js",
|
||||||
|
"^@asb(.*)$": "<rootDir>/src$1",
|
||||||
|
"^@api(.*)$": "<rootDir>/src/services/api$1",
|
||||||
|
"^@components(.*)$": "<rootDir>/src/components$1",
|
||||||
|
"^@services(.*)$": "<rootDir>/src/services$1",
|
||||||
|
"^@pages(.*)$": "<rootDir>/src/pages$1",
|
||||||
|
"^@utils(.*)$": "<rootDir>/src/utils$1",
|
||||||
|
"^@images(.*)$": "<rootDir>/src/images$1",
|
||||||
|
"^@styles(.*)$": "<rootDir>/src/styles$1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/d3": "^7.1.0",
|
"@babel/core": "^7.18.2",
|
||||||
"@types/react": "^17.0.3",
|
"@babel/preset-env": "^7.18.2",
|
||||||
"@types/react-router-dom": "^5.3.2",
|
"@babel/preset-react": "^7.17.12",
|
||||||
"craco-alias": "^3.0.1",
|
"@babel/preset-typescript": "^7.17.12",
|
||||||
"openapi-typescript": "^3.4.1",
|
"@svgr/webpack": "^6.2.1",
|
||||||
"openapi-typescript-codegen": "^0.21.0"
|
"@testing-library/jest-dom": "^5.16.4",
|
||||||
|
"@testing-library/react": "^13.3.0",
|
||||||
|
"@testing-library/user-event": "^14.2.1",
|
||||||
|
"@types/d3": "^7.4.0",
|
||||||
|
"@types/jest": "^28.1.0",
|
||||||
|
"@types/react": "^18.0.10",
|
||||||
|
"@types/react-dom": "^18.0.5",
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
"babel-jest": "^28.1.0",
|
||||||
|
"babel-loader": "^8.2.5",
|
||||||
|
"css-loader": "^6.7.1",
|
||||||
|
"file-loader": "^6.2.0",
|
||||||
|
"html-webpack-plugin": "^5.5.0",
|
||||||
|
"interpolate-html-plugin": "^4.0.0",
|
||||||
|
"jest": "^28.1.0",
|
||||||
|
"less": "^4.1.3",
|
||||||
|
"less-loader": "^11.0.0",
|
||||||
|
"openapi-typescript": "^5.4.0",
|
||||||
|
"openapi-typescript-codegen": "^0.23.0",
|
||||||
|
"path-browserify": "^1.0.1",
|
||||||
|
"react-test-renderer": "^18.1.0",
|
||||||
|
"source-map-loader": "^3.0.1",
|
||||||
|
"style-loader": "^3.3.1",
|
||||||
|
"ts-loader": "^9.3.0",
|
||||||
|
"typescript": "^4.7.4",
|
||||||
|
"url-loader": "^4.1.1",
|
||||||
|
"webpack": "^5.73.0",
|
||||||
|
"webpack-cli": "^4.9.2",
|
||||||
|
"webpack-dev-server": "^4.9.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,16 +2,12 @@
|
|||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="white" />
|
<meta name="theme-color" content="white" />
|
||||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="white" />
|
<meta name="theme-color" media="(prefers-color-scheme: light)" content="white" />
|
||||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="black" />
|
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="black" />
|
||||||
<meta
|
<meta name="description" content="Онлайн мониторинг процесса бурения в реальном времени в офисе заказчика" />
|
||||||
name="description"
|
|
||||||
content="Онлайн мониторинг процесса бурения в реальном времени в офисе заказчика"
|
|
||||||
/>
|
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
|
||||||
<title>АСБ Vision</title>
|
<title>АСБ Vision</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
46
src/App.tsx
46
src/App.tsx
@ -1,19 +1,19 @@
|
|||||||
import {
|
import { BrowserRouter as Router, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
BrowserRouter as Router,
|
|
||||||
Switch,
|
|
||||||
Route
|
|
||||||
} from 'react-router-dom'
|
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import { ConfigProvider } from 'antd'
|
import { ConfigProvider } from 'antd'
|
||||||
import locale from 'antd/lib/locale/ru_RU'
|
import locale from 'antd/lib/locale/ru_RU'
|
||||||
|
|
||||||
import { PrivateRoute } from '@components/Private'
|
import { RootPathContext } from '@asb/context'
|
||||||
import { getUserToken } from '@utils/storage'
|
import { getUserToken, NoAccessComponent } from '@utils'
|
||||||
import { OpenAPI } from '@api'
|
import { OpenAPI } from '@api'
|
||||||
|
|
||||||
import Main from '@pages/Main'
|
import AdminPanel from '@pages/AdminPanel'
|
||||||
|
import Well from '@pages/Well'
|
||||||
import Login from '@pages/Login'
|
import Login from '@pages/Login'
|
||||||
|
import Cluster from '@pages/Cluster'
|
||||||
|
import Deposit from '@pages/Deposit'
|
||||||
import Register from '@pages/Register'
|
import Register from '@pages/Register'
|
||||||
|
import FileDownload from '@pages/FileDownload'
|
||||||
|
|
||||||
import '@styles/App.less'
|
import '@styles/App.less'
|
||||||
|
|
||||||
@ -23,19 +23,27 @@ OpenAPI.HEADERS = {'Content-Type': 'application/json'}
|
|||||||
|
|
||||||
export const App = memo(() => (
|
export const App = memo(() => (
|
||||||
<ConfigProvider locale={locale}>
|
<ConfigProvider locale={locale}>
|
||||||
|
<RootPathContext.Provider value={''}>
|
||||||
<Router>
|
<Router>
|
||||||
<Switch>
|
<Routes>
|
||||||
<Route path={'/login'}>
|
<Route index element={<Navigate to={Deposit.getKey()} replace />} />
|
||||||
<Login />
|
<Route path={'*'} element={<NoAccessComponent />} />
|
||||||
</Route>
|
|
||||||
<Route path={'/register'}>
|
{/* Public pages */}
|
||||||
<Register />
|
<Route path={Login.route} element={<Login />} />
|
||||||
</Route>
|
<Route path={Register.route} element={<Register />} />
|
||||||
<PrivateRoute path={'/'}>
|
|
||||||
<Main />
|
{/* Admin pages */}
|
||||||
</PrivateRoute>
|
<Route path={AdminPanel.route} element={<AdminPanel />} />
|
||||||
</Switch>
|
|
||||||
|
{/* User pages */}
|
||||||
|
<Route path={Deposit.route} element={<Deposit />} />
|
||||||
|
<Route path={Cluster.route} element={<Cluster />} />
|
||||||
|
<Route path={Well.route} element={<Well />} />
|
||||||
|
<Route path={FileDownload.route} element={<FileDownload />} />
|
||||||
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
|
</RootPathContext.Provider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
))
|
))
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ import { Rule } from 'antd/lib/form'
|
|||||||
import { Form, Input, Modal, FormProps } from 'antd'
|
import { Form, Input, Modal, FormProps } from 'antd'
|
||||||
|
|
||||||
import { AuthService, UserDto } from '@api'
|
import { AuthService, UserDto } from '@api'
|
||||||
import { getUserId, getUserLogin } from '@utils/storage'
|
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'
|
||||||
|
55
src/components/CopyUrl.tsx
Normal file
55
src/components/CopyUrl.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { cloneElement, memo, useCallback, useMemo, useState } from 'react'
|
||||||
|
import { Button, ButtonProps } from 'antd'
|
||||||
|
import { CopyOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
|
import { invokeWebApiWrapperAsync, notify } from './factory'
|
||||||
|
|
||||||
|
export type CopyUrlProps = {
|
||||||
|
sendLoading?: boolean
|
||||||
|
hideUnsupported?: boolean
|
||||||
|
onCopy?: () => (void | Promise<void>)
|
||||||
|
children: JSX.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CopyUrl = memo<CopyUrlProps>(({ children, onCopy, sendLoading, hideUnsupported = true }) => {
|
||||||
|
const props = useMemo(() => children.props, [children])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const supported = !!navigator?.clipboard?.writeText // Проверка поддержки
|
||||||
|
|
||||||
|
const onClick = useCallback((event: MouseEvent) => {
|
||||||
|
if (supported) {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
|
async () => {
|
||||||
|
await navigator.clipboard.writeText(window.location.href)
|
||||||
|
await onCopy?.()
|
||||||
|
notify('URL успешно скопирован', 'info')
|
||||||
|
},
|
||||||
|
setLoading,
|
||||||
|
`Не удалось скопировать URL в буфер обмена`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
props.onClick?.(event) // Запуск onClick по-умолчанию
|
||||||
|
}, [props])
|
||||||
|
|
||||||
|
if (hideUnsupported && !supported) return null
|
||||||
|
|
||||||
|
return cloneElement(children, { onClick, loading: sendLoading ? loading : props.loading })
|
||||||
|
})
|
||||||
|
|
||||||
|
export type CopyUrlButtonProps = Omit<CopyUrlProps, 'children'> & ButtonProps
|
||||||
|
|
||||||
|
export const CopyUrlButton = memo<CopyUrlButtonProps>(({ sendLoading, hideUnsupported, onCopy, ...other }) => {
|
||||||
|
return (
|
||||||
|
<CopyUrl sendLoading={sendLoading} hideUnsupported={hideUnsupported} onCopy={onCopy}>
|
||||||
|
<Button
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
title={'Скопировать URL в буфер обмена'}
|
||||||
|
{...other}
|
||||||
|
/>
|
||||||
|
</CopyUrl>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default CopyUrl
|
@ -2,7 +2,7 @@ import moment from 'moment'
|
|||||||
import { useState, useEffect, memo, ReactNode } from 'react'
|
import { useState, useEffect, memo, ReactNode } from 'react'
|
||||||
import {CaretUpOutlined, CaretDownOutlined, CaretRightOutlined} from '@ant-design/icons'
|
import {CaretUpOutlined, CaretDownOutlined, CaretRightOutlined} from '@ant-design/icons'
|
||||||
|
|
||||||
import '@styles/display.css'
|
import '@styles/display.less'
|
||||||
|
|
||||||
export const formatNumber = (value?: unknown, format?: number) =>
|
export const formatNumber = (value?: unknown, format?: number) =>
|
||||||
Number.isInteger(format) && Number.isFinite(value)
|
Number.isInteger(format) && Number.isFinite(value)
|
||||||
@ -15,7 +15,7 @@ const displayValueStyle = { display: 'flex', flexGrow: 1 }
|
|||||||
export type ValueDisplayProps = {
|
export type ValueDisplayProps = {
|
||||||
prefix?: ReactNode
|
prefix?: ReactNode
|
||||||
suffix?: ReactNode
|
suffix?: ReactNode
|
||||||
format?: number | string
|
format?: number | string | ((arg: string) => ReactNode)
|
||||||
isArrowVisible?: boolean
|
isArrowVisible?: boolean
|
||||||
enumeration?: Record<string, string>
|
enumeration?: Record<string, string>
|
||||||
value: string
|
value: string
|
||||||
@ -27,7 +27,7 @@ export type DisplayProps = ValueDisplayProps & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ValueDisplay = memo<ValueDisplayProps>(({ prefix, value, suffix, isArrowVisible, format, enumeration }) => {
|
export const ValueDisplay = memo<ValueDisplayProps>(({ prefix, value, suffix, isArrowVisible, format, enumeration }) => {
|
||||||
const [val, setVal] = useState<string>('---')
|
const [val, setVal] = useState<ReactNode>('---')
|
||||||
const [arrowState, setArrowState] = useState({
|
const [arrowState, setArrowState] = useState({
|
||||||
preVal: NaN,
|
preVal: NaN,
|
||||||
preTimestamp: Date.now(),
|
preTimestamp: Date.now(),
|
||||||
@ -37,6 +37,7 @@ export const ValueDisplay = memo<ValueDisplayProps>(({ prefix, value, suffix, is
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVal((preVal) => {
|
setVal((preVal) => {
|
||||||
if ((value ?? '-') === '-' || value === '--') return '---'
|
if ((value ?? '-') === '-' || value === '--') return '---'
|
||||||
|
if (typeof format === 'function') return format(enumeration?.[value] ?? value)
|
||||||
if (enumeration?.[value]) return enumeration[value]
|
if (enumeration?.[value]) return enumeration[value]
|
||||||
|
|
||||||
if (Number.isFinite(+value)) {
|
if (Number.isFinite(+value)) {
|
||||||
|
@ -1,22 +1,43 @@
|
|||||||
import { memo } from 'react'
|
import { memo, ReactNode } from 'react'
|
||||||
import { Button, ButtonProps } from 'antd'
|
import { Link, LinkProps } from 'react-router-dom'
|
||||||
import { FileWordOutlined } from '@ant-design/icons'
|
import { FileWordOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
import { FileInfoDto } from '@api'
|
import { FileInfoDto } from '@api'
|
||||||
import { downloadFile } from './factory'
|
import { downloadFile } from './factory'
|
||||||
|
|
||||||
export type DownloadLinkProps = ButtonProps & {
|
import { getLinkToFile } from '@pages/FileDownload'
|
||||||
|
|
||||||
|
import '@styles/index.css'
|
||||||
|
|
||||||
|
export type DownloadLinkProps = LinkProps & {
|
||||||
file?: FileInfoDto
|
file?: FileInfoDto
|
||||||
name?: string
|
name?: string
|
||||||
|
icon?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DownloadLink = memo<DownloadLinkProps>(({ file, name, ...other }) => (
|
export const DownloadLink = memo<DownloadLinkProps>(({
|
||||||
<Button
|
className = '',
|
||||||
type={'link'}
|
file,
|
||||||
icon={<FileWordOutlined />}
|
name,
|
||||||
onClick={file && (() => downloadFile(file))}
|
icon = <FileWordOutlined />,
|
||||||
|
...other
|
||||||
|
}) => (
|
||||||
|
<Link
|
||||||
|
title={'Чтобы поделиться файлом с другим сотрудником скопируйте ссылку'}
|
||||||
{...other}
|
{...other}
|
||||||
>{name ?? file?.name ?? '-'}</Button>
|
className={`download-link ${className}`}
|
||||||
|
|
||||||
|
to={getLinkToFile(file)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (file)
|
||||||
|
downloadFile(file)
|
||||||
|
e.preventDefault()
|
||||||
|
return false
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span style={{ marginLeft: 8 }}>{name ?? file?.name ?? '-'}</span>
|
||||||
|
</Link>
|
||||||
))
|
))
|
||||||
|
|
||||||
export default DownloadLink
|
export default DownloadLink
|
||||||
|
@ -15,7 +15,7 @@ export const AdminLayoutPortal = memo<AdminLayoutPortalProps>(({ title, ...props
|
|||||||
<Layout.Content>
|
<Layout.Content>
|
||||||
<PageHeader isAdmin title={title} style={{ backgroundColor: '#900' }}>
|
<PageHeader isAdmin title={title} style={{ backgroundColor: '#900' }}>
|
||||||
<Button size={'large'}>
|
<Button size={'large'}>
|
||||||
<Link to={{ pathname: '/', state: { from: location.pathname }}}>Вернуться на сайт</Link>
|
<Link to={'/'}>Вернуться на сайт</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<Layout>
|
<Layout>
|
||||||
|
@ -3,13 +3,14 @@ import { Layout, LayoutProps } from 'antd'
|
|||||||
|
|
||||||
import PageHeader from '@components/PageHeader'
|
import PageHeader from '@components/PageHeader'
|
||||||
import WellTreeSelector from '@components/selectors/WellTreeSelector'
|
import WellTreeSelector from '@components/selectors/WellTreeSelector'
|
||||||
|
import { wrapPrivateComponent } from '@utils'
|
||||||
|
|
||||||
export type LayoutPortalProps = LayoutProps & {
|
export type LayoutPortalProps = LayoutProps & {
|
||||||
title?: ReactNode
|
title?: ReactNode
|
||||||
noSheet?: boolean
|
noSheet?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LayoutPortal = memo<LayoutPortalProps>(({ title, noSheet, ...props }) => (
|
const _LayoutPortal = memo<LayoutPortalProps>(({ title, noSheet, ...props }) => (
|
||||||
<Layout.Content>
|
<Layout.Content>
|
||||||
<PageHeader title={title}>
|
<PageHeader title={title}>
|
||||||
<WellTreeSelector />
|
<WellTreeSelector />
|
||||||
@ -22,4 +23,8 @@ export const LayoutPortal = memo<LayoutPortalProps>(({ title, noSheet, ...props
|
|||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
))
|
))
|
||||||
|
|
||||||
|
export const LayoutPortal = wrapPrivateComponent(_LayoutPortal, {
|
||||||
|
requirements: ['Deposit.get'],
|
||||||
|
})
|
||||||
|
|
||||||
export default LayoutPortal
|
export default LayoutPortal
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
export { AdminLayoutPortal } from './AdminLayoutPortal'
|
export * from './AdminLayoutPortal'
|
||||||
export { LayoutPortal } from './LayoutPortal'
|
export * from './LayoutPortal'
|
||||||
|
|
||||||
export type { AdminLayoutPortalProps } from './AdminLayoutPortal'
|
export type { AdminLayoutPortalProps } from './AdminLayoutPortal'
|
||||||
export type { LayoutPortalProps } from './LayoutPortal'
|
export type { LayoutPortalProps } from './LayoutPortal'
|
||||||
|
@ -8,7 +8,7 @@ type LoaderPortalProps = HTMLAttributes<HTMLDivElement> & {
|
|||||||
spinnerProps?: HTMLAttributes<HTMLDivElement>,
|
spinnerProps?: HTMLAttributes<HTMLDivElement>,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LoaderPortal: React.FC<LoaderPortalProps> = ({ className, show, fade = true, children, spinnerProps, ...other }) => (
|
export const LoaderPortal: React.FC<LoaderPortalProps> = ({ className = '', show, fade = true, children, spinnerProps, ...other }) => (
|
||||||
<div className={`loader-container ${className}`} {...other}>
|
<div className={`loader-container ${className}`} {...other}>
|
||||||
<div className={'loader-content'}>{children}</div>
|
<div className={'loader-content'}>{children}</div>
|
||||||
{show && fade && <div className={'loader-fade'}/>}
|
{show && fade && <div className={'loader-fade'}/>}
|
||||||
|
@ -20,7 +20,7 @@ export const PageHeader: React.FC<PageHeaderProps> = memo(({ title = 'Монит
|
|||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Layout.Header className={'header'} {...other}>
|
<Layout.Header className={'header'} {...other}>
|
||||||
<Link to={{ pathname: '/', state: { from: location.pathname }}} style={{ height: headerHeight }}>
|
<Link to={'/'} style={{ height: headerHeight }}>
|
||||||
<Logo />
|
<Logo />
|
||||||
</Link>
|
</Link>
|
||||||
{children}
|
{children}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { memo, ReactElement } from 'react'
|
import { memo, ReactElement } from 'react'
|
||||||
|
|
||||||
import { isURLAvailable } from '@utils/permissions'
|
import { isURLAvailable } from '@utils'
|
||||||
|
|
||||||
export type PrivateContentProps = {
|
export type PrivateContentProps = {
|
||||||
absolutePath: string
|
absolutePath: string
|
||||||
|
@ -1,25 +1,19 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import { Redirect, Route, RouteProps, useLocation } from 'react-router-dom'
|
import { Navigate, Route, RouteProps } from 'react-router-dom'
|
||||||
|
|
||||||
import { getUserId } from '@utils/storage'
|
import { isURLAvailable } from '@utils'
|
||||||
import { isURLAvailable } from '@utils/permissions'
|
|
||||||
|
import { getDefaultRedirectPath } from './PrivateRoutes'
|
||||||
|
|
||||||
export type PrivateDefaultRouteProps = RouteProps & {
|
export type PrivateDefaultRouteProps = RouteProps & {
|
||||||
urls: string[]
|
urls: string[]
|
||||||
elseRedirect?: string
|
elseRedirect?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PrivateDefaultRoute = memo<PrivateDefaultRouteProps>(({ elseRedirect, urls, ...other }) => {
|
export const PrivateDefaultRoute = memo<PrivateDefaultRouteProps>(({ elseRedirect, urls, ...other }) => (
|
||||||
const location = useLocation()
|
<Route {...other} path={'/'} element={(
|
||||||
|
<Navigate replace to={urls.find((url) => isURLAvailable(url)) ?? elseRedirect ?? getDefaultRedirectPath()} />
|
||||||
return (
|
)} />
|
||||||
<Route {...other} path={'/'}>
|
))
|
||||||
<Redirect to={{
|
|
||||||
pathname: urls.find((url) => isURLAvailable(url)) ?? elseRedirect ?? (getUserId() ? '/access_denied' : '/login'),
|
|
||||||
state: { from: location.pathname },
|
|
||||||
}} />
|
|
||||||
</Route>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export default PrivateDefaultRoute
|
export default PrivateDefaultRoute
|
||||||
|
@ -1,47 +1,73 @@
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { Menu, MenuItemProps, MenuProps } from 'antd'
|
import { Menu, MenuProps } from 'antd'
|
||||||
import { Children, cloneElement, memo, ReactElement, useContext, useMemo } from 'react'
|
import { ItemType } from 'antd/lib/menu/hooks/useItems'
|
||||||
import { Link, useLocation } from 'react-router-dom'
|
import { Link, LinkProps } from 'react-router-dom'
|
||||||
|
import { Children, isValidElement, memo, ReactNode, RefAttributes, useMemo } from 'react'
|
||||||
|
|
||||||
import { RootPathContext } from '@asb/context'
|
import { useRootPath } from '@asb/context'
|
||||||
import { isURLAvailable } from '@utils/permissions'
|
import { getTabname, hasPermission, PrivateComponent, PrivateProps } from '@utils'
|
||||||
|
|
||||||
export type PrivateMenuProps = MenuProps & { root?: string }
|
export type PrivateMenuProps = MenuProps & { root?: string }
|
||||||
|
|
||||||
export type PrivateMenuLinkProps = MenuItemProps & {
|
export type PrivateMenuLinkProps = Partial<ItemType> & Omit<LinkProps, 'to'> & RefAttributes<HTMLAnchorElement> & {
|
||||||
tabName?: string
|
icon?: ReactNode
|
||||||
|
danger?: boolean
|
||||||
|
title?: ReactNode
|
||||||
|
content?: PrivateComponent<any>
|
||||||
path?: string
|
path?: string
|
||||||
title: string
|
|
||||||
visible?: boolean
|
visible?: boolean
|
||||||
|
permissions?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PrivateMenuLink = memo<PrivateMenuLinkProps>(({ tabName = '', path = '', title, ...other }) => {
|
export const PrivateMenuLink = memo<PrivateMenuLinkProps>(({ content, path = '', title, ...other }) => (
|
||||||
const location = useLocation()
|
<Link to={path} {...other}>{title ?? content?.title}</Link>
|
||||||
return (
|
))
|
||||||
<Menu.Item key={tabName} {...other}>
|
|
||||||
<Link to={{ pathname: path, state: { from: location.pathname }}}>{title}</Link>
|
|
||||||
</Menu.Item>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const PrivateMenuMain = memo<PrivateMenuProps>(({ root, children, ...other }) => {
|
const PrivateMenuMain = memo<PrivateMenuProps>(({ selectable, mode, selectedKeys, root, children, ...other }) => {
|
||||||
const rootContext = useContext(RootPathContext)
|
const rootContext = useRootPath()
|
||||||
const rootPath = useMemo(() => root ?? rootContext ?? '', [root, rootContext])
|
const rootPath = useMemo(() => root ?? rootContext ?? '', [root, rootContext])
|
||||||
|
|
||||||
const items = useMemo(() => Children.toArray(children).map((child) => {
|
const tab = getTabname()
|
||||||
const element = child as ReactElement
|
const keys = useMemo(() => selectedKeys ?? (tab ? [tab] : []), [selectedKeys, tab])
|
||||||
let key = element.key?.toString()
|
|
||||||
const visible: boolean | undefined = element.props.visible
|
const items = useMemo(() => Children.map(children, (child) => {
|
||||||
if (key && visible !== false) {
|
if (!child || !isValidElement<PrivateMenuLinkProps>(child))
|
||||||
key = key.slice(key.lastIndexOf('$') + 1) // Ключ автоматический преобразуется в "(.+)\$ключ"
|
return null
|
||||||
const path = join(rootPath, key)
|
const content: PrivateProps | undefined = child.props.content
|
||||||
if (visible || isURLAvailable(path))
|
const visible: boolean | undefined = child.props.visible
|
||||||
return cloneElement(element, { key, path, tabName: key })
|
|
||||||
|
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
|
return null
|
||||||
}), [children, rootPath])
|
})?.filter((v) => v) ?? [], [children, rootPath])
|
||||||
|
|
||||||
return <Menu children={items} {...other} />
|
return (
|
||||||
|
<Menu
|
||||||
|
selectable={selectable ?? true}
|
||||||
|
mode={mode ?? 'horizontal'}
|
||||||
|
selectedKeys={keys}
|
||||||
|
items={items as ItemType[]}
|
||||||
|
{...other}
|
||||||
|
/>
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const PrivateMenu = Object.assign(PrivateMenuMain, { Link: PrivateMenuLink })
|
export const PrivateMenu = Object.assign(PrivateMenuMain, { Link: PrivateMenuLink })
|
||||||
|
@ -3,7 +3,7 @@ import { Menu, MenuItemProps } from 'antd'
|
|||||||
import { memo, NamedExoticComponent } from 'react'
|
import { memo, NamedExoticComponent } from 'react'
|
||||||
import { Link, useLocation } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import { isURLAvailable } from '@utils/permissions'
|
import { isURLAvailable } from '@utils'
|
||||||
|
|
||||||
export type PrivateMenuItemProps = MenuItemProps & {
|
export type PrivateMenuItemProps = MenuItemProps & {
|
||||||
root: string
|
root: string
|
||||||
@ -20,7 +20,7 @@ export const PrivateMenuItemLink = memo<PrivateMenuItemLinkProps>(({ root = '',
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
return (
|
return (
|
||||||
<PrivateMenuItem key={path} root={root} path={path} {...other}>
|
<PrivateMenuItem key={path} root={root} path={path} {...other}>
|
||||||
<Link to={{ pathname: join(root, path), state: { from: location.pathname }}}>{title}</Link>
|
<Link to={join(root, path)}>{title}</Link>
|
||||||
</PrivateMenuItem>
|
</PrivateMenuItem>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
@ -1,31 +1,28 @@
|
|||||||
import { Location } from 'history'
|
|
||||||
import { memo, ReactNode } from 'react'
|
|
||||||
import { Redirect, Route, RouteProps } from 'react-router-dom'
|
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
import { memo, ReactNode } from 'react'
|
||||||
|
import { Navigate, Route, RouteProps } from 'react-router-dom'
|
||||||
|
|
||||||
import { getUserId } from '@utils/storage'
|
import { getUserId, isURLAvailable } from '@utils'
|
||||||
import { isURLAvailable } from '@utils/permissions'
|
|
||||||
|
|
||||||
export type PrivateRouteProps = RouteProps & {
|
export type PrivateRouteProps = RouteProps & {
|
||||||
root?: string
|
root?: string
|
||||||
path: string
|
path: string
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
redirect?: (location?: Location<unknown>) => ReactNode
|
redirect?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultRedirect = (location?: Location<unknown>) => (
|
export const defaultRedirect = (
|
||||||
<Redirect to={{ pathname: getUserId() ? '/access_denied' : '/login', state: { from: location?.pathname } }} />
|
<Navigate to={getUserId() ? '/access_denied' : '/login'} />
|
||||||
)
|
)
|
||||||
|
|
||||||
export const PrivateRoute = memo<PrivateRouteProps>(({ root = '', path, component, children, redirect = defaultRedirect, ...other }) => {
|
export const PrivateRoute = memo<PrivateRouteProps>(({ root = '', path, children, redirect = defaultRedirect, ...other }) => {
|
||||||
const available = isURLAvailable(join(root, path))
|
const available = isURLAvailable(join(root, path))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Route
|
<Route
|
||||||
{...other}
|
{...other}
|
||||||
path={path}
|
path={path}
|
||||||
component={available ? component : undefined}
|
element={available ? children : redirect}
|
||||||
render={({ location }) => available ? children : redirect(location)}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
67
src/components/Private/PrivateRoutes.tsx
Normal file
67
src/components/Private/PrivateRoutes.tsx
Normal 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
|
@ -1,75 +0,0 @@
|
|||||||
import { join } from 'path'
|
|
||||||
import { Location } from 'history'
|
|
||||||
import { Children, cloneElement, memo, ReactElement, ReactNode, useCallback, useContext, useMemo } from 'react'
|
|
||||||
import { Redirect, Route, Switch, SwitchProps, useLocation } from 'react-router-dom'
|
|
||||||
|
|
||||||
import { RootPathContext } from '@asb/context'
|
|
||||||
import { isURLAvailable } from '@utils/permissions'
|
|
||||||
import { getUserId } from '@utils/storage'
|
|
||||||
|
|
||||||
|
|
||||||
export type PrivateSwitchProps = SwitchProps & {
|
|
||||||
root?: string
|
|
||||||
redirect?: (location?: Location<unknown>) => ReactNode
|
|
||||||
elseRedirect?: string | string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDefaultRedirectPath = () => getUserId() ? '/access_denied' : '/login'
|
|
||||||
|
|
||||||
export const defaultRedirect = (location?: Location<unknown>) => (
|
|
||||||
<Redirect to={{ pathname: getDefaultRedirectPath(), state: { from: location?.pathname } }} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export const PrivateSwitch = memo<PrivateSwitchProps>(({ root, elseRedirect, redirect = defaultRedirect, children }) => {
|
|
||||||
const rootContext = useContext(RootPathContext)
|
|
||||||
const rootPath = useMemo(() => root ?? rootContext ?? '', [root, rootContext])
|
|
||||||
|
|
||||||
const location = useLocation()
|
|
||||||
|
|
||||||
const toAbsolute = useCallback((path: string) => path.startsWith('/') ? path : join(rootPath, path), [rootPath])
|
|
||||||
|
|
||||||
const items = useMemo(() => Children.toArray(children).map((child) => {
|
|
||||||
const element = child as ReactElement
|
|
||||||
let key = element.key?.toString()
|
|
||||||
if (!key) return null
|
|
||||||
key = key.slice(key.lastIndexOf('$') + 1).replaceAll('=2', ':')
|
|
||||||
// Ключ автоматический преобразуется в "(.+)\$ключ"
|
|
||||||
// Все ":" в ключе заменяются на "=2"
|
|
||||||
// TODO: улучшить метод нормализации ключа
|
|
||||||
const path = toAbsolute(key)
|
|
||||||
return (
|
|
||||||
<Route
|
|
||||||
key={key}
|
|
||||||
path={path}
|
|
||||||
render={({ location }) => isURLAvailable(path) ? cloneElement(element) : redirect(location)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}), [children, redirect, toAbsolute])
|
|
||||||
|
|
||||||
const defaultRoute = useMemo(() => {
|
|
||||||
if (!elseRedirect) {
|
|
||||||
const path = items.map((elm) => elm?.props.path).find((path) => path && isURLAvailable(path))
|
|
||||||
if (path) return path
|
|
||||||
} else if (Array.isArray(elseRedirect)) {
|
|
||||||
const path = elseRedirect.find((path) => {
|
|
||||||
if (!path) return false
|
|
||||||
return isURLAvailable(toAbsolute(path))
|
|
||||||
})
|
|
||||||
if (path) return toAbsolute(path)
|
|
||||||
} else if(elseRedirect && isURLAvailable(toAbsolute(elseRedirect))) {
|
|
||||||
return toAbsolute(elseRedirect)
|
|
||||||
}
|
|
||||||
return getDefaultRedirectPath()
|
|
||||||
}, [items, elseRedirect, toAbsolute])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Switch>
|
|
||||||
{items}
|
|
||||||
<Route path={'/'}>
|
|
||||||
<Redirect to={{ pathname: defaultRoute, state: { from: location.pathname } }} />
|
|
||||||
</Route>
|
|
||||||
</Switch>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export default PrivateSwitch
|
|
@ -3,11 +3,11 @@ export { PrivateContent } from './PrivateContent' // TODO: Remove
|
|||||||
export { PrivateMenuItem, PrivateMenuItemLink } from './PrivateMenuItem' // TODO: Remove
|
export { PrivateMenuItem, PrivateMenuItemLink } from './PrivateMenuItem' // TODO: Remove
|
||||||
export { PrivateDefaultRoute } from './PrivateDefaultRoute'
|
export { PrivateDefaultRoute } from './PrivateDefaultRoute'
|
||||||
export { PrivateMenu, PrivateMenuLink } from './PrivateMenu'
|
export { PrivateMenu, PrivateMenuLink } from './PrivateMenu'
|
||||||
export { PrivateSwitch } from './PrivateSwitch'
|
export { PrivateRoutes } from './PrivateRoutes'
|
||||||
|
|
||||||
export type { PrivateRouteProps } from './PrivateRoute'
|
export type { PrivateRouteProps } from './PrivateRoute'
|
||||||
export type { PrivateContentProps } from './PrivateContent' // TODO: Remove
|
export type { PrivateContentProps } from './PrivateContent' // TODO: Remove
|
||||||
export type { PrivateMenuItemProps, PrivateMenuItemLinkProps } from './PrivateMenuItem' // TODO: Remove
|
export type { PrivateMenuItemProps, PrivateMenuItemLinkProps } from './PrivateMenuItem' // TODO: Remove
|
||||||
export type { PrivateDefaultRouteProps } from './PrivateDefaultRoute'
|
export type { PrivateDefaultRouteProps } from './PrivateDefaultRoute'
|
||||||
export type { PrivateMenuProps, PrivateMenuLinkProps } from './PrivateMenu'
|
export type { PrivateMenuProps, PrivateMenuLinkProps } from './PrivateMenu'
|
||||||
export type { PrivateSwitchProps } from './PrivateSwitch'
|
export type { PrivateRoutesProps } from './PrivateRoutes'
|
||||||
|
@ -4,13 +4,15 @@ import { formatDate } from '@utils'
|
|||||||
|
|
||||||
import makeColumn, { columnPropsOther } from '.'
|
import makeColumn, { columnPropsOther } from '.'
|
||||||
import { DatePickerWrapper, makeDateSorter } from '..'
|
import { DatePickerWrapper, makeDateSorter } from '..'
|
||||||
|
import { DatePickerWrapperProps } from '../DatePickerWrapper'
|
||||||
|
|
||||||
export const makeDateColumn = (
|
export const makeDateColumn = (
|
||||||
title: ReactNode,
|
title: ReactNode,
|
||||||
key: string,
|
key: string,
|
||||||
utc?: boolean,
|
utc?: boolean,
|
||||||
format?: string,
|
format?: string,
|
||||||
other?: columnPropsOther
|
other?: columnPropsOther,
|
||||||
|
pickerOther?: DatePickerWrapperProps,
|
||||||
) => makeColumn(title, key, {
|
) => makeColumn(title, key, {
|
||||||
...other,
|
...other,
|
||||||
render: (date) => (
|
render: (date) => (
|
||||||
@ -19,7 +21,7 @@ export const makeDateColumn = (
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
sorter: makeDateSorter(key),
|
sorter: makeDateSorter(key),
|
||||||
input: <DatePickerWrapper />,
|
input: <DatePickerWrapper {...pickerOther} />,
|
||||||
})
|
})
|
||||||
|
|
||||||
export default makeDateColumn
|
export default makeDateColumn
|
||||||
|
@ -3,6 +3,7 @@ import { Rule } from 'antd/lib/form'
|
|||||||
import { ColumnProps } from 'antd/lib/table'
|
import { ColumnProps } from 'antd/lib/table'
|
||||||
|
|
||||||
export { makeDateColumn } from './date'
|
export { makeDateColumn } from './date'
|
||||||
|
export { makeTimeColumn } from './time'
|
||||||
export {
|
export {
|
||||||
RegExpIsFloat,
|
RegExpIsFloat,
|
||||||
makeNumericRender,
|
makeNumericRender,
|
||||||
@ -30,10 +31,6 @@ export type DataType<T = any> = Record<string, T>
|
|||||||
export type RenderMethod<T = any> = (value: T, dataset?: DataType<T>, index?: number) => ReactNode
|
export type RenderMethod<T = any> = (value: T, dataset?: DataType<T>, index?: number) => ReactNode
|
||||||
export type SorterMethod<T = any> = (a?: DataType<T> | null, b?: DataType<T> | null) => number
|
export type SorterMethod<T = any> = (a?: DataType<T> | null, b?: DataType<T> | null) => number
|
||||||
|
|
||||||
/*
|
|
||||||
other - объект с дополнительными свойствами колонки
|
|
||||||
поддерживаются все базовые свойства из описания https://ant.design/components/table/#Column
|
|
||||||
плю дополнительные для колонок EditableTable: */
|
|
||||||
export type columnPropsOther<T = any> = ColumnProps<T> & {
|
export type columnPropsOther<T = any> = ColumnProps<T> & {
|
||||||
// редактируемая колонка
|
// редактируемая колонка
|
||||||
editable?: boolean
|
editable?: boolean
|
||||||
|
@ -14,7 +14,7 @@ export const makeSelectColumn = <T extends unknown = string>(
|
|||||||
...other,
|
...other,
|
||||||
input: <Select options={options} {...selectOther}/>,
|
input: <Select options={options} {...selectOther}/>,
|
||||||
render: (value) => {
|
render: (value) => {
|
||||||
const item = options?.find(option => option?.value === value)
|
const item = options?.find(option => String(option?.value) === String(value))
|
||||||
return other?.render?.(item?.value) ?? item?.label ?? defaultValue ?? value ?? '--'
|
return other?.render?.(item?.value) ?? item?.label ?? defaultValue ?? value ?? '--'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { memo, ReactNode, useCallback, useEffect, useState } from 'react'
|
import { memo, ReactNode, useCallback, useEffect, useState } from 'react'
|
||||||
import { Select, SelectProps, Tag } from 'antd'
|
|
||||||
import { DefaultOptionType, SelectValue } from 'antd/lib/select'
|
import { DefaultOptionType, SelectValue } from 'antd/lib/select'
|
||||||
|
import { Select, SelectProps, Tag } from 'antd'
|
||||||
|
|
||||||
import { OmitExtends } from '@utils'
|
import type { OmitExtends } from '@utils/types'
|
||||||
|
|
||||||
import { columnPropsOther, DataType, makeColumn } from '.'
|
import { columnPropsOther, DataType, makeColumn } from '.'
|
||||||
|
|
||||||
|
26
src/components/Table/Columns/time.tsx
Normal file
26
src/components/Table/Columns/time.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { formatTime } from '@utils'
|
||||||
|
|
||||||
|
import { makeColumn, columnPropsOther } from '.'
|
||||||
|
import { makeTimeSorter, TimePickerWrapper, TimePickerWrapperProps } from '..'
|
||||||
|
|
||||||
|
export const makeTimeColumn = (
|
||||||
|
title: ReactNode,
|
||||||
|
key: string,
|
||||||
|
utc?: boolean,
|
||||||
|
format?: string,
|
||||||
|
other?: columnPropsOther,
|
||||||
|
pickerOther?: TimePickerWrapperProps,
|
||||||
|
) => makeColumn(title, key, {
|
||||||
|
...other,
|
||||||
|
render: (time) => (
|
||||||
|
<div className={'text-align-r-container'}>
|
||||||
|
<span>{formatTime(time, utc, format) ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
sorter: makeTimeSorter(key),
|
||||||
|
input: <TimePickerWrapper isUTC={utc} {...pickerOther} />,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default makeTimeColumn
|
@ -1,8 +1,8 @@
|
|||||||
import { 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 { OmitExtends } from '@utils'
|
import { findTimezoneId, rawTimezones, TimezoneId } from '@utils'
|
||||||
import { findTimezoneId, rawTimezones, TimezoneId } from '@utils/datetime'
|
import type { OmitExtends } from '@utils/types'
|
||||||
import { SimpleTimezoneDto } from '@api'
|
import { SimpleTimezoneDto } from '@api'
|
||||||
|
|
||||||
import { columnPropsOther, makeColumn } from '.'
|
import { columnPropsOther, makeColumn } from '.'
|
||||||
|
@ -16,7 +16,7 @@ export const DatePickerWrapper = memo<DatePickerWrapperProps>(({ value, onChange
|
|||||||
showTime
|
showTime
|
||||||
allowClear={false}
|
allowClear={false}
|
||||||
format={defaultFormat}
|
format={defaultFormat}
|
||||||
defaultValue={moment()}
|
defaultValue={undefined}
|
||||||
onChange={(date) => onChange?.(date)}
|
onChange={(date) => onChange?.(date)}
|
||||||
value={value && (isUTC ? moment.utc(value).local() : moment(value))}
|
value={value && (isUTC ? moment.utc(value).local() : moment(value))}
|
||||||
{...other}
|
{...other}
|
||||||
|
@ -9,14 +9,17 @@ import { defaultFormat } from '@utils'
|
|||||||
const { RangePicker } = DatePicker
|
const { RangePicker } = DatePicker
|
||||||
|
|
||||||
export type DateRangeWrapperProps = RangePickerSharedProps<Moment> & {
|
export type DateRangeWrapperProps = RangePickerSharedProps<Moment> & {
|
||||||
value: RangeValue<Moment>,
|
value?: RangeValue<Moment>,
|
||||||
isUTC?: boolean
|
isUTC?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeDates = (value: RangeValue<Moment>, isUTC?: boolean): RangeValue<Moment> => value && [
|
const normalizeDates = (value?: RangeValue<Moment>, isUTC?: boolean): RangeValue<Moment> => {
|
||||||
|
if (!value) return [null, null]
|
||||||
|
return [
|
||||||
value[0] ? (isUTC ? moment.utc(value[0]).local() : moment(value[0])) : null,
|
value[0] ? (isUTC ? moment.utc(value[0]).local() : moment(value[0])) : null,
|
||||||
value[1] ? (isUTC ? moment.utc(value[1]).local() : moment(value[1])) : null,
|
value[1] ? (isUTC ? moment.utc(value[1]).local() : moment(value[1])) : null,
|
||||||
]
|
]
|
||||||
|
}
|
||||||
|
|
||||||
export const DateRangeWrapper = memo<DateRangeWrapperProps>(({ value, isUTC, ...other }) => (
|
export const DateRangeWrapper = memo<DateRangeWrapperProps>(({ value, isUTC, ...other }) => (
|
||||||
<RangePicker
|
<RangePicker
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
import { memo } from 'react'
|
import { memo, ReactNode } from 'react'
|
||||||
import { Form, Input } from 'antd'
|
import { Form, Input } from 'antd'
|
||||||
import { NamePath, Rule } from 'rc-field-form/lib/interface'
|
import { NamePath, Rule } from 'rc-field-form/lib/interface'
|
||||||
|
|
||||||
type EditableCellProps = React.DetailedHTMLProps<React.TdHTMLAttributes<HTMLTableDataCellElement>, HTMLTableDataCellElement> & {
|
type EditableCellProps = React.DetailedHTMLProps<React.TdHTMLAttributes<HTMLTableDataCellElement>, HTMLTableDataCellElement> & {
|
||||||
editing?: boolean
|
editing?: boolean
|
||||||
dataIndex?: NamePath
|
dataIndex?: NamePath
|
||||||
input?: React.Component
|
input?: ReactNode
|
||||||
isRequired?: boolean
|
isRequired?: boolean
|
||||||
title: string
|
title: string
|
||||||
formItemClass?: string
|
formItemClass?: string
|
||||||
formItemRules?: Rule[]
|
formItemRules?: Rule[]
|
||||||
children: React.ReactNode
|
children: ReactNode
|
||||||
initialValue: any
|
initialValue: any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,32 +1,40 @@
|
|||||||
import { memo, useCallback, useState, useEffect } from 'react'
|
import { memo, useCallback, useState, useEffect, useMemo } from 'react'
|
||||||
import { Form, Button, Popconfirm } from 'antd'
|
import { Form, Button, Popconfirm } 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 { Table } from '.'
|
import { Table } from '.'
|
||||||
import { EditableCell } from './EditableCell'
|
import { EditableCell } from './EditableCell'
|
||||||
|
|
||||||
const newRowKeyValue = 'newRow'
|
const newRowKeyValue = 'newRow'
|
||||||
|
|
||||||
const actions = [
|
const actions = {
|
||||||
[['insert'], (data) => [data]],
|
insert: (data, idWell) => [idWell, data],
|
||||||
[['insertRange'], (data) => [[data].flat(1)]],
|
insertRange: (data, idWell) => [idWell, [data].flat(1)],
|
||||||
[['edit', 'update', 'put'], (data) => data.id && [data.id, data]],
|
update: (data, idWell, idRecord) => [idWell, idRecord && data.id, data],
|
||||||
[['delete'], (data) => data.id && [data.id]],
|
delete: (data, idWell) => [idWell, data.id],
|
||||||
]
|
}
|
||||||
|
|
||||||
export const makeActionHandler = (action, { idWell, service, setLoader, errorMsg, onComplete }, recordParser, actionName) => service && action && (
|
export const makeTableAction = ({
|
||||||
|
service,
|
||||||
|
permission,
|
||||||
|
action,
|
||||||
|
actionName,
|
||||||
|
recordParser,
|
||||||
|
idWell,
|
||||||
|
idRecord = false,
|
||||||
|
setLoader,
|
||||||
|
errorMsg = 'Не удалось выполнить операцию',
|
||||||
|
onComplete,
|
||||||
|
}) => hasPermission(permission) && service && action && (
|
||||||
(record) => invokeWebApiWrapperAsync(
|
(record) => invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
const addIdWell = (...params) => idWell ? [idWell, ...params] : params
|
const data = recordParser?.(record) ?? record
|
||||||
if (typeof recordParser === 'function')
|
const params = actions[action]?.(data, idWell, idRecord).filter(Boolean)
|
||||||
record = recordParser(record)
|
if (params?.length > 0)
|
||||||
|
await service[action](...params)
|
||||||
const actionId = actions.findIndex((elm) => elm[0].includes(action))
|
|
||||||
const params = actions[actionId]?.[1](record)
|
|
||||||
|
|
||||||
if (params) await service[action](...addIdWell(...params))
|
|
||||||
await onComplete?.()
|
await onComplete?.()
|
||||||
},
|
},
|
||||||
setLoader,
|
setLoader,
|
||||||
@ -43,13 +51,21 @@ export const tryAddKeys = (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 }}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param onChange - Метод вызывается со всем dataSource с измененными элементами после любого действия
|
||||||
|
* @param onRowAdd - Метод вызывается с новой добавленной записью. Если метод не определен, то кнопка добавления строки не показывается
|
||||||
|
* @param onRowEdit - Метод вызывается с новой отредактированной записью. Если метод не поределен, то кнопка редактирования строки не показывается
|
||||||
|
* @param onRowDelete - Метод вызывается с удаленной записью. Если метод не поределен, то кнопка удаления строки не показывается
|
||||||
|
*/
|
||||||
export const EditableTable = memo(({
|
export const EditableTable = memo(({
|
||||||
columns,
|
columns,
|
||||||
dataSource,
|
dataSource,
|
||||||
onChange, // Метод вызывается со всем dataSource с измененными элементами после любого действия
|
onChange,
|
||||||
onRowAdd, // Метод вызывается с новой добавленной записью. Если метод не определен, то кнопка добавления строки не показывается
|
onRowAdd,
|
||||||
onRowEdit, // Метод вызывается с новой отредактированной записью. Если метод не поределен, то кнопка редактирования строки не показывается
|
onRowEdit,
|
||||||
onRowDelete, // Метод вызывается с удаленной записью. Если метод не поределен, то кнопка удаления строки не показывается
|
onRowDelete,
|
||||||
additionalButtons,
|
additionalButtons,
|
||||||
buttonsWidth,
|
buttonsWidth,
|
||||||
...otherTableProps
|
...otherTableProps
|
||||||
@ -59,9 +75,9 @@ export const EditableTable = memo(({
|
|||||||
const [data, setData] = useState(tryAddKeys(dataSource))
|
const [data, setData] = useState(tryAddKeys(dataSource))
|
||||||
const [editingKey, setEditingKey] = useState('')
|
const [editingKey, setEditingKey] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
const onAdd = useMemo(() => onRowAdd && typeof onRowAdd !== 'function' ? makeTableAction(onRowAdd) : onRowAdd, [onRowAdd])
|
||||||
setData(tryAddKeys(dataSource))
|
const onEdit = useMemo(() => onRowEdit && typeof onRowEdit !== 'function' ? makeTableAction(onRowEdit) : onRowEdit, [onRowEdit])
|
||||||
}, [dataSource])
|
const onDelete = useMemo(() => onRowDelete && typeof onRowDelete !== 'function' ? makeTableAction(onRowDelete) : onRowDelete, [onRowDelete])
|
||||||
|
|
||||||
const isEditing = useCallback((record) => record?.key === editingKey, [editingKey])
|
const isEditing = useCallback((record) => record?.key === editingKey, [editingKey])
|
||||||
|
|
||||||
@ -110,13 +126,13 @@ export const EditableTable = memo(({
|
|||||||
|
|
||||||
if (isAdding)
|
if (isAdding)
|
||||||
try {
|
try {
|
||||||
onRowAdd(newItem)
|
onAdd(newItem)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('callback onRowAdd fault:', err)
|
console.log('callback onRowAdd fault:', err)
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
try {
|
try {
|
||||||
onRowEdit(newItem)
|
onEdit(newItem)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('callback onRowEdit fault:', err)
|
console.log('callback onRowEdit fault:', err)
|
||||||
}
|
}
|
||||||
@ -126,11 +142,10 @@ export const EditableTable = memo(({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('callback onChange fault:', err)
|
console.log('callback onChange fault:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (errInfo) {
|
} catch (errInfo) {
|
||||||
console.log('Validate Failed:', errInfo)
|
console.log('Validate Failed:', errInfo)
|
||||||
}
|
}
|
||||||
}, [data, editingKey, form, onChange, onRowAdd, onRowEdit])
|
}, [data, editingKey, form, onChange, onAdd, onEdit])
|
||||||
|
|
||||||
const deleteRow = useCallback((record) => {
|
const deleteRow = useCallback((record) => {
|
||||||
const newData = [...data]
|
const newData = [...data]
|
||||||
@ -139,44 +154,9 @@ export const EditableTable = memo(({
|
|||||||
newData.splice(index, 1)
|
newData.splice(index, 1)
|
||||||
setData(newData)
|
setData(newData)
|
||||||
|
|
||||||
onRowDelete(record)
|
onDelete(record)
|
||||||
onChange?.(newData)
|
onChange?.(newData)
|
||||||
}, [data, onChange, onRowDelete])
|
}, [data, onChange, onDelete])
|
||||||
|
|
||||||
const operationColumn = {
|
|
||||||
width: buttonsWidth ?? 82,
|
|
||||||
title: !!onRowAdd && (
|
|
||||||
<Button
|
|
||||||
onClick={addNewRow}
|
|
||||||
disabled={editingKey !== ''}
|
|
||||||
icon={<PlusOutlined/>}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
dataIndex: 'operation',
|
|
||||||
render: (_, record) => isEditing(record) ? (
|
|
||||||
<span>
|
|
||||||
<Button onClick={() => save(record)} icon={<SaveOutlined/>}/>
|
|
||||||
<Button onClick={cancel} icon={<CloseCircleOutlined/>}/>
|
|
||||||
{additionalButtons?.(record, editingKey)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span>
|
|
||||||
{onRowEdit && (
|
|
||||||
<Button
|
|
||||||
disabled={editingKey !== ''}
|
|
||||||
onClick={() => edit(record)}
|
|
||||||
icon={<EditOutlined/>}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{onRowDelete && (
|
|
||||||
<Popconfirm title={'Удалить?'} onConfirm={() => deleteRow(record)}>
|
|
||||||
<Button icon={<DeleteOutlined/>}/>
|
|
||||||
</Popconfirm>
|
|
||||||
)}
|
|
||||||
{additionalButtons?.(record, editingKey)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleColumn = useCallback((col) => {
|
const handleColumn = useCallback((col) => {
|
||||||
if (col.children)
|
if (col.children)
|
||||||
@ -204,16 +184,51 @@ export const EditableTable = memo(({
|
|||||||
}
|
}
|
||||||
}, [isEditing])
|
}, [isEditing])
|
||||||
|
|
||||||
const mergedColumns = [...columns.map(handleColumn), operationColumn]
|
const operationColumn = useMemo(() => ({
|
||||||
|
width: buttonsWidth ?? 82,
|
||||||
|
title: !!onAdd && (
|
||||||
|
<Button
|
||||||
|
onClick={addNewRow}
|
||||||
|
disabled={editingKey !== ''}
|
||||||
|
icon={<PlusOutlined/>}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
dataIndex: 'operation',
|
||||||
|
render: (_, record) => isEditing(record) ? (
|
||||||
|
<span>
|
||||||
|
<Button onClick={() => save(record)} icon={<SaveOutlined/>}/>
|
||||||
|
<Button onClick={cancel} icon={<CloseCircleOutlined/>}/>
|
||||||
|
{additionalButtons?.(record, editingKey)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
{onEdit && (
|
||||||
|
<Button
|
||||||
|
disabled={editingKey !== ''}
|
||||||
|
onClick={() => edit(record)}
|
||||||
|
icon={<EditOutlined/>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<Popconfirm disabled={editingKey !== ''} title={'Удалить?'} onConfirm={() => deleteRow(record)}>
|
||||||
|
<Button disabled={editingKey !== ''} icon={<DeleteOutlined/>}/>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
{additionalButtons?.(record, editingKey)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}), [onAdd, onEdit, onDelete, isEditing, editingKey, save, cancel, edit, deleteRow])
|
||||||
|
|
||||||
|
const mergedColumns = useMemo(() => [...columns.map(handleColumn), operationColumn], [columns, handleColumn, operationColumn])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setData(tryAddKeys(dataSource))
|
||||||
|
}, [dataSource])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form form={form}>
|
<Form form={form}>
|
||||||
<Table
|
<Table
|
||||||
components={{
|
components={EditableTableComponents}
|
||||||
body: {
|
|
||||||
cell: EditableCell,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
columns={mergedColumns}
|
columns={mergedColumns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
{...otherTableProps}
|
{...otherTableProps}
|
||||||
|
@ -2,9 +2,8 @@ import { memo, useCallback, useEffect, useState } from 'react'
|
|||||||
import { ColumnGroupType, ColumnType } from 'antd/lib/table'
|
import { ColumnGroupType, ColumnType } from 'antd/lib/table'
|
||||||
import { Table as RawTable, TableProps } from 'antd'
|
import { Table as RawTable, TableProps } from 'antd'
|
||||||
|
|
||||||
import { OmitExtends } from '@utils'
|
import type { OmitExtends } from '@utils/types'
|
||||||
import { getTableSettings, setTableSettings } from '@utils/storage'
|
import { applyTableSettings, getTableSettings, setTableSettings, TableColumnSettings, TableSettings } from '@utils'
|
||||||
import { applySettings, ColumnSettings, TableSettings } from '@utils/table_settings'
|
|
||||||
|
|
||||||
import TableSettingsChanger from './TableSettingsChanger'
|
import TableSettingsChanger from './TableSettingsChanger'
|
||||||
import { tryAddKeys } from './EditableTable'
|
import { tryAddKeys } from './EditableTable'
|
||||||
@ -12,7 +11,7 @@ import { tryAddKeys } from './EditableTable'
|
|||||||
import '@styles/index.css'
|
import '@styles/index.css'
|
||||||
|
|
||||||
export type BaseTableColumn<T = any> = ColumnGroupType<T> | ColumnType<T>
|
export type BaseTableColumn<T = any> = ColumnGroupType<T> | ColumnType<T>
|
||||||
export type TableColumns<T = any> = OmitExtends<BaseTableColumn<T>, ColumnSettings>[]
|
export type TableColumns<T = any> = OmitExtends<BaseTableColumn<T>, TableColumnSettings>[]
|
||||||
|
|
||||||
export type TableContainer = TableProps<any> & {
|
export type TableContainer = TableProps<any> & {
|
||||||
columns: TableColumns
|
columns: TableColumns
|
||||||
@ -33,7 +32,7 @@ export const Table = memo<TableContainer>(({ columns, dataSource, tableName, sho
|
|||||||
|
|
||||||
useEffect(() => setSettings(tableName ? getTableSettings(tableName) : {}), [tableName])
|
useEffect(() => setSettings(tableName ? getTableSettings(tableName) : {}), [tableName])
|
||||||
useEffect(() => setNewColumns(() => {
|
useEffect(() => setNewColumns(() => {
|
||||||
const newColumns = applySettings(columns, settings)
|
const newColumns = applyTableSettings(columns, settings)
|
||||||
if (tableName && showSettingsChanger) {
|
if (tableName && showSettingsChanger) {
|
||||||
const oldTitle = newColumns[0].title
|
const oldTitle = newColumns[0].title
|
||||||
newColumns[0].title = (props) => (
|
newColumns[0].title = (props) => (
|
||||||
|
@ -3,16 +3,16 @@ import { ColumnsType } from 'antd/lib/table'
|
|||||||
import { Button, Modal, Switch, Table } from 'antd'
|
import { Button, Modal, Switch, Table } from 'antd'
|
||||||
import { SettingOutlined } from '@ant-design/icons'
|
import { SettingOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
import { ColumnSettings, makeSettings, mergeSettings, TableSettings } from '@utils/table_settings'
|
import { TableColumnSettings, makeTableSettings, mergeTableSettings, TableSettings } from '@utils'
|
||||||
import { TableColumns } from './Table'
|
import { TableColumns } from './Table'
|
||||||
import { makeColumn } from '.'
|
import { makeColumn } from '.'
|
||||||
|
|
||||||
const parseSettings = (columns?: TableColumns, settings?: TableSettings | null): ColumnSettings[] => {
|
const parseSettings = (columns?: TableColumns, settings?: TableSettings | null): TableColumnSettings[] => {
|
||||||
const newSettings = mergeSettings(makeSettings(columns ?? []), settings ?? {})
|
const newSettings = mergeTableSettings(makeTableSettings(columns ?? []), settings ?? {})
|
||||||
return Object.values(newSettings).map((set, i) => ({ ...set, key: i }))
|
return Object.values(newSettings).map((set, i) => ({ ...set, key: i }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const unparseSettings = (columns: ColumnSettings[]): TableSettings =>
|
const unparseSettings = (columns: TableColumnSettings[]): TableSettings =>
|
||||||
Object.fromEntries(columns.map((column) => [column.columnName, column]))
|
Object.fromEntries(columns.map((column) => [column.columnName, column]))
|
||||||
|
|
||||||
export type TableSettingsChangerProps = {
|
export type TableSettingsChangerProps = {
|
||||||
@ -24,8 +24,8 @@ export type TableSettingsChangerProps = {
|
|||||||
|
|
||||||
export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, columns, settings, onChange }) => {
|
export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, columns, settings, onChange }) => {
|
||||||
const [visible, setVisible] = useState<boolean>(false)
|
const [visible, setVisible] = useState<boolean>(false)
|
||||||
const [newSettings, setNewSettings] = useState<ColumnSettings[]>(parseSettings(columns, settings))
|
const [newSettings, setNewSettings] = useState<TableColumnSettings[]>(parseSettings(columns, settings))
|
||||||
const [tableColumns, setTableColumns] = useState<ColumnsType<ColumnSettings>>([])
|
const [tableColumns, setTableColumns] = useState<ColumnsType<TableColumnSettings>>([])
|
||||||
|
|
||||||
const onVisibilityChange = useCallback((index: number, visible: boolean) => {
|
const onVisibilityChange = useCallback((index: number, visible: boolean) => {
|
||||||
setNewSettings((oldSettings) => {
|
setNewSettings((oldSettings) => {
|
||||||
@ -52,7 +52,7 @@ export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, co
|
|||||||
<Button type={'link'} onClick={() => toogleAll(true)}>Показать все</Button>
|
<Button type={'link'} onClick={() => toogleAll(true)}>Показать все</Button>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
render: (visible: boolean, _?: ColumnSettings, index: number = NaN) => (
|
render: (visible: boolean, _?: TableColumnSettings, index: number = NaN) => (
|
||||||
<Switch
|
<Switch
|
||||||
checked={visible}
|
checked={visible}
|
||||||
checkedChildren={'Отображён'}
|
checkedChildren={'Отображён'}
|
||||||
|
31
src/components/Table/TimePickerWrapper.tsx
Normal file
31
src/components/Table/TimePickerWrapper.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Moment } from 'moment'
|
||||||
|
import { TimePicker, TimePickerProps } from 'antd'
|
||||||
|
import { memo, useCallback, useMemo } from 'react'
|
||||||
|
|
||||||
|
import { defaultTimeFormat, momentToTime, timeToMoment } from '@utils'
|
||||||
|
import { TimeDto } from '@api'
|
||||||
|
|
||||||
|
export type TimePickerWrapperProps = Omit<Omit<TimePickerProps, 'value'>, 'onChange'> & {
|
||||||
|
value?: TimeDto,
|
||||||
|
onChange?: (date: TimeDto | null) => any
|
||||||
|
isUTC?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TimePickerWrapper = memo<TimePickerWrapperProps>(({ value, onChange, isUTC, ...other }) => {
|
||||||
|
const time = useMemo(() => value ? timeToMoment(value, isUTC) : null, [value, isUTC])
|
||||||
|
|
||||||
|
const onTimeChange = useCallback((time: Moment | null) => onChange?.(time ? momentToTime(time) : null), [onChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TimePicker
|
||||||
|
allowClear={false}
|
||||||
|
format={defaultTimeFormat}
|
||||||
|
defaultValue={timeToMoment()}
|
||||||
|
onChange={onTimeChange}
|
||||||
|
value={time}
|
||||||
|
{...other}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default TimePickerWrapper
|
@ -1,6 +1,7 @@
|
|||||||
export { makeDateSorter, makeNumericSorter, makeStringSorter } from './sorters'
|
export { makeDateSorter, makeNumericSorter, makeStringSorter, makeTimeSorter } from './sorters'
|
||||||
export { EditableTable, makeActionHandler } from './EditableTable'
|
export { EditableTable, makeTableAction } from './EditableTable'
|
||||||
export { DatePickerWrapper } from './DatePickerWrapper'
|
export { DatePickerWrapper } from './DatePickerWrapper'
|
||||||
|
export { TimePickerWrapper } from './TimePickerWrapper'
|
||||||
export { DateRangeWrapper } from './DateRangeWrapper'
|
export { DateRangeWrapper } from './DateRangeWrapper'
|
||||||
export { Table } from './Table'
|
export { Table } from './Table'
|
||||||
export {
|
export {
|
||||||
@ -8,6 +9,7 @@ export {
|
|||||||
timezoneOptions,
|
timezoneOptions,
|
||||||
TimezoneSelect,
|
TimezoneSelect,
|
||||||
makeDateColumn,
|
makeDateColumn,
|
||||||
|
makeTimeColumn,
|
||||||
makeGroupColumn,
|
makeGroupColumn,
|
||||||
makeColumn,
|
makeColumn,
|
||||||
makeColumnsPlanFact,
|
makeColumnsPlanFact,
|
||||||
@ -36,6 +38,7 @@ export type {
|
|||||||
} from './Columns'
|
} from './Columns'
|
||||||
export type { DateRangeWrapperProps } from './DateRangeWrapper'
|
export type { DateRangeWrapperProps } from './DateRangeWrapper'
|
||||||
export type { DatePickerWrapperProps } from './DatePickerWrapper'
|
export type { DatePickerWrapperProps } from './DatePickerWrapper'
|
||||||
|
export type { TimePickerWrapperProps } from './TimePickerWrapper'
|
||||||
export type { BaseTableColumn, TableColumns, TableContainer } from './Table'
|
export type { BaseTableColumn, TableColumns, TableContainer } from './Table'
|
||||||
|
|
||||||
export const defaultPagination = {
|
export const defaultPagination = {
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
|
import { timeToMoment } from '@utils'
|
||||||
import { isRawDate } from '@utils'
|
import { isRawDate } from '@utils'
|
||||||
|
import { TimeDto } from '@api'
|
||||||
|
|
||||||
import { DataType } from './Columns'
|
import { DataType } from './Columns'
|
||||||
|
|
||||||
@ -23,3 +25,14 @@ export const makeDateSorter = <T extends unknown>(key: keyof DataType<T>) => (a:
|
|||||||
|
|
||||||
return date.getTime() - new Date(bdate).getTime()
|
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))
|
||||||
|
}
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Upload, Button } from 'antd'
|
|
||||||
import { UploadOutlined } from '@ant-design/icons'
|
|
||||||
import { UploadFile } from 'antd/lib/upload/interface'
|
import { UploadFile } from 'antd/lib/upload/interface'
|
||||||
|
import { UploadOutlined } from '@ant-design/icons'
|
||||||
import { RcFile } from 'antd/lib/upload'
|
import { RcFile } from 'antd/lib/upload'
|
||||||
|
import { Upload, Button } from 'antd'
|
||||||
|
|
||||||
|
import { isDev } from '@utils'
|
||||||
|
|
||||||
import { notify, upload } from './factory'
|
import { notify, upload } from './factory'
|
||||||
import { ErrorFetch } from './ErrorFetch'
|
import { ErrorFetch } from './ErrorFetch'
|
||||||
@ -32,8 +34,6 @@ export const UploadForm = memo<UploadFormProps>(({ url, disabled, style, formDat
|
|||||||
|
|
||||||
const accept = useMemo(() => Array.isArray(mimeTypes) ? mimeTypes.join(',') : mimeTypes, [mimeTypes])
|
const accept = useMemo(() => Array.isArray(mimeTypes) ? mimeTypes.join(',') : mimeTypes, [mimeTypes])
|
||||||
|
|
||||||
useEffect(() => console.log(fileList), [fileList])
|
|
||||||
|
|
||||||
const handleFileSend = useCallback(async () => {
|
const handleFileSend = useCallback(async () => {
|
||||||
onUploadStart?.()
|
onUploadStart?.()
|
||||||
try {
|
try {
|
||||||
@ -53,7 +53,7 @@ export const UploadForm = memo<UploadFormProps>(({ url, disabled, style, formDat
|
|||||||
onUploadSuccess?.()
|
onUploadSuccess?.()
|
||||||
}
|
}
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
if(process.env.NODE_ENV === 'development')
|
if(isDev())
|
||||||
console.error(error)
|
console.error(error)
|
||||||
onUploadError?.(error)
|
onUploadError?.(error)
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -1,19 +1,20 @@
|
|||||||
import { memo, MouseEventHandler, useCallback, useState } from 'react'
|
import { memo, MouseEventHandler, useCallback, useState } from 'react'
|
||||||
import { Link, useHistory, useLocation } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { Button, Dropdown, DropDownProps, Menu } from 'antd'
|
import { Button, Dropdown, DropDownProps } from 'antd'
|
||||||
import { UserOutlined } from '@ant-design/icons'
|
import { UserOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
import { getUserLogin, removeUser } from '@utils/storage'
|
import { getUserLogin, removeUser } from '@utils'
|
||||||
|
|
||||||
import { ChangePassword } from './ChangePassword'
|
import { ChangePassword } from './ChangePassword'
|
||||||
import { PrivateMenuItemLink } from './Private/PrivateMenuItem'
|
import { PrivateMenu } from './Private'
|
||||||
|
|
||||||
|
import AdminPanel from '@pages/AdminPanel'
|
||||||
|
|
||||||
type UserMenuProps = Omit<DropDownProps, 'overlay'> & { isAdmin?: boolean }
|
type UserMenuProps = Omit<DropDownProps, 'overlay'> & { isAdmin?: boolean }
|
||||||
|
|
||||||
export const UserMenu = memo<UserMenuProps>(({ isAdmin, ...other }) => {
|
export const UserMenu = memo<UserMenuProps>(({ isAdmin, ...other }) => {
|
||||||
const [isModalVisible, setIsModalVisible] = useState<boolean>(false)
|
const [isModalVisible, setIsModalVisible] = useState<boolean>(false)
|
||||||
|
|
||||||
const history = useHistory()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
const onChangePasswordClick: MouseEventHandler = useCallback((e) => {
|
const onChangePasswordClick: MouseEventHandler = useCallback((e) => {
|
||||||
@ -23,8 +24,8 @@ export const UserMenu = memo<UserMenuProps>(({ isAdmin, ...other }) => {
|
|||||||
|
|
||||||
const onChangePasswordOk = useCallback(() => {
|
const onChangePasswordOk = useCallback(() => {
|
||||||
setIsModalVisible(false)
|
setIsModalVisible(false)
|
||||||
history.push({ pathname: '/login', state: { from: location.pathname }})
|
navigate('/login', { state: { from: location.pathname }})
|
||||||
}, [history, location])
|
}, [navigate, location])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -32,19 +33,15 @@ export const UserMenu = memo<UserMenuProps>(({ isAdmin, ...other }) => {
|
|||||||
{...other}
|
{...other}
|
||||||
placement={'bottomRight'}
|
placement={'bottomRight'}
|
||||||
overlay={(
|
overlay={(
|
||||||
<Menu style={{ textAlign: 'right' }}>
|
<PrivateMenu mode={'vertical'} style={{ textAlign: 'right' }}>
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<PrivateMenuItemLink key={''} path={'/'} title={'Вернуться на сайт'}/>
|
<PrivateMenu.Link visible key={'/'} path={'/'} title={'Вернуться на сайт'} />
|
||||||
) : (
|
) : (
|
||||||
<PrivateMenuItemLink key={'admin'} path={'/admin'} title={'Панель администратора'}/>
|
<PrivateMenu.Link path={'/admin'} content={AdminPanel} />
|
||||||
)}
|
)}
|
||||||
<Menu.Item>
|
<PrivateMenu.Link visible key={'change_password'} onClick={onChangePasswordClick} title={'Сменить пароль'} />
|
||||||
<Link to={'/'} onClick={onChangePasswordClick}>Сменить пароль</Link>
|
<PrivateMenu.Link visible key={'login'} path={'/login'} onClick={removeUser} title={'Выход'} />
|
||||||
</Menu.Item>
|
</PrivateMenu>
|
||||||
<Menu.Item>
|
|
||||||
<Link to={{ pathname: '/login', state: { from: location.pathname }}} onClick={removeUser}>Выход</Link>
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu>
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Button icon={<UserOutlined/>}>{getUserLogin()}</Button>
|
<Button icon={<UserOutlined/>}>{getUserLogin()}</Button>
|
||||||
|
@ -10,7 +10,8 @@ import {
|
|||||||
ChartData,
|
ChartData,
|
||||||
ChartOptions,
|
ChartOptions,
|
||||||
ChartType,
|
ChartType,
|
||||||
ChartDataset
|
ChartDataset,
|
||||||
|
Tooltip
|
||||||
} from 'chart.js'
|
} from 'chart.js'
|
||||||
import 'chartjs-adapter-moment'
|
import 'chartjs-adapter-moment'
|
||||||
import ChartDataLabels from 'chartjs-plugin-datalabels'
|
import ChartDataLabels from 'chartjs-plugin-datalabels'
|
||||||
@ -24,7 +25,8 @@ Chart.register(
|
|||||||
PointElement,
|
PointElement,
|
||||||
Legend,
|
Legend,
|
||||||
ChartDataLabels,
|
ChartDataLabels,
|
||||||
zoomPlugin
|
zoomPlugin,
|
||||||
|
Tooltip,
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultOptions: ChartOptions = {
|
const defaultOptions: ChartOptions = {
|
||||||
|
373
src/components/d3/D3Chart.tsx
Normal file
373
src/components/d3/D3Chart.tsx
Normal file
@ -0,0 +1,373 @@
|
|||||||
|
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useElementSize } from 'usehooks-ts'
|
||||||
|
import { Property } from 'csstype'
|
||||||
|
import { Empty } from 'antd'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
|
||||||
|
import LoaderPortal from '@components/LoaderPortal'
|
||||||
|
import { isDev, usePartialProps } from '@utils'
|
||||||
|
|
||||||
|
import D3MouseZone from './D3MouseZone'
|
||||||
|
import { getChartClass } from './functions'
|
||||||
|
import {
|
||||||
|
renderArea,
|
||||||
|
renderLine,
|
||||||
|
renderPoint,
|
||||||
|
renderNeedle
|
||||||
|
} from './renders'
|
||||||
|
import {
|
||||||
|
BasePluginSettings,
|
||||||
|
D3ContextMenu,
|
||||||
|
D3ContextMenuSettings,
|
||||||
|
D3Cursor,
|
||||||
|
D3CursorSettings,
|
||||||
|
D3Legend,
|
||||||
|
D3LegendSettings,
|
||||||
|
D3Tooltip,
|
||||||
|
D3TooltipSettings,
|
||||||
|
} from './plugins'
|
||||||
|
import type {
|
||||||
|
ChartAxis,
|
||||||
|
ChartDataset,
|
||||||
|
ChartDomain,
|
||||||
|
ChartOffset,
|
||||||
|
ChartRegistry,
|
||||||
|
ChartTicks
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
import '@styles/d3.less'
|
||||||
|
|
||||||
|
const defaultOffsets: ChartOffset = {
|
||||||
|
top: 10,
|
||||||
|
bottom: 30,
|
||||||
|
left: 50,
|
||||||
|
right: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getByAccessor = <DataType extends Record<any, any>, R>(accessor: keyof DataType | ((d: DataType) => R)): ((d: DataType) => R) => {
|
||||||
|
if (typeof accessor === 'function')
|
||||||
|
return accessor
|
||||||
|
return (d) => d[accessor]
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAxis = <DataType,>(config: ChartAxis<DataType>) => {
|
||||||
|
if (config.type === 'time')
|
||||||
|
return d3.scaleTime()
|
||||||
|
return d3.scaleLinear()
|
||||||
|
}
|
||||||
|
|
||||||
|
export type D3ChartProps<DataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||||
|
xAxis: ChartAxis<DataType>
|
||||||
|
datasets: ChartDataset<DataType>[]
|
||||||
|
data?: DataType[] | Record<string, DataType[]>
|
||||||
|
domain?: Partial<ChartDomain>
|
||||||
|
width?: number | string
|
||||||
|
height?: number | string
|
||||||
|
loading?: boolean
|
||||||
|
offset?: Partial<ChartOffset>
|
||||||
|
animDurationMs?: number
|
||||||
|
backgroundColor?: Property.Color
|
||||||
|
ticks?: ChartTicks<DataType>
|
||||||
|
plugins?: {
|
||||||
|
menu?: BasePluginSettings & D3ContextMenuSettings
|
||||||
|
tooltip?: BasePluginSettings & D3TooltipSettings<DataType>
|
||||||
|
cursor?: BasePluginSettings & D3CursorSettings
|
||||||
|
legend?: BasePluginSettings & D3LegendSettings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDefaultXAxisConfig = <DataType,>(): ChartAxis<DataType> => ({
|
||||||
|
type: 'time',
|
||||||
|
accessor: (d: any) => new Date(d.date)
|
||||||
|
})
|
||||||
|
|
||||||
|
const _D3Chart = <DataType extends Record<string, unknown>>({
|
||||||
|
className = '',
|
||||||
|
xAxis: _xAxisConfig,
|
||||||
|
datasets,
|
||||||
|
data,
|
||||||
|
domain,
|
||||||
|
width: givenWidth = '100%',
|
||||||
|
height: givenHeight = '100%',
|
||||||
|
loading,
|
||||||
|
offset: _offset,
|
||||||
|
animDurationMs = 200,
|
||||||
|
backgroundColor = 'transparent',
|
||||||
|
ticks,
|
||||||
|
plugins,
|
||||||
|
...other
|
||||||
|
}: D3ChartProps<DataType>) => {
|
||||||
|
const xAxisConfig = usePartialProps<ChartAxis<DataType>>(_xAxisConfig, getDefaultXAxisConfig)
|
||||||
|
const offset = usePartialProps(_offset, defaultOffsets)
|
||||||
|
|
||||||
|
const [svgRef, setSvgRef] = useState<SVGSVGElement | null>(null)
|
||||||
|
const [xAxisRef, setXAxisRef] = useState<SVGGElement | null>(null)
|
||||||
|
const [yAxisRef, setYAxisRef] = useState<SVGGElement | null>(null)
|
||||||
|
const [chartAreaRef, setChartAreaRef] = useState<SVGGElement | null>(null)
|
||||||
|
|
||||||
|
const xAxisArea = useCallback(() => d3.select(xAxisRef), [xAxisRef])
|
||||||
|
const yAxisArea = useCallback(() => d3.select(yAxisRef), [yAxisRef])
|
||||||
|
const chartArea = useCallback(() => d3.select(chartAreaRef), [chartAreaRef])
|
||||||
|
|
||||||
|
const [charts, setCharts] = useState<ChartRegistry<DataType>[]>([])
|
||||||
|
|
||||||
|
const [rootRef, { width, height }] = useElementSize()
|
||||||
|
|
||||||
|
const xAxis = useMemo(() => {
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
const getX = getByAccessor(xAxisConfig.accessor)
|
||||||
|
|
||||||
|
const xAxis = createAxis(xAxisConfig)
|
||||||
|
xAxis.range([0, width - offset.left - offset.right])
|
||||||
|
|
||||||
|
if (domain?.x?.min && domain?.x?.max) {
|
||||||
|
xAxis.domain([domain.x.min, domain.x.max])
|
||||||
|
return xAxis
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
const [minX, maxX] = d3.extent(data, getX)
|
||||||
|
xAxis.domain([domain?.x?.min ?? minX, domain?.x?.max ?? maxX])
|
||||||
|
} else {
|
||||||
|
let [minX, maxX] = [Infinity, -Infinity]
|
||||||
|
for (const key in data) {
|
||||||
|
const [min, max] = d3.extent(data[key], getX)
|
||||||
|
if (min < minX) minX = min
|
||||||
|
if (max > maxX) maxX = max
|
||||||
|
}
|
||||||
|
xAxis.domain([domain?.x?.min ?? minX, domain?.x?.max ?? maxX])
|
||||||
|
}
|
||||||
|
|
||||||
|
return xAxis
|
||||||
|
}, [xAxisConfig, data, domain, width, offset])
|
||||||
|
|
||||||
|
const yAxis = useMemo(() => {
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
const yAxis = d3.scaleLinear()
|
||||||
|
|
||||||
|
if (domain?.y) {
|
||||||
|
const { min, max } = domain.y
|
||||||
|
if (min && max && Number.isFinite(min + max)) {
|
||||||
|
yAxis.domain([min, max])
|
||||||
|
return yAxis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let minY = Infinity
|
||||||
|
let maxY = -Infinity
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
charts.forEach(({ y }) => {
|
||||||
|
const [min, max] = d3.extent(data, y)
|
||||||
|
if (min && min < minY) minY = min
|
||||||
|
if (max && max > maxY) maxY = max
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
for (const key in data) {
|
||||||
|
const chart = charts.find((chart) => chart.key === key)
|
||||||
|
if (!chart) continue
|
||||||
|
const [min, max] = d3.extent(data[key], chart.y)
|
||||||
|
if (min && min < minY) minY = min
|
||||||
|
if (max && max > maxY) maxY = max
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yAxis.domain([
|
||||||
|
domain?.y?.min ?? minY,
|
||||||
|
domain?.y?.max ?? maxY,
|
||||||
|
])
|
||||||
|
|
||||||
|
yAxis.range([height - offset.top - offset.bottom, 0])
|
||||||
|
|
||||||
|
return yAxis
|
||||||
|
}, [charts, data, domain, height, offset])
|
||||||
|
|
||||||
|
const nTicks = {
|
||||||
|
color: 'lightgray',
|
||||||
|
...ticks,
|
||||||
|
x: {
|
||||||
|
visible: false,
|
||||||
|
format: (d: any) => String(d),
|
||||||
|
count: 10,
|
||||||
|
...ticks?.x,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { // Рисуем ось X
|
||||||
|
if (!xAxis) return
|
||||||
|
xAxisArea().transition()
|
||||||
|
.duration(animDurationMs)
|
||||||
|
.call(d3.axisBottom(xAxis)
|
||||||
|
.tickSize(nTicks.x.visible ? -height + offset.bottom : 0)
|
||||||
|
.tickFormat((d, i) => nTicks.x.format(d, i))
|
||||||
|
.ticks(nTicks.x.count) as any // TODO: Исправить тип
|
||||||
|
)
|
||||||
|
|
||||||
|
xAxisArea().selectAll('.tick line').attr('stroke', nTicks.x.color || nTicks.color)
|
||||||
|
}, [xAxisArea, xAxis, animDurationMs, height, offset, ticks])
|
||||||
|
|
||||||
|
useEffect(() => { // Рисуем ось Y
|
||||||
|
if (!yAxis) return
|
||||||
|
|
||||||
|
const nTicks = {
|
||||||
|
color: 'lightgray',
|
||||||
|
...ticks,
|
||||||
|
y: {
|
||||||
|
visible: false,
|
||||||
|
format: (d: any) => String(d),
|
||||||
|
count: 10,
|
||||||
|
...ticks?.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yAxisArea().transition()
|
||||||
|
.duration(animDurationMs)
|
||||||
|
.call(d3.axisLeft(yAxis)
|
||||||
|
.tickSize(nTicks.y.visible ? -width + offset.left + offset.right : 0)
|
||||||
|
.tickFormat((d, i) => nTicks.y.format(d, i))
|
||||||
|
.ticks(nTicks.y.count) as any // TODO: Исправить тип
|
||||||
|
)
|
||||||
|
|
||||||
|
yAxisArea().selectAll('.tick line').attr('stroke', nTicks.y.color || nTicks.color)
|
||||||
|
}, [yAxisArea, yAxis, animDurationMs, width, offset, ticks])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDev())
|
||||||
|
for (let i = 0; i < datasets.length - 1; i++)
|
||||||
|
for (let j = i + 1; j < datasets.length; j++)
|
||||||
|
if (datasets[i].key === datasets[j].key)
|
||||||
|
console.warn(`Ключ датасета "${datasets[i].key}" неуникален (индексы ${i} и ${j})!`)
|
||||||
|
|
||||||
|
setCharts((oldCharts) => {
|
||||||
|
const charts: ChartRegistry<DataType>[] = []
|
||||||
|
|
||||||
|
for (const chart of oldCharts) { // Удаляем ненужные графики
|
||||||
|
if (datasets.find(({ key }) => key === chart.key))
|
||||||
|
charts.push(chart)
|
||||||
|
else
|
||||||
|
chart().remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
datasets.forEach((dataset) => { // Добавляем новые
|
||||||
|
let chartIdx = charts.findIndex(({ key }) => key === dataset.key)
|
||||||
|
if (chartIdx < 0)
|
||||||
|
chartIdx = charts.length
|
||||||
|
|
||||||
|
const newChart: ChartRegistry<DataType> = Object.assign(
|
||||||
|
() => chartArea().select('.' + getChartClass(dataset.key)) as d3.Selection<SVGGElement, DataType, any, unknown>,
|
||||||
|
{
|
||||||
|
width: 1,
|
||||||
|
opacity: 1,
|
||||||
|
label: dataset.key,
|
||||||
|
color: 'gray',
|
||||||
|
animDurationMs,
|
||||||
|
...dataset,
|
||||||
|
xAxis: dataset.xAxis ?? xAxisConfig,
|
||||||
|
y: getByAccessor(dataset.yAxis.accessor),
|
||||||
|
x: getByAccessor(dataset.xAxis?.accessor ?? xAxisConfig.accessor),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (newChart.type === 'line')
|
||||||
|
newChart.optimization = false
|
||||||
|
|
||||||
|
if (!newChart().node())
|
||||||
|
chartArea()
|
||||||
|
.append('g')
|
||||||
|
.attr('class', getChartClass(newChart.key))
|
||||||
|
|
||||||
|
charts[chartIdx] = newChart
|
||||||
|
})
|
||||||
|
|
||||||
|
return charts
|
||||||
|
})
|
||||||
|
}, [xAxisConfig, chartArea, datasets, animDurationMs])
|
||||||
|
|
||||||
|
const redrawCharts = useCallback(() => {
|
||||||
|
if (!data || !xAxis || !yAxis) return
|
||||||
|
|
||||||
|
charts.forEach((chart) => {
|
||||||
|
chart()
|
||||||
|
.attr('color', chart.color || null)
|
||||||
|
.attr('stroke', 'currentColor')
|
||||||
|
.attr('stroke-width', chart.width ?? null)
|
||||||
|
.attr('opacity', chart.opacity ?? null)
|
||||||
|
.attr('fill', 'none')
|
||||||
|
|
||||||
|
let chartData = Array.isArray(data) ? data : data[String(chart.key).split(':')[0]]
|
||||||
|
if (!chartData) return
|
||||||
|
|
||||||
|
switch (chart.type) {
|
||||||
|
case 'needle':
|
||||||
|
chartData = renderNeedle<DataType>(xAxis, yAxis, chart, chartData, height, offset)
|
||||||
|
break
|
||||||
|
case 'line':
|
||||||
|
chartData = renderLine<DataType>(xAxis, yAxis, chart, chartData)
|
||||||
|
break
|
||||||
|
case 'point':
|
||||||
|
chartData = renderPoint<DataType>(xAxis, yAxis, chart, chartData)
|
||||||
|
break
|
||||||
|
case 'area':
|
||||||
|
chartData = renderArea<DataType>(xAxis, yAxis, chart, chartData)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chart.point)
|
||||||
|
renderPoint<DataType>(xAxis, yAxis, chart, chartData, true)
|
||||||
|
|
||||||
|
chart.afterDraw?.(chart)
|
||||||
|
})
|
||||||
|
}, [charts, data, xAxis, yAxis, height, offset])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
redrawCharts()
|
||||||
|
}, [redrawCharts])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoaderPortal
|
||||||
|
show={loading}
|
||||||
|
style={{
|
||||||
|
width: givenWidth,
|
||||||
|
height: givenHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
{...other}
|
||||||
|
ref={rootRef}
|
||||||
|
className={`asb-d3-chart ${className}`}
|
||||||
|
>
|
||||||
|
{data ? (
|
||||||
|
<D3ContextMenu {...plugins?.menu} svg={svgRef}>
|
||||||
|
<svg ref={setSvgRef} width={'100%'} height={'100%'}>
|
||||||
|
<g ref={setXAxisRef} className={'axis x'} transform={`translate(${offset.left}, ${height - offset.bottom})`} />
|
||||||
|
<g ref={setYAxisRef} className={'axis y'} transform={`translate(${offset.left}, ${offset.top})`} />
|
||||||
|
<g ref={setChartAreaRef} className={'chart-area'} transform={`translate(${offset.left}, ${offset.top})`}>
|
||||||
|
<rect
|
||||||
|
width={Math.max(width - offset.left - offset.right, 0)}
|
||||||
|
height={Math.max(height - offset.top - offset.bottom, 0)}
|
||||||
|
fill={backgroundColor}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<D3MouseZone width={width} height={height} offset={offset}>
|
||||||
|
<D3Cursor {...plugins?.cursor} />
|
||||||
|
<D3Legend<DataType> charts={charts} {...plugins?.legend} />
|
||||||
|
<D3Tooltip<DataType> charts={charts} {...plugins?.tooltip} />
|
||||||
|
</D3MouseZone>
|
||||||
|
</svg>
|
||||||
|
</D3ContextMenu>
|
||||||
|
) : (
|
||||||
|
<div className={'chart-empty'}>
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</LoaderPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const D3Chart = memo(_D3Chart) as typeof _D3Chart
|
||||||
|
|
||||||
|
export default D3Chart
|
495
src/components/d3/D3MonitoringCharts.tsx
Normal file
495
src/components/d3/D3MonitoringCharts.tsx
Normal file
@ -0,0 +1,495 @@
|
|||||||
|
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useElementSize } from 'usehooks-ts'
|
||||||
|
import { Property } from 'csstype'
|
||||||
|
import { Empty } from 'antd'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
|
||||||
|
import LoaderPortal from '@components/LoaderPortal'
|
||||||
|
import { isDev, usePartialProps } from '@utils'
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChartAxis,
|
||||||
|
ChartDataset,
|
||||||
|
ChartOffset,
|
||||||
|
ChartRegistry,
|
||||||
|
ChartTick,
|
||||||
|
MinMax
|
||||||
|
} from './types'
|
||||||
|
import {
|
||||||
|
BasePluginSettings,
|
||||||
|
D3ContextMenu,
|
||||||
|
D3ContextMenuSettings,
|
||||||
|
D3HorizontalCursor,
|
||||||
|
D3HorizontalCursorSettings,
|
||||||
|
D3TooltipSettings
|
||||||
|
} from './plugins'
|
||||||
|
import D3MouseZone from './D3MouseZone'
|
||||||
|
import { getByAccessor, getChartClass, getGroupClass, getTicks } from './functions'
|
||||||
|
import { renderArea, renderLine, renderNeedle, renderPoint } from './renders'
|
||||||
|
|
||||||
|
const roundTo = (v: number, to: number = 50) => {
|
||||||
|
if (to == 0) return v
|
||||||
|
if (v < 0) return Math.round(v / to) * to
|
||||||
|
return Math.ceil(v / to) * to
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateDomain = (mm: MinMax, round: number = 100): Required<MinMax> => {
|
||||||
|
let min = roundTo(mm.min ?? 0, round)
|
||||||
|
let max = roundTo(mm.max ?? round, round)
|
||||||
|
if (min - max < round) {
|
||||||
|
const mid = (min + max) / 2
|
||||||
|
min = mid - round
|
||||||
|
max = mid + round
|
||||||
|
}
|
||||||
|
return { min, max }
|
||||||
|
}
|
||||||
|
|
||||||
|
type AxisScale = d3.ScaleTime<number, number, never> | d3.ScaleLinear<number, number, never>
|
||||||
|
|
||||||
|
type ExtendedChartDataset<DataType> = ChartDataset<DataType> & {
|
||||||
|
xDomain: MinMax
|
||||||
|
hideXAxis?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtendedChartRegistry<DataType> = ExtendedChartDataset<DataType> & {
|
||||||
|
(): d3.Selection<SVGGElement, DataType, any, any>
|
||||||
|
y: (value: any) => number
|
||||||
|
x: (value: any) => number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChartGroup<DataType> = {
|
||||||
|
(): d3.Selection<SVGGElement, any, any, any>
|
||||||
|
key: number
|
||||||
|
charts: ExtendedChartRegistry<DataType>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOffsets: ChartOffset = {
|
||||||
|
top: 10,
|
||||||
|
bottom: 10,
|
||||||
|
left: 100,
|
||||||
|
right: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDefaultYAxisConfig = <DataType,>(): ChartAxis<DataType> => ({
|
||||||
|
type: 'time',
|
||||||
|
accessor: (d: any) => new Date(d.date)
|
||||||
|
})
|
||||||
|
|
||||||
|
const getDefaultYTicks = <DataType,>(): Required<ChartTick<DataType>> => ({
|
||||||
|
visible: false,
|
||||||
|
format: (d: d3.NumberValue, idx: number, data?: DataType) => String(d),
|
||||||
|
color: 'lightgray',
|
||||||
|
count: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
const findChartsByKey = <DataType,>(groups: ChartGroup<DataType>[], key: string) => {
|
||||||
|
const out: ChartRegistry<DataType>[] = []
|
||||||
|
groups.forEach((group) => {
|
||||||
|
const res = group.charts.find((chart) => chart.key === key)
|
||||||
|
if (res) out.push(res)
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export type D3MonitoringChartsProps<DataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||||
|
datasetGroups: ExtendedChartDataset<DataType>[][]
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
animDurationMs?: number
|
||||||
|
loading?: boolean
|
||||||
|
data?: DataType[]
|
||||||
|
offset?: Partial<ChartOffset>
|
||||||
|
backgroundColor?: Property.Color
|
||||||
|
yAxis?: ChartAxis<DataType>
|
||||||
|
plugins?: {
|
||||||
|
cursor?: BasePluginSettings & D3HorizontalCursorSettings<DataType>
|
||||||
|
menu?: BasePluginSettings & D3ContextMenuSettings
|
||||||
|
tooltip?: BasePluginSettings & D3TooltipSettings<DataType>
|
||||||
|
}
|
||||||
|
yTicks?: ChartTick<DataType>
|
||||||
|
yDomain?: {
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
}
|
||||||
|
onWheel: (e: WheelEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChartSizes = ChartOffset & {
|
||||||
|
inlineWidth: number
|
||||||
|
inlineHeight: number
|
||||||
|
groupWidth: number
|
||||||
|
axesHeight: number
|
||||||
|
chartsTop: number
|
||||||
|
chartsHeight: number
|
||||||
|
groupLeft: (i: number) => number
|
||||||
|
axisTop: (i: number, count: number) => number
|
||||||
|
}
|
||||||
|
|
||||||
|
const axisHeight = 20
|
||||||
|
const space = 30
|
||||||
|
|
||||||
|
const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
|
||||||
|
width: givenWidth = '100%',
|
||||||
|
height: givenHeight = '100%',
|
||||||
|
animDurationMs = 0,
|
||||||
|
loading = false,
|
||||||
|
datasetGroups,
|
||||||
|
data,
|
||||||
|
plugins,
|
||||||
|
offset: _offset,
|
||||||
|
yAxis: _yAxisConfig,
|
||||||
|
backgroundColor = 'transparent',
|
||||||
|
yDomain,
|
||||||
|
yTicks: _yTicks,
|
||||||
|
|
||||||
|
className = '',
|
||||||
|
...other
|
||||||
|
}: D3MonitoringChartsProps<DataType>) => {
|
||||||
|
const [groups, setGroups] = useState<ChartGroup<DataType>[]>([])
|
||||||
|
const [groupScales, setGroupScales] = useState<Record<string, AxisScale>[]>([])
|
||||||
|
const [svgRef, setSvgRef] = useState<SVGSVGElement | null>(null)
|
||||||
|
const [yAxisRef, setYAxisRef] = useState<SVGGElement | null>(null)
|
||||||
|
const [chartAreaRef, setChartAreaRef] = useState<SVGGElement | null>(null)
|
||||||
|
const [axesAreaRef, setAxesAreaRef] = useState<SVGGElement | null>(null)
|
||||||
|
|
||||||
|
const offset = usePartialProps(_offset, defaultOffsets)
|
||||||
|
const yTicks = usePartialProps<Required<ChartTick<DataType>>>(_yTicks, getDefaultYTicks)
|
||||||
|
const yAxisConfig = usePartialProps<ChartAxis<DataType>>(_yAxisConfig, getDefaultYAxisConfig)
|
||||||
|
|
||||||
|
const [rootRef, { width, height }] = useElementSize()
|
||||||
|
|
||||||
|
const yAxisArea = useCallback(() => d3.select(yAxisRef), [yAxisRef])
|
||||||
|
const axesArea = useCallback(() => d3.select(axesAreaRef), [axesAreaRef])
|
||||||
|
const chartArea = useCallback(() => d3.select(chartAreaRef), [chartAreaRef])
|
||||||
|
|
||||||
|
const sizes: ChartSizes = useMemo(() => {
|
||||||
|
const inlineWidth = Math.max(width - offset.left - offset.right, 0)
|
||||||
|
const inlineHeight = Math.max(height - offset.top - offset.bottom, 0)
|
||||||
|
const groupsCount = groups.length
|
||||||
|
|
||||||
|
const groupWidth = groupsCount ? (inlineWidth - space * (groupsCount - 1)) / groupsCount : 0
|
||||||
|
|
||||||
|
let maxChartCount = Math.max(...groups.map((group) => group.charts.length))
|
||||||
|
if (!Number.isFinite(maxChartCount)) maxChartCount = 0
|
||||||
|
const axesHeight = (axisHeight * maxChartCount)
|
||||||
|
|
||||||
|
return ({
|
||||||
|
...offset,
|
||||||
|
inlineWidth,
|
||||||
|
inlineHeight,
|
||||||
|
groupWidth,
|
||||||
|
axesHeight,
|
||||||
|
chartsTop: offset.top + axesHeight,
|
||||||
|
chartsHeight: inlineHeight - axesHeight,
|
||||||
|
groupLeft: (i: number) => (groupWidth + space) * i,
|
||||||
|
axisTop: (i: number, count: number) => axisHeight * (maxChartCount - count + i + 1)
|
||||||
|
})
|
||||||
|
}, [groups, height, offset])
|
||||||
|
|
||||||
|
const yAxis = useMemo(() => {
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
const yAxis = d3.scaleTime()
|
||||||
|
.domain([yDomain?.min ?? 0, yDomain?.max ?? 0])
|
||||||
|
.range([0, sizes.chartsHeight])
|
||||||
|
|
||||||
|
return yAxis
|
||||||
|
}, [groups, data, yDomain, sizes.chartsHeight])
|
||||||
|
|
||||||
|
const createAxesGroup = useCallback((i: number): ChartGroup<DataType> => Object.assign(
|
||||||
|
() => chartArea().select('.' + getGroupClass(i)) as d3.Selection<SVGGElement, any, any, any>,
|
||||||
|
{
|
||||||
|
key: i,
|
||||||
|
charts: [],
|
||||||
|
}
|
||||||
|
), [chartArea, axesArea])
|
||||||
|
|
||||||
|
const chartDomains: Record<string, {
|
||||||
|
scale: d3.ScaleLinear<number, number>,
|
||||||
|
domain: Required<MinMax>,
|
||||||
|
}>[] = useMemo(() => {
|
||||||
|
return groups.map((group) => {
|
||||||
|
const out = group.charts.map((chart) => {
|
||||||
|
const mm = { ...chart.xDomain }
|
||||||
|
let domain: Required<MinMax> = { min: 0, max: 100 }
|
||||||
|
if (mm.min && mm.max) {
|
||||||
|
domain = mm as Required<MinMax>
|
||||||
|
} else if (data) {
|
||||||
|
const [min, max] = d3.extent(data, chart.x)
|
||||||
|
domain = calculateDomain({ min, max, ...mm }, 100)
|
||||||
|
}
|
||||||
|
return [chart.key, {
|
||||||
|
scale: d3.scaleLinear().domain([domain.min, domain.max]),
|
||||||
|
domain,
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
return Object.fromEntries(out)
|
||||||
|
})
|
||||||
|
}, [groups, data])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDev()) {
|
||||||
|
datasetGroups.forEach((sets, i) => {
|
||||||
|
sets.forEach((set, j) => {
|
||||||
|
for (let k = j + 1; k < sets.length; k++) {
|
||||||
|
if (set.key === sets[k].key)
|
||||||
|
console.warn(`Ключ датасета "${set.key}" неуникален (группа ${i}, индексы ${j} и ${k})!`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setGroups((oldGroups) => {
|
||||||
|
const groups: ChartGroup<DataType>[] = []
|
||||||
|
|
||||||
|
if (datasetGroups.length < oldGroups.length) {
|
||||||
|
// Удаляем неактуальные группы
|
||||||
|
oldGroups.slice(datasetGroups.length).forEach((group) => group().remove())
|
||||||
|
groups.push(...oldGroups.slice(0, datasetGroups.length))
|
||||||
|
} else {
|
||||||
|
groups.push(...oldGroups)
|
||||||
|
}
|
||||||
|
|
||||||
|
datasetGroups.forEach((datasets, i) => {
|
||||||
|
let group: ChartGroup<DataType> = createAxesGroup(i)
|
||||||
|
|
||||||
|
if (group().empty())
|
||||||
|
chartArea().append('g')
|
||||||
|
.attr('class', `chart-group ${getGroupClass(i)}`)
|
||||||
|
|
||||||
|
datasets.forEach((dataset) => { // Обновляем и добавляем новые чарты
|
||||||
|
let chartIdx = group.charts.findIndex(({ key }) => key === dataset.key)
|
||||||
|
if (chartIdx < 0) {
|
||||||
|
chartIdx = group.charts.length
|
||||||
|
} else {
|
||||||
|
// Если типы графиков не сходятся удалить старые элементы
|
||||||
|
if (group.charts[chartIdx].type !== dataset.type)
|
||||||
|
group.charts[chartIdx]().selectAll('*').remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пересоздаём график
|
||||||
|
const newChart: ExtendedChartRegistry<DataType> = Object.assign(
|
||||||
|
() => group().select('.' + getChartClass(dataset.key)) as d3.Selection<SVGGElement, DataType, any, unknown>,
|
||||||
|
{
|
||||||
|
width: 1,
|
||||||
|
opacity: 1,
|
||||||
|
label: dataset.key,
|
||||||
|
color: 'gray',
|
||||||
|
animDurationMs,
|
||||||
|
...dataset,
|
||||||
|
yAxis: dataset.yAxis ?? yAxisConfig,
|
||||||
|
y: getByAccessor(dataset.yAxis.accessor ?? yAxisConfig.accessor),
|
||||||
|
x: getByAccessor(dataset.xAxis?.accessor),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (newChart.type === 'line')
|
||||||
|
newChart.optimization = false
|
||||||
|
|
||||||
|
// Если у графика нет группы создаём её
|
||||||
|
if (newChart().empty())
|
||||||
|
group().append('g')
|
||||||
|
.attr('class', `chart ${getChartClass(newChart.key)}`)
|
||||||
|
|
||||||
|
group.charts[chartIdx] = newChart
|
||||||
|
})
|
||||||
|
|
||||||
|
groups[i] = group
|
||||||
|
})
|
||||||
|
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
}, [yAxisConfig, chartArea, datasetGroups, animDurationMs, createAxesGroup])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const axesGroups = d3.select(axesAreaRef)
|
||||||
|
.selectAll('.charts-group')
|
||||||
|
.data(groups)
|
||||||
|
|
||||||
|
axesGroups.exit().remove()
|
||||||
|
axesGroups.enter()
|
||||||
|
.append('g')
|
||||||
|
.attr('class', 'charts-group')
|
||||||
|
|
||||||
|
const actualAxesGroups = d3.select(axesAreaRef)
|
||||||
|
.selectAll<SVGGElement | null, ChartGroup<DataType>>('.charts-group')
|
||||||
|
.attr('class', (g) => `charts-group ${getGroupClass(g.key)}`)
|
||||||
|
.attr('transform', (g) => `translate(${sizes.groupLeft(g.key)}, 0)`)
|
||||||
|
|
||||||
|
actualAxesGroups.each(function(group, i) {
|
||||||
|
const groupAxes = d3.select(this)
|
||||||
|
const chartsData = group.charts.filter((chart) => !chart.hideXAxis)
|
||||||
|
const charts = groupAxes.selectChildren().data(chartsData)
|
||||||
|
|
||||||
|
charts.exit().remove()
|
||||||
|
charts.enter().append('g')
|
||||||
|
.attr('class', (d) => `chart ${getChartClass(d.key)}`)
|
||||||
|
.attr('transform', (_, j) => `translate(0, ${sizes.axisTop(j, chartsData.length)})`)
|
||||||
|
|
||||||
|
const actualCharts = groupAxes.selectChildren<SVGGElement | null, ExtendedChartRegistry<DataType>>()
|
||||||
|
.style('color', (d) => d.color ?? null)
|
||||||
|
|
||||||
|
actualCharts.each(function (chart, j) {
|
||||||
|
let axis = d3.axisTop(chartDomains[i][chart.key].scale.range([0, sizes.groupWidth]))
|
||||||
|
const domain = chartDomains[i][chart.key].domain
|
||||||
|
|
||||||
|
if (j === chartsData.length - 1) {
|
||||||
|
axis = axis
|
||||||
|
.ticks(5)
|
||||||
|
.tickSize(-sizes.chartsHeight)
|
||||||
|
.tickFormat((d, i) => i === 0 || i === 5 ? String(d) : '')
|
||||||
|
.tickValues(getTicks(domain, 5))
|
||||||
|
} else {
|
||||||
|
axis = axis.ticks(1)
|
||||||
|
.tickValues(getTicks(domain, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
d3.select(this).call(axis as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (actualCharts.selectChild('text').empty())
|
||||||
|
actualCharts.append('text')
|
||||||
|
|
||||||
|
actualCharts.selectChild('text')
|
||||||
|
.attr('fill', 'currentColor')
|
||||||
|
.style('text-anchor', 'middle')
|
||||||
|
.style('dominant-baseline', 'middle')
|
||||||
|
.attr('x', sizes.groupWidth / 2)
|
||||||
|
.attr('y', -axisHeight / 2)
|
||||||
|
.text((d) => String(d.label) ?? d.key)
|
||||||
|
|
||||||
|
actualCharts.each(function (_, j) {
|
||||||
|
d3.select(this)
|
||||||
|
.selectAll('.tick line')
|
||||||
|
.attr('stroke', j === chartsData.length - 1 ? 'gray' : 'currentColor')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [groups, groupScales, sizes, space, chartDomains])
|
||||||
|
|
||||||
|
useEffect(() => { // Рисуем ось Y
|
||||||
|
if (!yAxis) return
|
||||||
|
|
||||||
|
const getX = getByAccessor(yAxisConfig.accessor)
|
||||||
|
|
||||||
|
yAxisArea().transition()
|
||||||
|
.duration(animDurationMs)
|
||||||
|
.call(d3.axisLeft(yAxis)
|
||||||
|
.tickFormat((d, i) => {
|
||||||
|
let rowData
|
||||||
|
if (data)
|
||||||
|
rowData = data.find((row) => getX(row) === d)
|
||||||
|
return yTicks.format(d, i, rowData)
|
||||||
|
})
|
||||||
|
.tickSize(yTicks.visible ? -width + offset.left + offset.right : 0)
|
||||||
|
.ticks(yTicks.count) as any // TODO: Исправить тип
|
||||||
|
)
|
||||||
|
|
||||||
|
yAxisArea().selectAll('.tick line').attr('stroke', yTicks.color)
|
||||||
|
}, [yAxisArea, yAxis, animDurationMs, width, offset, yTicks])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || !yAxis) return
|
||||||
|
|
||||||
|
groups.forEach((group, i) => {
|
||||||
|
group()
|
||||||
|
.attr('transform', `translate(${sizes.groupLeft(group.key)}, 0)`)
|
||||||
|
.attr('clip-path', `url(#chart-clip)`)
|
||||||
|
|
||||||
|
group.charts.forEach((chart) => {
|
||||||
|
chart()
|
||||||
|
.attr('color', chart.color || null)
|
||||||
|
.attr('stroke', 'currentColor')
|
||||||
|
.attr('stroke-width', chart.width ?? null)
|
||||||
|
.attr('opacity', chart.opacity ?? null)
|
||||||
|
.attr('fill', 'none')
|
||||||
|
|
||||||
|
let chartData = data
|
||||||
|
if (!chartData) return
|
||||||
|
|
||||||
|
const xAxis = chartDomains[i][chart.key].scale.range([0, sizes.groupWidth])
|
||||||
|
|
||||||
|
switch (chart.type) {
|
||||||
|
case 'needle':
|
||||||
|
chartData = renderNeedle<DataType>(xAxis, yAxis, chart, chartData, height, offset)
|
||||||
|
break
|
||||||
|
case 'line':
|
||||||
|
chartData = renderLine<DataType>(xAxis, yAxis, chart, chartData)
|
||||||
|
break
|
||||||
|
case 'point':
|
||||||
|
chartData = renderPoint<DataType>(xAxis, yAxis, chart, chartData)
|
||||||
|
break
|
||||||
|
case 'area':
|
||||||
|
chartData = renderArea<DataType>(xAxis, yAxis, chart, chartData)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chart.point)
|
||||||
|
renderPoint<DataType>(xAxis, yAxis, chart, chartData, true)
|
||||||
|
|
||||||
|
chart.afterDraw?.(chart)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [data, groups, groupScales, height, offset, sizes, chartDomains])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoaderPortal
|
||||||
|
show={loading}
|
||||||
|
style={{
|
||||||
|
width: givenWidth,
|
||||||
|
height: givenHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
{...other}
|
||||||
|
ref={rootRef}
|
||||||
|
className={`asb-d3-chart ${className}`}
|
||||||
|
>
|
||||||
|
{data ? (
|
||||||
|
<D3ContextMenu {...plugins?.menu} svg={svgRef}>
|
||||||
|
<svg ref={setSvgRef} width={'100%'} height={'100%'}>
|
||||||
|
<defs>
|
||||||
|
<clipPath id={`chart-clip`}>
|
||||||
|
{/* Сдвиг во все стороны на 1 px чтобы линии на краях было видно */}
|
||||||
|
<rect x={-1} y={-1} width={sizes.groupWidth + 2} height={sizes.chartsHeight + 2} />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g ref={setYAxisRef} className={'axis y'} transform={`translate(${offset.left}, ${sizes.chartsTop})`} />
|
||||||
|
<g ref={setAxesAreaRef} className={'chart-axes'} transform={`translate(${offset.left}, ${offset.top})`}>
|
||||||
|
<rect width={sizes.inlineWidth} height={sizes.axesHeight} fill={backgroundColor} />
|
||||||
|
</g>
|
||||||
|
<g ref={setChartAreaRef} className={'chart-area'} transform={`translate(${offset.left}, ${sizes.chartsTop})`}>
|
||||||
|
<rect width={sizes.inlineWidth} height={sizes.chartsHeight} fill={backgroundColor} />
|
||||||
|
</g>
|
||||||
|
<g stroke={'black'}>
|
||||||
|
{d3.range(1, groups.length).map((i) => {
|
||||||
|
const x = offset.left + (sizes.groupWidth + space) * i - space / 2
|
||||||
|
return <line key={`${i}`} x1={x} x2={x} y1={sizes.chartsTop} y2={offset.top + sizes.inlineHeight} />
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
<D3MouseZone width={width} height={height} offset={{ ...offset, top: sizes.chartsTop }}>
|
||||||
|
<D3HorizontalCursor
|
||||||
|
{...plugins?.cursor}
|
||||||
|
yAxis={yAxis}
|
||||||
|
groups={groups}
|
||||||
|
sizes={sizes}
|
||||||
|
data={data}
|
||||||
|
/>
|
||||||
|
</D3MouseZone>
|
||||||
|
</svg>
|
||||||
|
</D3ContextMenu>
|
||||||
|
) : (
|
||||||
|
<div className={'chart-empty'}>
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</LoaderPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const D3MonitoringCharts = memo(_D3MonitoringCharts)
|
||||||
|
|
||||||
|
export default D3MonitoringCharts
|
112
src/components/d3/D3MouseZone.tsx
Normal file
112
src/components/d3/D3MouseZone.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { createContext, memo, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
|
||||||
|
import '@styles/d3.less'
|
||||||
|
|
||||||
|
export type D3MouseState = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
visible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubscribeFunction = (name: keyof SVGElementEventMap, handler: EventListenerOrEventListenerObject) => null | (() => boolean)
|
||||||
|
|
||||||
|
export type D3MouseZoneContext = {
|
||||||
|
mouseState: D3MouseState,
|
||||||
|
zone: (() => d3.Selection<any, any, null, undefined>) | null
|
||||||
|
zoneRect: DOMRect | null
|
||||||
|
subscribe: SubscribeFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
export type D3MouseZoneProps = {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
offset: Record<string, number>
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultMouseZoneContext: D3MouseZoneContext = {
|
||||||
|
mouseState: {
|
||||||
|
x: NaN,
|
||||||
|
y: NaN,
|
||||||
|
visible: false
|
||||||
|
},
|
||||||
|
zone: null,
|
||||||
|
zoneRect: null,
|
||||||
|
subscribe: () => null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const D3MouseZoneContext = createContext<D3MouseZoneContext>(defaultMouseZoneContext)
|
||||||
|
|
||||||
|
export const useD3MouseZone = () => useContext(D3MouseZoneContext)
|
||||||
|
|
||||||
|
export const D3MouseZone = memo<D3MouseZoneProps>(({ width, height, offset, children }) => {
|
||||||
|
const rectRef = useRef<SVGRectElement>(null)
|
||||||
|
|
||||||
|
const [state, setState] = useState<D3MouseState>({ x: 0, y: 0, visible: false })
|
||||||
|
const [childContext, setChildContext] = useState<D3MouseZoneContext>(defaultMouseZoneContext)
|
||||||
|
|
||||||
|
const subscribeEvent: SubscribeFunction = useCallback((name, handler) => {
|
||||||
|
if (!rectRef.current) return null
|
||||||
|
rectRef.current.addEventListener(name, handler)
|
||||||
|
return () => {
|
||||||
|
if (!rectRef.current) return false
|
||||||
|
rectRef.current.removeEventListener(name, handler)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}, [rectRef.current])
|
||||||
|
|
||||||
|
const updateContext = useCallback(() => {
|
||||||
|
const zone = rectRef.current ? (() => d3.select(rectRef.current)) : null
|
||||||
|
|
||||||
|
setChildContext({
|
||||||
|
mouseState: state,
|
||||||
|
zone,
|
||||||
|
zoneRect: rectRef.current?.getBoundingClientRect() || null,
|
||||||
|
subscribe: subscribeEvent,
|
||||||
|
})
|
||||||
|
}, [rectRef.current, state, subscribeEvent])
|
||||||
|
|
||||||
|
const onMouse = useCallback((e: any) => {
|
||||||
|
const rect = e.target.getBoundingClientRect()
|
||||||
|
|
||||||
|
setState({
|
||||||
|
x: e.nativeEvent.clientX - rect.left,
|
||||||
|
y: e.nativeEvent.clientY - rect.top,
|
||||||
|
visible: true,
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onMouseOut = useCallback((e: any) => {
|
||||||
|
setState((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
visible: false,
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateContext()
|
||||||
|
}, [updateContext])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g className={'asb-d3-mouse-zone'} transform={`translate(${offset.left}, ${offset.top})`}>
|
||||||
|
<rect
|
||||||
|
ref={rectRef}
|
||||||
|
pointerEvents={'all'}
|
||||||
|
className={'event-zone'}
|
||||||
|
width={Math.max(width - offset.left - offset.right, 0)}
|
||||||
|
height={Math.max(height - offset.top - offset.bottom, 0)}
|
||||||
|
fill={'none'}
|
||||||
|
stroke={'none'}
|
||||||
|
onMouseMove={onMouse}
|
||||||
|
onMouseOver={onMouse}
|
||||||
|
onMouseOut={onMouseOut}
|
||||||
|
/>
|
||||||
|
<D3MouseZoneContext.Provider value={childContext}>
|
||||||
|
{children}
|
||||||
|
</D3MouseZoneContext.Provider>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default D3MouseZone
|
19
src/components/d3/functions.ts
Normal file
19
src/components/d3/functions.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { range } from 'd3'
|
||||||
|
|
||||||
|
import { MinMax } from './types'
|
||||||
|
|
||||||
|
export const getChartClass = (key: string | number) => `chart-id-${key}`
|
||||||
|
export const getGroupClass = (key: string | number) => `group-id-${key}`
|
||||||
|
|
||||||
|
export const getByAccessor = <DataType extends Record<any, any>, R>(accessor: keyof DataType | ((d: DataType) => R)): ((d: DataType) => R) => {
|
||||||
|
if (typeof accessor === 'function')
|
||||||
|
return accessor
|
||||||
|
return (d) => d[accessor]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTicks = (domain: MinMax, count: number) => {
|
||||||
|
const min = domain.min ?? 0
|
||||||
|
const max = domain.max ?? 0
|
||||||
|
const step = (max - min) / count
|
||||||
|
return [...range(min, max, step), max]
|
||||||
|
}
|
6
src/components/d3/index.ts
Normal file
6
src/components/d3/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export * from './D3Chart'
|
||||||
|
export type { D3ChartProps } from './D3Chart'
|
||||||
|
|
||||||
|
export * from './D3MonitoringCharts'
|
||||||
|
|
||||||
|
export * from './types'
|
62
src/components/d3/plugins/D3ContextMenu.tsx
Normal file
62
src/components/d3/plugins/D3ContextMenu.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { memo, ReactElement, useMemo } from 'react'
|
||||||
|
import { ItemType } from 'antd/lib/menu/hooks/useItems'
|
||||||
|
import { Dropdown, Menu } from 'antd'
|
||||||
|
|
||||||
|
import { FunctionalValue, svgToDataURL, useFunctionalValue } from '@utils'
|
||||||
|
|
||||||
|
import { BasePluginSettings } from './base'
|
||||||
|
|
||||||
|
export type D3ContextMenuSettings = {
|
||||||
|
overlay?: FunctionalValue<ReactElement | null, [SVGSVGElement | null]>
|
||||||
|
downloadFilename?: string
|
||||||
|
onUpdate?: () => void
|
||||||
|
additionalMenuItems?: ItemType[]
|
||||||
|
trigger?: ('click' | 'hover' | 'contextMenu')[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type D3ContextMenuProps = BasePluginSettings & D3ContextMenuSettings & {
|
||||||
|
children: any
|
||||||
|
svg: SVGSVGElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const D3ContextMenu = memo<D3ContextMenuProps>(({
|
||||||
|
overlay: _overlay = null,
|
||||||
|
downloadFilename = 'chart',
|
||||||
|
additionalMenuItems,
|
||||||
|
onUpdate,
|
||||||
|
trigger = ['contextMenu'],
|
||||||
|
enabled = true,
|
||||||
|
children,
|
||||||
|
svg
|
||||||
|
}) => {
|
||||||
|
const overlay = useFunctionalValue(_overlay)
|
||||||
|
|
||||||
|
const menuItems = useMemo(() => {
|
||||||
|
const menuItems: ItemType[] = []
|
||||||
|
|
||||||
|
if (onUpdate)
|
||||||
|
menuItems.push({ key: 'refresh', label: 'Обновить', onClick: onUpdate })
|
||||||
|
|
||||||
|
if (svg)
|
||||||
|
menuItems.push({ key: 'download', label: (
|
||||||
|
<a href={svgToDataURL(svg)} download={`${downloadFilename}.svg`}>Сохранить</a>
|
||||||
|
)})
|
||||||
|
|
||||||
|
if (additionalMenuItems)
|
||||||
|
menuItems.push(...additionalMenuItems)
|
||||||
|
|
||||||
|
return menuItems
|
||||||
|
}, [svg, downloadFilename, onUpdate, additionalMenuItems])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
overlay={overlay(svg) || ( <Menu items={menuItems} /> )}
|
||||||
|
disabled={!enabled}
|
||||||
|
trigger={trigger}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Dropdown>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default D3ContextMenu
|
82
src/components/d3/plugins/D3Cursor.tsx
Normal file
82
src/components/d3/plugins/D3Cursor.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { SVGProps, useEffect, useMemo, useRef } from 'react'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
|
||||||
|
import { useD3MouseZone } from '@components/d3/D3MouseZone'
|
||||||
|
import { usePartialProps } from '@utils'
|
||||||
|
|
||||||
|
import { wrapPlugin } from './base'
|
||||||
|
|
||||||
|
import '@styles/d3.less'
|
||||||
|
|
||||||
|
export type D3CursorSettings = {
|
||||||
|
lineStyle?: SVGProps<SVGGElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultLineStyle: SVGProps<SVGGElement> = {
|
||||||
|
stroke: 'gray',
|
||||||
|
strokeWidth: 1,
|
||||||
|
strokeOpacity: 1,
|
||||||
|
className: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSettings: D3CursorSettings = {
|
||||||
|
lineStyle: defaultLineStyle,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type D3CursorProps = D3CursorSettings
|
||||||
|
|
||||||
|
export const D3Cursor = wrapPlugin<D3CursorSettings>(((props) => {
|
||||||
|
const settings = usePartialProps(props, defaultSettings)
|
||||||
|
const lineStyle = usePartialProps(settings.lineStyle, defaultLineStyle)
|
||||||
|
|
||||||
|
const { mouseState, zoneRect } = useD3MouseZone()
|
||||||
|
const zoneRef = useRef(null)
|
||||||
|
|
||||||
|
const zone = useMemo(() => zoneRef.current ? (() => d3.select(zoneRef.current)) : null, [zoneRef.current])
|
||||||
|
|
||||||
|
const getXLine = useMemo(() => zone ? (() => zone().select('.tooltip-x-line')) : null, [zone])
|
||||||
|
const getYLine = useMemo(() => zone ? (() => zone().select('.tooltip-y-line')) : null, [zone])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!zone || !getXLine || !getYLine) return
|
||||||
|
const z = zone()
|
||||||
|
|
||||||
|
if (z.selectAll('line').empty()) {
|
||||||
|
z.append('line').attr('class', 'tooltip-x-line').style('pointer-events', 'none')
|
||||||
|
z.append('line').attr('class', 'tooltip-y-line').style('pointer-events', 'none')
|
||||||
|
}
|
||||||
|
|
||||||
|
getXLine()
|
||||||
|
.attr('y1', 0)
|
||||||
|
.attr('y2', zoneRect?.height ?? 0)
|
||||||
|
|
||||||
|
getYLine()
|
||||||
|
.attr('x1', 0)
|
||||||
|
.attr('x2', zoneRect?.width ?? 0)
|
||||||
|
}, [zone, getXLine, getYLine, zoneRect])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!getXLine || !getYLine || !mouseState) return
|
||||||
|
|
||||||
|
getXLine()
|
||||||
|
.attr('x1', mouseState.x)
|
||||||
|
.attr('x2', mouseState.x)
|
||||||
|
.attr('opacity', mouseState.visible ? '1' : '0')
|
||||||
|
|
||||||
|
getYLine()
|
||||||
|
.attr('y1', mouseState.y)
|
||||||
|
.attr('y2', mouseState.y)
|
||||||
|
.attr('opacity', mouseState.visible ? '1' : '0')
|
||||||
|
}, [mouseState, getXLine, getYLine])
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
{...lineStyle}
|
||||||
|
ref={zoneRef}
|
||||||
|
className={`cursor-zone ${lineStyle.className}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}), true)
|
||||||
|
|
||||||
|
export default D3Cursor
|
212
src/components/d3/plugins/D3HorizontalCursor.tsx
Normal file
212
src/components/d3/plugins/D3HorizontalCursor.tsx
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import { AreaChartOutlined, BarChartOutlined, BorderOuterOutlined, DotChartOutlined, LineChartOutlined } from '@ant-design/icons'
|
||||||
|
import { CSSProperties, ReactNode, SVGProps, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
|
||||||
|
import { useD3MouseZone } from '@components/d3/D3MouseZone'
|
||||||
|
import { ChartGroup, ChartSizes } from '@components/d3/D3MonitoringCharts'
|
||||||
|
import { isDev, usePartialProps } from '@utils'
|
||||||
|
|
||||||
|
import { wrapPlugin } from './base'
|
||||||
|
import { D3TooltipPosition } from './D3Tooltip'
|
||||||
|
|
||||||
|
import '@styles/d3.less'
|
||||||
|
|
||||||
|
type D3GroupRenderFunction<DataType> = (group: ChartGroup<DataType>, data: DataType[]) => ReactNode
|
||||||
|
|
||||||
|
export type D3HorizontalCursorSettings<DataType> = {
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
render?: D3GroupRenderFunction<DataType>
|
||||||
|
position?: D3TooltipPosition
|
||||||
|
className?: string
|
||||||
|
style?: CSSProperties
|
||||||
|
limit?: number
|
||||||
|
lineStyle?: SVGProps<SVGLineElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type D3HorizontalCursorProps<DataType> = D3HorizontalCursorSettings<DataType> & {
|
||||||
|
groups: ChartGroup<DataType>[]
|
||||||
|
data: DataType[]
|
||||||
|
sizes: ChartSizes
|
||||||
|
yAxis?: d3.ScaleTime<number, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultLineStyle: SVGProps<SVGLineElement> = {
|
||||||
|
stroke: 'black',
|
||||||
|
}
|
||||||
|
|
||||||
|
const offsetY = 5
|
||||||
|
|
||||||
|
const makeDefaultRender = <DataType,>(): D3GroupRenderFunction<DataType> => (group, data) => (
|
||||||
|
<>
|
||||||
|
{data.length > 0 ? group.charts.map((chart) => {
|
||||||
|
let Icon
|
||||||
|
switch (chart.type) {
|
||||||
|
case 'needle': Icon = BarChartOutlined; break
|
||||||
|
case 'line': Icon = LineChartOutlined; break
|
||||||
|
case 'point': Icon = DotChartOutlined; break
|
||||||
|
case 'area': Icon = AreaChartOutlined; break
|
||||||
|
case 'rect_area': Icon = BorderOuterOutlined; break
|
||||||
|
}
|
||||||
|
|
||||||
|
const xFormat = (d: number | Date) => chart.xAxis.format?.(d) ?? `${(+d).toFixed(2)} ${chart.xAxis.unit ?? ''}`
|
||||||
|
const yFormat = (d: number) => chart.yAxis.format?.(d) ?? `${d?.toFixed(2)} ${chart.yAxis.unit ?? ''}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'tooltip-group'} key={chart.key}>
|
||||||
|
<div className={'group-label'}>
|
||||||
|
<Icon style={{ color: chart.color }} />
|
||||||
|
<span>{chart.shortLabel || chart.label}:</span>
|
||||||
|
</div>
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<span key={`${i}`}>
|
||||||
|
{xFormat(chart.x(d))} :: {yFormat(chart.y(d))}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}) : (
|
||||||
|
<span>Данных нет</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const _D3HorizontalCursor = <DataType,>({
|
||||||
|
width = 220,
|
||||||
|
height = 200,
|
||||||
|
render = makeDefaultRender<DataType>(),
|
||||||
|
position: _position = 'bottom',
|
||||||
|
className = '',
|
||||||
|
style: _style = {},
|
||||||
|
limit = 2,
|
||||||
|
lineStyle: _lineStyle,
|
||||||
|
|
||||||
|
data,
|
||||||
|
groups,
|
||||||
|
sizes,
|
||||||
|
yAxis,
|
||||||
|
}: D3HorizontalCursorProps<DataType>) => {
|
||||||
|
const zoneRef = useRef(null)
|
||||||
|
|
||||||
|
const { mouseState, zoneRect, subscribe } = useD3MouseZone()
|
||||||
|
const [position, setPosition] = useState<D3TooltipPosition>(_position ?? 'bottom')
|
||||||
|
const [tooltipBodies, setTooltipBodies] = useState<ReactNode[]>([])
|
||||||
|
const [tooltipY, setTooltipY] = useState(0)
|
||||||
|
const [fixed, setFixed] = useState(false)
|
||||||
|
|
||||||
|
const lineStyle = usePartialProps(_lineStyle, defaultLineStyle)
|
||||||
|
|
||||||
|
const zone = useMemo(() => zoneRef.current ? (() => d3.select(zoneRef.current)) : null, [zoneRef.current])
|
||||||
|
const getXLine = useMemo(() => zone ? (() => zone().select('.tooltip-x-line')) : null, [zone])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMiddleClick = (e: Event) => {
|
||||||
|
if ((e as MouseEvent).button === 1)
|
||||||
|
setFixed((prev) => !prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = subscribe('auxclick', onMiddleClick)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (unsubscribe)
|
||||||
|
if (!unsubscribe() && isDev())
|
||||||
|
console.warn('Не удалось отвязать эвент')
|
||||||
|
}
|
||||||
|
}, [subscribe])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!zone || !getXLine) return
|
||||||
|
const z = zone()
|
||||||
|
|
||||||
|
if (z.selectAll('line').empty()) {
|
||||||
|
z.append('line').attr('class', 'tooltip-x-line').style('pointer-events', 'none')
|
||||||
|
}
|
||||||
|
|
||||||
|
getXLine()
|
||||||
|
.attr('x1', 0)
|
||||||
|
.attr('x2', zoneRect?.width ?? 0)
|
||||||
|
}, [zone, getXLine, zoneRect])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!getXLine) return
|
||||||
|
|
||||||
|
const line = getXLine()
|
||||||
|
Object.entries(lineStyle).map(([key, value]) => line.attr(key, value))
|
||||||
|
}, [getXLine, lineStyle])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!getXLine || !mouseState || fixed) return
|
||||||
|
|
||||||
|
getXLine()
|
||||||
|
.attr('y1', mouseState.y)
|
||||||
|
.attr('y2', mouseState.y)
|
||||||
|
.attr('opacity', mouseState.visible ? 1 : 0)
|
||||||
|
}, [getXLine, mouseState, fixed])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mouseState.visible || fixed) return
|
||||||
|
|
||||||
|
let top = mouseState.y + offsetY
|
||||||
|
if (top + height >= sizes.chartsHeight) {
|
||||||
|
setPosition('bottom')
|
||||||
|
top = mouseState.y - offsetY - height
|
||||||
|
} else {
|
||||||
|
setPosition('top')
|
||||||
|
}
|
||||||
|
|
||||||
|
setTooltipY(top)
|
||||||
|
}, [sizes.chartsHeight, height, mouseState, fixed])
|
||||||
|
|
||||||
|
const [lineY, setLineY] = useState<number>(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fixed || !mouseState.visible) return
|
||||||
|
setLineY(mouseState.y)
|
||||||
|
}, [mouseState, fixed])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!yAxis || !data || (!fixed && !mouseState.visible)) return
|
||||||
|
|
||||||
|
const limitInS = limit * 1000
|
||||||
|
const currentDate = +yAxis.invert(lineY)
|
||||||
|
|
||||||
|
const chartData = data.filter((row: any) => {
|
||||||
|
const date = +new Date(row.date)
|
||||||
|
return (date >= currentDate - limitInS) && (date <= currentDate + limitInS)
|
||||||
|
})
|
||||||
|
|
||||||
|
const bodies = groups.map((group) => render(group, chartData))
|
||||||
|
|
||||||
|
setTooltipBodies(bodies)
|
||||||
|
}, [groups, data, yAxis, lineY, fixed, mouseState.visible])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
ref={zoneRef}
|
||||||
|
className={`cursor-zone ${className}`}
|
||||||
|
>
|
||||||
|
{groups.map((_, i) => (
|
||||||
|
<foreignObject
|
||||||
|
key={`${i}`}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
x={sizes.groupLeft(i) + (sizes.groupWidth - width) / 2}
|
||||||
|
y={tooltipY}
|
||||||
|
opacity={fixed || mouseState.visible ? 1 : 0}
|
||||||
|
pointerEvents={fixed ? 'all' : 'none'}
|
||||||
|
style={{ transition: 'opacity .1s ease-out', userSelect: fixed ? 'auto' : 'none' }}
|
||||||
|
>
|
||||||
|
<div className={`tooltip ${position} ${className}`}>
|
||||||
|
<div className={'tooltip-content'}>
|
||||||
|
{tooltipBodies[i]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const D3HorizontalCursor = wrapPlugin(_D3HorizontalCursor, true) as typeof _D3HorizontalCursor
|
||||||
|
|
||||||
|
export default D3HorizontalCursor
|
142
src/components/d3/plugins/D3Legend.tsx
Normal file
142
src/components/d3/plugins/D3Legend.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
|
import { Property } from 'csstype'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
|
||||||
|
import { ChartRegistry } from '@components/d3/types'
|
||||||
|
import { useD3MouseZone } from '@components/d3/D3MouseZone'
|
||||||
|
import { usePartialProps } from '@utils'
|
||||||
|
|
||||||
|
import { wrapPlugin } from './base'
|
||||||
|
|
||||||
|
export type LegendPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center'
|
||||||
|
|
||||||
|
export type D3LegendSettings = {
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
offset?: {
|
||||||
|
x?: number
|
||||||
|
y?: number
|
||||||
|
}
|
||||||
|
position?: LegendPosition
|
||||||
|
color?: Property.Color
|
||||||
|
backgroundColor?: Property.Color
|
||||||
|
type?: 'horizontal' | 'vertical'
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOffset = { x: 10, y: 10 }
|
||||||
|
|
||||||
|
export type D3LegendProps<DataType> = D3LegendSettings & {
|
||||||
|
charts: ChartRegistry<DataType>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const _D3Legend = <DataType, >({
|
||||||
|
charts,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
offset: _offset,
|
||||||
|
position = 'top-center',
|
||||||
|
backgroundColor = 'transparent',
|
||||||
|
color = 'black',
|
||||||
|
type = 'vertical',
|
||||||
|
}: D3LegendProps<DataType>) => {
|
||||||
|
const legendRef = useRef<SVGGElement>(null)
|
||||||
|
const offset = usePartialProps(_offset, defaultOffset)
|
||||||
|
|
||||||
|
const { zoneRect } = useD3MouseZone()
|
||||||
|
|
||||||
|
const maxLength = useMemo(() => {
|
||||||
|
let max = 0
|
||||||
|
charts.forEach((chart) => {
|
||||||
|
const key = String(chart.label ?? chart.key).length
|
||||||
|
if (key > max) max = key
|
||||||
|
})
|
||||||
|
return max
|
||||||
|
}, [charts])
|
||||||
|
|
||||||
|
const [x, y] = useMemo(() => {
|
||||||
|
if (!legendRef.current || !zoneRect) return [0, 0]
|
||||||
|
|
||||||
|
let x = offset.x
|
||||||
|
let y = offset.y
|
||||||
|
|
||||||
|
const legendRect = legendRef.current.getBoundingClientRect()
|
||||||
|
|
||||||
|
if (position.includes('bottom'))
|
||||||
|
y = zoneRect.height - offset.y - legendRect.height
|
||||||
|
|
||||||
|
if (position.includes('center'))
|
||||||
|
x = (zoneRect.width - legendRect.width) / 2
|
||||||
|
if (position.includes('right'))
|
||||||
|
x = zoneRect.width - offset.x - legendRect.width
|
||||||
|
|
||||||
|
return [x, y]
|
||||||
|
}, [zoneRect, legendRef.current, position, offset])
|
||||||
|
|
||||||
|
const defaultSizes = useMemo(() => {
|
||||||
|
const out = {
|
||||||
|
width: 10 + maxLength * 10 * (type === 'horizontal' ? charts.length : 1),
|
||||||
|
height: 20 * (type === 'vertical' ? charts.length + 0.5 : 1)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [maxLength])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!legendRef.current) return
|
||||||
|
|
||||||
|
const currentElms = d3.select(legendRef.current)
|
||||||
|
.selectAll('.legend')
|
||||||
|
.data(charts)
|
||||||
|
|
||||||
|
currentElms.exit().remove() /// Удаляем лишние
|
||||||
|
|
||||||
|
/// Добавляем новые
|
||||||
|
const newElms = currentElms.enter()
|
||||||
|
.append('g')
|
||||||
|
.attr('class', 'legend')
|
||||||
|
|
||||||
|
newElms.append('rect')
|
||||||
|
.attr('x', 5)
|
||||||
|
.attr('y', 4)
|
||||||
|
.attr('width', 10)
|
||||||
|
.attr('height', 10)
|
||||||
|
|
||||||
|
newElms.append('text')
|
||||||
|
.attr('x', 20)
|
||||||
|
.attr('y', 9)
|
||||||
|
.attr('dy', '.35em')
|
||||||
|
.style('text-anchor', 'start')
|
||||||
|
.attr('fill', color)
|
||||||
|
|
||||||
|
const allElms = d3.select(legendRef.current)
|
||||||
|
.selectAll('.legend')
|
||||||
|
|
||||||
|
/// Обновляем значения
|
||||||
|
if (type === 'vertical') {
|
||||||
|
allElms.attr('transform', (d, i) => `translate(5, ${5 + i * 20})`)
|
||||||
|
} else {
|
||||||
|
allElms.attr('transform', (d, i) => `translate(${5 + maxLength * 10 * i}, 0)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
allElms.selectAll('rect').style('fill', (d: any) => d.color)
|
||||||
|
allElms.selectAll('text').text((d: any) => d.label ?? d.key)
|
||||||
|
}, [legendRef.current, charts, color, maxLength])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
ref={legendRef}
|
||||||
|
pointerEvents={'none'}
|
||||||
|
className={'legendTable'}
|
||||||
|
transform={`translate(${x}, ${y})`}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
width={width ?? defaultSizes.width}
|
||||||
|
height={height ?? defaultSizes.height}
|
||||||
|
fill={backgroundColor}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const D3Legend = wrapPlugin(_D3Legend) as typeof _D3Legend
|
||||||
|
|
||||||
|
export default D3Legend
|
174
src/components/d3/plugins/D3Tooltip.tsx
Normal file
174
src/components/d3/plugins/D3Tooltip.tsx
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import { AreaChartOutlined, BarChartOutlined, BorderOuterOutlined, DotChartOutlined, LineChartOutlined } from '@ant-design/icons'
|
||||||
|
import { CSSProperties, ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
|
||||||
|
import { isDev } from '@utils'
|
||||||
|
|
||||||
|
import { D3MouseState, useD3MouseZone } from '../D3MouseZone'
|
||||||
|
import { ChartRegistry } from '../types'
|
||||||
|
import { getTouchedElements, wrapPlugin } from './base'
|
||||||
|
|
||||||
|
import '@styles/d3.less'
|
||||||
|
|
||||||
|
export type D3TooltipPosition = 'bottom' | 'top' | 'left' | 'right' | 'none'
|
||||||
|
|
||||||
|
export type D3RenderData<DataType> = {
|
||||||
|
chart: ChartRegistry<DataType>
|
||||||
|
data: DataType[]
|
||||||
|
selection?: d3.Selection<any, DataType, any, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type D3RenderFunction<DataType> = (data: D3RenderData<DataType>[], mouseState: D3MouseState) => ReactNode
|
||||||
|
|
||||||
|
export type D3TooltipSettings<DataType> = {
|
||||||
|
render?: D3RenderFunction<DataType>
|
||||||
|
width?: number | string
|
||||||
|
height?: number | string
|
||||||
|
style?: CSSProperties
|
||||||
|
position?: D3TooltipPosition
|
||||||
|
className?: string
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeDefaultRender = <DataType,>(): D3RenderFunction<DataType> => (data, mouseState) => (
|
||||||
|
<>
|
||||||
|
{data.length > 0 ? data.map(({ chart, data }) => {
|
||||||
|
let Icon
|
||||||
|
switch (chart.type) {
|
||||||
|
case 'needle': Icon = BarChartOutlined; break
|
||||||
|
case 'line': Icon = LineChartOutlined; break
|
||||||
|
case 'point': Icon = DotChartOutlined; break
|
||||||
|
case 'area': Icon = AreaChartOutlined; break
|
||||||
|
case 'rect_area': Icon = BorderOuterOutlined; break
|
||||||
|
// case 'dot': Icon = DotChartOutLined; break
|
||||||
|
}
|
||||||
|
|
||||||
|
const xFormat = (d: number | Date) => chart.xAxis.format?.(d) ?? `${(+d).toFixed(2)} ${chart.xAxis.unit ?? ''}`
|
||||||
|
const yFormat = (d: number) => chart.yAxis.format?.(d) ?? `${d?.toFixed(2)} ${chart.yAxis.unit ?? ''}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'tooltip-group'} key={chart.key}>
|
||||||
|
<div className={'group-label'}>
|
||||||
|
<Icon style={{ color: chart.color }} />
|
||||||
|
<span>{chart.shortLabel || chart.label}:</span>
|
||||||
|
</div>
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<span key={`${i}`}>
|
||||||
|
{xFormat(chart.x(d))} :: {yFormat(chart.y(d))}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}) : (
|
||||||
|
<span>Данных нет</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
export type D3TooltipProps<DataType> = Partial<D3TooltipSettings<DataType>> & {
|
||||||
|
charts: ChartRegistry<DataType>[],
|
||||||
|
}
|
||||||
|
|
||||||
|
function _D3Tooltip<DataType extends Record<string, unknown>>({
|
||||||
|
width = 200,
|
||||||
|
height = 120,
|
||||||
|
render = makeDefaultRender<DataType>(),
|
||||||
|
charts,
|
||||||
|
position: _position = 'bottom',
|
||||||
|
className = '',
|
||||||
|
style: _style = {},
|
||||||
|
limit = 2
|
||||||
|
}: D3TooltipProps<DataType>) {
|
||||||
|
const { mouseState, zoneRect, subscribe } = useD3MouseZone()
|
||||||
|
const [tooltipBody, setTooltipBody] = useState<any>()
|
||||||
|
const [style, setStyle] = useState<CSSProperties>(_style)
|
||||||
|
const [position, setPosition] = useState<D3TooltipPosition>(_position ?? 'bottom')
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const [fixed, setFixed] = useState(false)
|
||||||
|
|
||||||
|
const tooltipRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMiddleClick = (e: Event) => {
|
||||||
|
if ((e as MouseEvent).button === 1 && visible)
|
||||||
|
setFixed((prev) => !prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = subscribe('auxclick', onMiddleClick)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (unsubscribe)
|
||||||
|
if (!unsubscribe() && isDev())
|
||||||
|
console.warn('Не удалось отвязать эвент')
|
||||||
|
}
|
||||||
|
}, [visible])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tooltipRef.current || !zoneRect || fixed) return
|
||||||
|
const rect = tooltipRef.current.getBoundingClientRect()
|
||||||
|
|
||||||
|
if (!mouseState.visible) return
|
||||||
|
|
||||||
|
const offsetX = -rect.width / 2 // По центру
|
||||||
|
const offsetY = 15 // Чуть выше курсора
|
||||||
|
|
||||||
|
const left = Math.max(10, Math.min(zoneRect.width - rect.width - 10, mouseState.x + offsetX))
|
||||||
|
let top = mouseState.y - offsetY - rect.height
|
||||||
|
setPosition(top <= 0 ? 'top' : 'bottom')
|
||||||
|
if (top <= 0) top = mouseState.y + offsetY
|
||||||
|
|
||||||
|
setStyle((prev) => ({
|
||||||
|
...prev,
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
}))
|
||||||
|
}, [tooltipRef.current, mouseState, zoneRect, fixed])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fixed) return
|
||||||
|
if (!mouseState.visible)
|
||||||
|
return setVisible(false)
|
||||||
|
|
||||||
|
const data: D3RenderData<DataType>[] = []
|
||||||
|
charts.forEach((chart) => {
|
||||||
|
const touched = getTouchedElements(chart, mouseState.x, mouseState.y, limit)
|
||||||
|
|
||||||
|
if (touched.empty()) return
|
||||||
|
|
||||||
|
data.push({
|
||||||
|
chart,
|
||||||
|
data: touched.data(),
|
||||||
|
selection: touched,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
setVisible(data.length > 0)
|
||||||
|
if (data.length > 0)
|
||||||
|
setTooltipBody(render(data, mouseState))
|
||||||
|
}, [charts, mouseState, fixed, limit])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<foreignObject
|
||||||
|
x={style.left}
|
||||||
|
y={style.top}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
opacity={visible ? 1 : 0}
|
||||||
|
pointerEvents={fixed ? 'all' : 'none'}
|
||||||
|
style={{ transition: 'opacity .1s ease-out', userSelect: fixed ? 'auto' : 'none' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
className={`tooltip ${position} ${className}`}
|
||||||
|
>
|
||||||
|
<div className={'tooltip-content'}>
|
||||||
|
{tooltipBody}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const D3Tooltip = wrapPlugin(_D3Tooltip) as typeof _D3Tooltip
|
||||||
|
|
||||||
|
export default D3Tooltip
|
125
src/components/d3/plugins/base.tsx
Normal file
125
src/components/d3/plugins/base.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { FC } from 'react'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
|
||||||
|
import { getDistance, TouchType } from '@utils'
|
||||||
|
|
||||||
|
import { ChartRegistry } from '../types'
|
||||||
|
|
||||||
|
export type BasePluginSettings = {
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wrapPlugin = <TProps,>(
|
||||||
|
Component: FC<TProps>,
|
||||||
|
defaultEnabled?: boolean
|
||||||
|
): FC<TProps & BasePluginSettings> => {
|
||||||
|
const wrappedComponent = ({ enabled, ...props }: TProps & BasePluginSettings) => {
|
||||||
|
if (!(enabled ?? defaultEnabled)) return <></>
|
||||||
|
|
||||||
|
return <Component {...(props as TProps)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrappedComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeIsCircleTouched = (x: number, y: number, limit: number, type: TouchType = 'all') => function (this: d3.BaseType, d: any, i: number) {
|
||||||
|
const elm = d3.select(this)
|
||||||
|
const cx = +elm.attr('cx')
|
||||||
|
const cy = +elm.attr('cy')
|
||||||
|
const r = +elm.attr('r')
|
||||||
|
|
||||||
|
if (Number.isNaN(cx + cy + r)) return false
|
||||||
|
|
||||||
|
const distance = getDistance(x, y, cx, cy, type)
|
||||||
|
|
||||||
|
return (distance - r) <= limit
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeIsLineTouched = (x: number, y: number, limit: number, type: TouchType = 'all') => function (this: d3.BaseType, d: any, i: number) {
|
||||||
|
const elm = d3.select(this)
|
||||||
|
const dx = +elm.attr('x1')
|
||||||
|
const y1 = +elm.attr('y1')
|
||||||
|
const y2 = +elm.attr('y2')
|
||||||
|
|
||||||
|
if (Number.isNaN(dx + y1 + y2)) return false
|
||||||
|
|
||||||
|
const ymin = Math.min(y1, y2)
|
||||||
|
const ymax = Math.max(y1, y2)
|
||||||
|
const pd = getDistance(x, y, dx, ymin) // Расстояние до верхней точки
|
||||||
|
|
||||||
|
let distance
|
||||||
|
switch (type) {
|
||||||
|
case 'all':
|
||||||
|
distance = (ymin <= y && y <= ymax) ? Math.abs(x - dx) : pd
|
||||||
|
break
|
||||||
|
case 'x':
|
||||||
|
distance = Math.abs(x - dx)
|
||||||
|
break
|
||||||
|
case 'y':
|
||||||
|
distance = (ymin <= y && y <= ymax) ? 0 : Math.min(Math.abs(y - ymin), Math.abs(y - ymax))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return distance <= limit
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeIsRectTouched = (x: number, y: number, limit: number, type: TouchType = 'all') => function (this: d3.BaseType, d: any, i: number) {
|
||||||
|
const elm = d3.select(this)
|
||||||
|
const dx = +elm.attr('x')
|
||||||
|
const dy = +elm.attr('y')
|
||||||
|
const width = +elm.attr('width')
|
||||||
|
const height = +elm.attr('height')
|
||||||
|
|
||||||
|
if (Number.isNaN(x + y + width + height)) return false
|
||||||
|
|
||||||
|
const isOnHorizont = (dx - limit <= x) && (x <= dx + limit + width)
|
||||||
|
const isOnVertical = (dy - limit <= y) && (y <= dy + limit + height)
|
||||||
|
|
||||||
|
if (isOnHorizont && isOnVertical)
|
||||||
|
return true
|
||||||
|
|
||||||
|
switch(type) {
|
||||||
|
case 'all': {
|
||||||
|
const dV = Math.min(getDistance(x, y, x, dy), getDistance(x, y, x, dy + height))
|
||||||
|
const dH = Math.min(getDistance(x, y, dx, y), getDistance(x, y, dx + width, y))
|
||||||
|
return (isOnHorizont && dV <= limit) || (isOnVertical && dH <= limit)
|
||||||
|
}
|
||||||
|
case 'x': return isOnHorizont
|
||||||
|
case 'y': return isOnVertical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTouchedElements = <DataType,>(
|
||||||
|
chart: ChartRegistry<DataType>,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
limit: number = 0,
|
||||||
|
type: TouchType = 'all'
|
||||||
|
): d3.Selection<any, DataType, any, any> => {
|
||||||
|
let nodes: d3.Selection<any, any, any, any>
|
||||||
|
switch (chart.type) {
|
||||||
|
case 'area':
|
||||||
|
case 'line':
|
||||||
|
case 'point': {
|
||||||
|
const tag = chart.point?.shape ?? 'circle'
|
||||||
|
nodes = chart().selectAll(tag)
|
||||||
|
switch (tag) {
|
||||||
|
case 'circle':
|
||||||
|
nodes = nodes.filter(makeIsCircleTouched(x, y, chart.tooltip?.limit ?? limit, type))
|
||||||
|
break
|
||||||
|
case 'line':
|
||||||
|
nodes = nodes.filter(makeIsLineTouched(x, y, chart.tooltip?.limit ?? limit, type))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'needle':
|
||||||
|
nodes = chart().selectAll('line')
|
||||||
|
nodes = nodes.filter(makeIsLineTouched(x, y, chart.tooltip?.limit ?? limit, type))
|
||||||
|
break
|
||||||
|
case 'rect_area':
|
||||||
|
nodes = chart().selectAll('rect')
|
||||||
|
nodes = nodes.filter(makeIsRectTouched(x, y, chart.tooltip?.limit ?? limit, type))
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
}
|
6
src/components/d3/plugins/index.ts
Normal file
6
src/components/d3/plugins/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export * from './base'
|
||||||
|
export * from './D3ContextMenu'
|
||||||
|
export * from './D3Cursor'
|
||||||
|
export * from './D3HorizontalCursor'
|
||||||
|
export * from './D3Legend'
|
||||||
|
export * from './D3Tooltip'
|
58
src/components/d3/renders/area.ts
Normal file
58
src/components/d3/renders/area.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import * as d3 from 'd3'
|
||||||
|
|
||||||
|
import { ChartRegistry } from '@components/d3/types'
|
||||||
|
import { makePointsOptimizator } from '@utils'
|
||||||
|
|
||||||
|
export const renderArea = <DataType extends Record<string, unknown>>(
|
||||||
|
xAxis: (value: any) => number,
|
||||||
|
yAxis: (value: any) => number,
|
||||||
|
chart: ChartRegistry<DataType>,
|
||||||
|
data: DataType[]
|
||||||
|
): DataType[] => {
|
||||||
|
if (chart.type !== 'area') return data
|
||||||
|
|
||||||
|
let area = d3.area()
|
||||||
|
|
||||||
|
if ('y0' in chart) {
|
||||||
|
area = area.y0(yAxis(chart.y0 ?? 0))
|
||||||
|
.y1(d => yAxis(chart.y(d)))
|
||||||
|
} else {
|
||||||
|
area = area.y(d => yAxis(chart.y(d)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('x0' in chart) {
|
||||||
|
area = area.x0(xAxis(chart.x0 ?? 0))
|
||||||
|
.x1(d => xAxis(chart.x(d)))
|
||||||
|
} else {
|
||||||
|
area = area.x(d => xAxis(chart.x(d)))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (chart.nullValues || 'skip') {
|
||||||
|
case 'gap':
|
||||||
|
area = area.defined(d => (chart.y(d) ?? null) !== null && !Number.isNaN(chart.y(d)))
|
||||||
|
break
|
||||||
|
case 'skip':
|
||||||
|
data = data.filter(chart.y)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chart.optimization) {
|
||||||
|
const optimize = makePointsOptimizator<DataType>((a, b) => chart.y(a) === chart.y(b))
|
||||||
|
data = optimize(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (chart().selectAll('path').empty())
|
||||||
|
chart().append('path')
|
||||||
|
|
||||||
|
chart().selectAll('path')
|
||||||
|
.transition()
|
||||||
|
.duration(chart.animDurationMs || 0)
|
||||||
|
.attr('d', area(data as any))
|
||||||
|
.attr('stroke-dasharray', chart.dash ? String(chart.dash) : null)
|
||||||
|
.attr('fill', chart.areaColor ?? null)
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
5
src/components/d3/renders/index.ts
Normal file
5
src/components/d3/renders/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from './area'
|
||||||
|
export * from './line'
|
||||||
|
export * from './needle'
|
||||||
|
export * from './points'
|
||||||
|
export * from './rect_area'
|
45
src/components/d3/renders/line.ts
Normal file
45
src/components/d3/renders/line.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import * as d3 from 'd3'
|
||||||
|
|
||||||
|
import { ChartRegistry } from '@components/d3/types'
|
||||||
|
import { makePointsOptimizator } from '@utils'
|
||||||
|
|
||||||
|
export const renderLine = <DataType extends Record<string, unknown>>(
|
||||||
|
xAxis: (value: any) => number,
|
||||||
|
yAxis: (value: any) => number,
|
||||||
|
chart: ChartRegistry<DataType>,
|
||||||
|
data: DataType[]
|
||||||
|
): DataType[] => {
|
||||||
|
if (chart.type !== 'line') return data
|
||||||
|
|
||||||
|
let line = d3.line()
|
||||||
|
.x(d => xAxis(chart.x(d)))
|
||||||
|
.y(d => yAxis(chart.y(d)))
|
||||||
|
|
||||||
|
switch (chart.nullValues || 'skip') {
|
||||||
|
case 'gap':
|
||||||
|
line = line.defined(d => (chart.y(d) ?? null) !== null && !Number.isNaN(chart.y(d)))
|
||||||
|
break
|
||||||
|
case 'skip':
|
||||||
|
data = data.filter(chart.y)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chart.optimization) {
|
||||||
|
const optimize = makePointsOptimizator<DataType>((a, b) => chart.y(a) === chart.y(b))
|
||||||
|
data = optimize(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (chart().selectAll('path').empty())
|
||||||
|
chart().append('path')
|
||||||
|
|
||||||
|
chart().selectAll('path')
|
||||||
|
.transition()
|
||||||
|
.duration(chart.animDurationMs ?? 0)
|
||||||
|
.attr('d', line(data as any))
|
||||||
|
.attr('stroke-dasharray', String(chart.dash ?? ''))
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
33
src/components/d3/renders/needle.ts
Normal file
33
src/components/d3/renders/needle.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { ChartOffset, ChartRegistry } from '@components/d3/types'
|
||||||
|
|
||||||
|
export const renderNeedle = <DataType extends Record<string, unknown>>(
|
||||||
|
xAxis: (value: d3.NumberValue) => number,
|
||||||
|
yAxis: (value: d3.NumberValue) => number,
|
||||||
|
chart: ChartRegistry<DataType>,
|
||||||
|
data: DataType[],
|
||||||
|
height: number,
|
||||||
|
offset: ChartOffset
|
||||||
|
): DataType[] => {
|
||||||
|
if (chart.type !== 'needle') return data
|
||||||
|
|
||||||
|
data = data.filter(chart.y)
|
||||||
|
|
||||||
|
const currentNeedles = chart()
|
||||||
|
.selectAll('line')
|
||||||
|
.data(data)
|
||||||
|
|
||||||
|
currentNeedles.exit().remove()
|
||||||
|
currentNeedles.enter().append('line')
|
||||||
|
|
||||||
|
chart()
|
||||||
|
.selectAll('line')
|
||||||
|
.transition()
|
||||||
|
.duration(chart.animDurationMs ?? 0)
|
||||||
|
.attr('x1', (d: any) => xAxis(chart.x(d)))
|
||||||
|
.attr('x2', (d: any) => xAxis(chart.x(d)))
|
||||||
|
.attr('y1', height - offset.bottom - offset.top)
|
||||||
|
.attr('y2', (d: any) => yAxis(chart.y(d)))
|
||||||
|
.attr('stroke-dasharray', String(chart.dash ?? ''))
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
73
src/components/d3/renders/points.ts
Normal file
73
src/components/d3/renders/points.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { ChartRegistry, PointChartDataset } from '@components/d3/types'
|
||||||
|
|
||||||
|
export const renderPoint = <DataType extends Record<string, unknown>>(
|
||||||
|
xAxis: (value: any) => number,
|
||||||
|
yAxis: (value: any) => number,
|
||||||
|
chart: ChartRegistry<DataType>,
|
||||||
|
data: DataType[],
|
||||||
|
embeded: boolean = false,
|
||||||
|
): DataType[] => {
|
||||||
|
let config: Required<Omit<PointChartDataset, 'type'>> = {
|
||||||
|
radius: 3,
|
||||||
|
shape: 'circle',
|
||||||
|
strokeWidth: 0,
|
||||||
|
strokeColor: 'currentColor',
|
||||||
|
strokeOpacity: 1,
|
||||||
|
fillColor: 'currentColor',
|
||||||
|
fillOpacity: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embeded)
|
||||||
|
config = { ...config, ...chart.point }
|
||||||
|
else if (chart.type === 'point')
|
||||||
|
config = { ...config, ...chart }
|
||||||
|
else return data
|
||||||
|
|
||||||
|
const getPointsRoot = (): d3.Selection<any, any, any, any> => {
|
||||||
|
let root = chart()
|
||||||
|
if (embeded) {
|
||||||
|
if (root.select('.points').empty())
|
||||||
|
root.append('g').attr('class', 'points')
|
||||||
|
root = root.select('.points')
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
getPointsRoot()
|
||||||
|
.transition()
|
||||||
|
.duration(chart.animDurationMs ?? 0)
|
||||||
|
.attr('stroke-width', config.strokeWidth)
|
||||||
|
.attr('fill-opacity', config.fillOpacity)
|
||||||
|
.attr('fill', config.fillColor)
|
||||||
|
.attr('stroke', config.strokeColor)
|
||||||
|
.attr('stroke-opacity', config.strokeOpacity)
|
||||||
|
|
||||||
|
const currentPoints = getPointsRoot()
|
||||||
|
.selectAll(config.shape)
|
||||||
|
.data(data.filter(chart.y))
|
||||||
|
|
||||||
|
currentPoints.exit().remove()
|
||||||
|
currentPoints.enter().append(config.shape)
|
||||||
|
|
||||||
|
const newPoints = getPointsRoot()
|
||||||
|
.selectAll<d3.BaseType, DataType>(config.shape)
|
||||||
|
.transition()
|
||||||
|
.duration(chart.animDurationMs ?? 0)
|
||||||
|
|
||||||
|
switch (config.shape) {
|
||||||
|
default:
|
||||||
|
case 'circle':
|
||||||
|
newPoints.attr('r', config.radius)
|
||||||
|
.attr('cx', (d) => xAxis(chart.x(d)))
|
||||||
|
.attr('cy', (d) => yAxis(chart.y(d)))
|
||||||
|
break
|
||||||
|
case 'line':
|
||||||
|
newPoints.attr('x1', (d) => xAxis(chart.x(d)))
|
||||||
|
.attr('x2', (d) => xAxis(chart.x(d)))
|
||||||
|
.attr('y1', (d) => yAxis(chart.y(d)) - config.radius)
|
||||||
|
.attr('y2', (d) => yAxis(chart.y(d)) + config.radius)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
37
src/components/d3/renders/rect_area.ts
Normal file
37
src/components/d3/renders/rect_area.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { getByAccessor } from '../functions'
|
||||||
|
import { ChartRegistry } from '../types'
|
||||||
|
|
||||||
|
export const renderRectArea = <DataType extends Record<string, any>>(
|
||||||
|
xAxis: (value: d3.NumberValue) => number,
|
||||||
|
yAxis: (value: d3.NumberValue) => number,
|
||||||
|
chart: ChartRegistry<DataType>
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
chart.type !== 'rect_area' ||
|
||||||
|
!chart.minXAccessor ||
|
||||||
|
!chart.maxXAccessor ||
|
||||||
|
!chart.minYAccessor ||
|
||||||
|
!chart.maxYAccessor ||
|
||||||
|
!chart.data
|
||||||
|
) return
|
||||||
|
|
||||||
|
const data = chart.data
|
||||||
|
const xMin = getByAccessor(chart.minXAccessor)
|
||||||
|
const xMax = getByAccessor(chart.maxXAccessor)
|
||||||
|
const yMin = getByAccessor(chart.minYAccessor)
|
||||||
|
const yMax = getByAccessor(chart.maxYAccessor)
|
||||||
|
|
||||||
|
chart().attr('fill', 'currentColor')
|
||||||
|
|
||||||
|
const rects = chart().selectAll<SVGRectElement, null>('rect').data(data)
|
||||||
|
|
||||||
|
rects.exit().remove()
|
||||||
|
rects.enter().append('rect')
|
||||||
|
|
||||||
|
const actualRects = chart()
|
||||||
|
.selectAll<SVGRectElement, Record<string, any>>('rect')
|
||||||
|
.attr('x1', (d) => xAxis(xMin(d)))
|
||||||
|
.attr('x2', (d) => xAxis(xMax(d)))
|
||||||
|
.attr('y1', (d) => yAxis(yMin(d)))
|
||||||
|
.attr('y2', (d) => yAxis(yMax(d)))
|
||||||
|
}
|
111
src/components/d3/types.ts
Normal file
111
src/components/d3/types.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { Property } from 'csstype'
|
||||||
|
|
||||||
|
import {
|
||||||
|
D3TooltipSettings
|
||||||
|
} from './plugins'
|
||||||
|
|
||||||
|
export type AxisAccessor<DataType extends Record<string, any>> = keyof DataType | ((d: DataType) => any)
|
||||||
|
|
||||||
|
export type ChartAxis<DataType> = {
|
||||||
|
type: 'linear' | 'time',
|
||||||
|
accessor: AxisAccessor<DataType>
|
||||||
|
unit?: ReactNode
|
||||||
|
format?: (v: d3.NumberValue) => ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PointChartDataset = {
|
||||||
|
type: 'point'
|
||||||
|
radius?: number
|
||||||
|
shape?: 'circle' | 'line'
|
||||||
|
strokeColor?: Property.Color
|
||||||
|
strokeWidth?: number | string
|
||||||
|
strokeOpacity?: number
|
||||||
|
fillColor?: Property.Color
|
||||||
|
fillOpacity?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BaseChartDataset<DataType> = {
|
||||||
|
key: string | number
|
||||||
|
yAxis: ChartAxis<DataType>
|
||||||
|
xAxis: ChartAxis<DataType>
|
||||||
|
label?: ReactNode
|
||||||
|
shortLabel?: ReactNode
|
||||||
|
color?: Property.Color
|
||||||
|
opacity?: number
|
||||||
|
width?: number | string
|
||||||
|
tooltip?: D3TooltipSettings<DataType>
|
||||||
|
animDurationMs?: number
|
||||||
|
point?: Omit<PointChartDataset, 'type'>
|
||||||
|
afterDraw?: (d: any) => void
|
||||||
|
dash?: string | number | [string | number, string | number]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AreaChartDataset = {
|
||||||
|
type: 'area'
|
||||||
|
x0?: number
|
||||||
|
y0?: number
|
||||||
|
areaColor?: Property.Color
|
||||||
|
nullValues?: 'skip' | 'gap' | 'none'
|
||||||
|
optimization?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RectArea<DataType extends Record<string, number>> = {
|
||||||
|
type: 'rect_area'
|
||||||
|
minXAccessor?: AxisAccessor<DataType>
|
||||||
|
maxXAccessor?: AxisAccessor<DataType>
|
||||||
|
minYAccessor?: AxisAccessor<DataType>
|
||||||
|
maxYAccessor?: AxisAccessor<DataType>
|
||||||
|
data?: DataType[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LineChartDataset = {
|
||||||
|
type: 'line'
|
||||||
|
nullValues?: 'skip' | 'gap' | 'none'
|
||||||
|
optimization?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NeedleChartDataset = {
|
||||||
|
type: 'needle'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChartDataset<DataType> = BaseChartDataset<DataType> & (
|
||||||
|
AreaChartDataset |
|
||||||
|
LineChartDataset |
|
||||||
|
NeedleChartDataset |
|
||||||
|
PointChartDataset |
|
||||||
|
RectArea<Record<string, any>>
|
||||||
|
)
|
||||||
|
|
||||||
|
export type MinMax = { min?: number, max?: number }
|
||||||
|
|
||||||
|
export type ChartDomain = {
|
||||||
|
x?: MinMax
|
||||||
|
y?: MinMax
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChartOffset = {
|
||||||
|
top: number
|
||||||
|
bottom: number
|
||||||
|
left: number
|
||||||
|
right: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChartTick<DataType> = {
|
||||||
|
visible?: boolean,
|
||||||
|
count?: number | d3.TimeInterval,
|
||||||
|
format?: (d: d3.NumberValue, idx: number, data?: DataType) => string,
|
||||||
|
color?: Property.Color
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChartTicks<DataType> = {
|
||||||
|
color?: Property.Color
|
||||||
|
x?: ChartTick<DataType>
|
||||||
|
y?: ChartTick<DataType>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChartRegistry<DataType> = ChartDataset<DataType> & {
|
||||||
|
(): d3.Selection<SVGGElement, DataType, any, any>
|
||||||
|
y: (value: any) => number
|
||||||
|
x: (value: any) => number
|
||||||
|
}
|
@ -1,62 +1,71 @@
|
|||||||
import { notification } from 'antd'
|
import { notification } from 'antd'
|
||||||
|
import { ArgsProps } from 'antd/lib/notification'
|
||||||
import { Dispatch, ReactNode, SetStateAction } from 'react'
|
import { Dispatch, ReactNode, SetStateAction } from 'react'
|
||||||
|
|
||||||
|
import { FunctionalValue, getFunctionalValue, isDev } from '@utils'
|
||||||
|
import { getUserToken } from '@utils'
|
||||||
import { ApiError, FileInfoDto } from '@api'
|
import { ApiError, FileInfoDto } from '@api'
|
||||||
import { getUserToken } from '@utils/storage'
|
|
||||||
|
|
||||||
const notificationTypeDictionary = new Map([
|
|
||||||
['error' , { notifyInstance: notification.error , caption: 'Ошибка' }],
|
|
||||||
['warning', { notifyInstance: notification.warning, caption: 'Предупреждение' }],
|
|
||||||
['info' , { notifyInstance: notification.info , caption: 'Инфо' }],
|
|
||||||
['open' , { notifyInstance: notification.info , caption: '' }],
|
|
||||||
])
|
|
||||||
|
|
||||||
export type NotifyType = 'error' | 'warning' | 'info'
|
export type NotifyType = 'error' | 'warning' | 'info'
|
||||||
|
|
||||||
|
const notifyTypes: Record<NotifyType | 'defualt', ArgsProps & { instance: (args: ArgsProps) => void }> = {
|
||||||
|
error: { instance: notification.error, message: 'Ошибка' },
|
||||||
|
warning: { instance: notification.warning, message: 'Предупреждение' },
|
||||||
|
info: { instance: notification.info, message: 'Инфо' },
|
||||||
|
defualt: { instance: notification.info, message: '' },
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Вызов оповещений всплывающим окошком.
|
* Вызов оповещений всплывающим окошком.
|
||||||
* @param body string или ReactNode
|
* @param body string или ReactNode
|
||||||
* @param notifyType для параметра типа. Допустимые значение 'error', 'warning', 'info'
|
* @param notifyType для параметра типа. Допустимые значение 'error', 'warning', 'info'
|
||||||
|
* @param other прочие возможные аргументы уведомления
|
||||||
*/
|
*/
|
||||||
export const notify = (body: ReactNode, notifyType: NotifyType = 'info', other?: any) => {
|
export const notify = (body?: ReactNode, notifyType: NotifyType = 'info', other?: ArgsProps) => {
|
||||||
if (!body) return
|
if (!body) return
|
||||||
|
|
||||||
const instance = notificationTypeDictionary.get(notifyType) ??
|
const instance = notifyTypes[notifyType] ?? notifyTypes.defualt
|
||||||
notificationTypeDictionary.get('open')
|
|
||||||
|
|
||||||
instance?.notifyInstance({
|
instance?.instance({
|
||||||
description: body,
|
description: body,
|
||||||
message: instance.caption,
|
|
||||||
placement: 'bottomRight',
|
placement: 'bottomRight',
|
||||||
duration: 10,
|
duration: 10,
|
||||||
|
...instance,
|
||||||
...other
|
...other
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type asyncFunction = (...args: any) => Promise<any|void>
|
type asyncFunction = (...args: any) => Promise<any|void>
|
||||||
|
|
||||||
|
const parseApiEror = (err: unknown, actionName?: string) => {
|
||||||
|
if (!(err instanceof ApiError)) return false
|
||||||
|
|
||||||
|
switch (err.status) {
|
||||||
|
case 403:
|
||||||
|
if (actionName)
|
||||||
|
notify(`Недостаточно прав для выполнения действия "${actionName}"`, 'error')
|
||||||
|
else
|
||||||
|
notify('Недостаточно прав для выполнения действия', 'error')
|
||||||
|
return true
|
||||||
|
case 204: return true
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const invokeWebApiWrapperAsync = async (
|
export const invokeWebApiWrapperAsync = async (
|
||||||
funcAsync: asyncFunction,
|
funcAsync: asyncFunction,
|
||||||
setShowLoader?: Dispatch<SetStateAction<boolean>>,
|
setShowLoader?: Dispatch<SetStateAction<boolean>>,
|
||||||
errorNotifyText?: ReactNode | ((ex: unknown) => ReactNode),
|
errorNotifyText?: FunctionalValue<ReactNode, [unknown]>,
|
||||||
actionName?: string,
|
actionName?: string,
|
||||||
) => {
|
) => {
|
||||||
setShowLoader?.(true)
|
setShowLoader?.(true)
|
||||||
try{
|
try{
|
||||||
await funcAsync()
|
await funcAsync()
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
if(process.env.NODE_ENV === 'development')
|
if(isDev())
|
||||||
console.error(ex)
|
console.error(ex)
|
||||||
if (ex instanceof ApiError && ex.status === 403) {
|
if (!parseApiEror(ex, actionName))
|
||||||
if (actionName)
|
notify(getFunctionalValue(errorNotifyText)(ex), 'error')
|
||||||
notify(`Недостаточно прав для выполнения действия "${actionName}"`, 'error')
|
|
||||||
else
|
|
||||||
notify('Недостаточно прав для выполнения действия', 'error')
|
|
||||||
} else if(errorNotifyText) {
|
|
||||||
if (typeof errorNotifyText === 'function')
|
|
||||||
notify(errorNotifyText(ex), 'error')
|
|
||||||
else notify(errorNotifyText, 'error')
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setShowLoader?.(false)
|
setShowLoader?.(false)
|
||||||
}
|
}
|
||||||
@ -104,9 +113,11 @@ export const upload = async (url: string, formData: FormData) => {
|
|||||||
export const downloadFile = async (fileInfo: FileInfoDto) => {
|
export const downloadFile = async (fileInfo: FileInfoDto) => {
|
||||||
try {
|
try {
|
||||||
await download(`/api/well/${fileInfo.idWell}/files/${fileInfo.id}`)
|
await download(`/api/well/${fileInfo.idWell}/files/${fileInfo.id}`)
|
||||||
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify(`Не удалось скачать файл ${fileInfo.name} по скважине (${fileInfo.idWell})`, 'error')
|
notify(`Не удалось скачать файл "${fileInfo.name}" по скважине №${fileInfo.idWell}`, 'error')
|
||||||
console.log(error)
|
console.log(error)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import { Select, SelectProps } from 'antd'
|
import { Select, SelectProps } from 'antd'
|
||||||
|
|
||||||
export const defaultPeriod = 600
|
export const defaultPeriod = 3600
|
||||||
|
|
||||||
const timePeriodCollection = [
|
const timePeriodCollection = [
|
||||||
{ value: 60, label: '1 минута' },
|
{ value: 60, label: '1 минута' },
|
||||||
|
@ -2,7 +2,7 @@ import { Tag, TreeSelect } from 'antd'
|
|||||||
import { memo, useEffect, useState } from 'react'
|
import { memo, useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||||
import { hasPermission } from '@utils/permissions'
|
import { hasPermission } from '@utils'
|
||||||
import { DepositService } from '@api'
|
import { DepositService } from '@api'
|
||||||
|
|
||||||
export const getTreeData = async () => {
|
export const getTreeData = async () => {
|
||||||
@ -40,7 +40,8 @@ export const WellSelector = memo(({ idWell, value, onChange, treeData, treeLabel
|
|||||||
const [wellsTree, setWellsTree] = useState([])
|
const [wellsTree, setWellsTree] = useState([])
|
||||||
const [wellLabels, setWellLabels] = useState([])
|
const [wellLabels, setWellLabels] = useState([])
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
const wellsTree = treeData ?? await getTreeData()
|
const wellsTree = treeData ?? await getTreeData()
|
||||||
const labels = treeLabels ?? getTreeLabels(wellsTree)
|
const labels = treeLabels ?? getTreeLabels(wellsTree)
|
||||||
@ -50,7 +51,8 @@ export const WellSelector = memo(({ idWell, value, onChange, treeData, treeLabel
|
|||||||
null,
|
null,
|
||||||
'Не удалось загрузить список скважин',
|
'Не удалось загрузить список скважин',
|
||||||
'Получение списка скважин'
|
'Получение списка скважин'
|
||||||
), [idWell, treeData, treeLabels])
|
)
|
||||||
|
}, [idWell, treeData, treeLabels])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TreeSelect
|
<TreeSelect
|
||||||
|
@ -3,7 +3,7 @@ import { LabelInValueType } from 'rc-select/lib/Select'
|
|||||||
import { RawValueType } from 'rc-tree-select/lib/TreeSelect'
|
import { RawValueType } from 'rc-tree-select/lib/TreeSelect'
|
||||||
import { DefaultValueType } from 'rc-tree-select/lib/interface'
|
import { DefaultValueType } from 'rc-tree-select/lib/interface'
|
||||||
import { useState, useEffect, ReactNode, useCallback, memo } from 'react'
|
import { useState, useEffect, ReactNode, useCallback, memo } from 'react'
|
||||||
import { useHistory, useLocation, useRouteMatch } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import { isRawDate } from '@utils'
|
import { isRawDate } from '@utils'
|
||||||
import LoaderPortal from '@components/LoaderPortal'
|
import LoaderPortal from '@components/LoaderPortal'
|
||||||
@ -29,40 +29,47 @@ export type TreeNodeData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getLabel = (wellsTree: TreeNodeData[], value?: string): string | undefined => {
|
const getLabel = (wellsTree: TreeNodeData[], value?: string): string | undefined => {
|
||||||
if (!value) return value
|
const result = value?.match(/^\/([^\/]+)\/([^\/?]+)/) // pattern "/:type/:id"
|
||||||
const type = value.replaceAll('/', ' ').trim().split(' ')[0]
|
if (wellsTree.length <= 0 || !result) return
|
||||||
|
const [url, type] = result
|
||||||
let deposit: TreeNodeData | undefined
|
let deposit: TreeNodeData | undefined
|
||||||
let cluster: TreeNodeData | undefined
|
let cluster: TreeNodeData | undefined
|
||||||
let well: TreeNodeData | undefined
|
let well: TreeNodeData | undefined
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case 'deposit':
|
||||||
|
deposit = wellsTree.find((deposit) => deposit.key === url)
|
||||||
|
if (deposit)
|
||||||
|
return `${deposit.title}`
|
||||||
|
return 'Ошибка! Месторождение не найдено!'
|
||||||
|
|
||||||
case 'cluster':
|
case 'cluster':
|
||||||
deposit = wellsTree.find((deposit) => (
|
deposit = wellsTree.find((deposit) => (
|
||||||
cluster = deposit.children?.find((cluster: TreeNodeData) => cluster.key === value)
|
cluster = deposit.children?.find((cluster: TreeNodeData) => cluster.key === url)
|
||||||
))
|
))
|
||||||
if (deposit && cluster)
|
if (deposit && cluster)
|
||||||
return `${deposit.title} / ${cluster.title}`
|
return `${deposit.title} / ${cluster.title}`
|
||||||
break
|
return 'Ошибка! Куст не найден!'
|
||||||
|
|
||||||
case 'well':
|
case 'well':
|
||||||
deposit = wellsTree.find((deposit) => (
|
deposit = wellsTree.find((deposit) => (
|
||||||
cluster = deposit.children?.find((cluster: TreeNodeData) => (
|
cluster = deposit.children?.find((cluster: TreeNodeData) => (
|
||||||
well = cluster.children?.find((well: TreeNodeData) => well.key === value)
|
well = cluster.children?.find((well: TreeNodeData) => well.key === url)
|
||||||
))
|
))
|
||||||
))
|
))
|
||||||
if (deposit && cluster && well)
|
if (deposit && cluster && well)
|
||||||
return `${deposit.title} / ${cluster.title} / ${well.title}`
|
return `${deposit.title} / ${cluster.title} / ${well.title}`
|
||||||
break
|
return 'Ошибка! Скважина не найдена!'
|
||||||
|
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
return 'Ошибка! Скважина не найдена!'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WellTreeSelector = memo(({ ...other }) => {
|
export const WellTreeSelector = memo(({ ...other }) => {
|
||||||
const [wellsTree, setWellsTree] = useState<TreeNodeData[]>([])
|
const [wellsTree, setWellsTree] = useState<TreeNodeData[]>([])
|
||||||
const [showLoader, setShowLoader] = useState<boolean>(false)
|
const [showLoader, setShowLoader] = useState<boolean>(false)
|
||||||
const [value, setValue] = useState<string>()
|
const [value, setValue] = useState<string>()
|
||||||
const history = useHistory()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const routeMatch = useRouteMatch('/:route/:id')
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
invokeWebApiWrapperAsync(
|
invokeWebApiWrapperAsync(
|
||||||
@ -99,14 +106,14 @@ export const WellTreeSelector = memo(({ ...other }) => {
|
|||||||
)
|
)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => setValue(getLabel(wellsTree, routeMatch?.url)), [wellsTree, routeMatch])
|
useEffect(() => setValue(getLabel(wellsTree, location.pathname)), [wellsTree, location])
|
||||||
|
|
||||||
const onChange = useCallback((value?: string): void => setValue(getLabel(wellsTree, value)), [wellsTree])
|
const onChange = useCallback((value?: string): void => setValue(getLabel(wellsTree, value)), [wellsTree])
|
||||||
|
|
||||||
const onSelect = useCallback((value: RawValueType | LabelInValueType): void => {
|
const onSelect = useCallback((value: RawValueType | LabelInValueType): void => {
|
||||||
if (['number', 'string'].includes(typeof value))
|
if (['number', 'string'].includes(typeof value))
|
||||||
history.push({ pathname: String(value), state: { from: location.pathname }})
|
navigate(String(value), { state: { from: location.pathname }})
|
||||||
}, [history, location])
|
}, [navigate, location])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoaderPortal show={showLoader}>
|
<LoaderPortal show={showLoader}>
|
||||||
|
@ -2,6 +2,8 @@ import { Button } from 'antd'
|
|||||||
import { memo, ReactNode, useMemo } from 'react'
|
import { memo, ReactNode, useMemo } from 'react'
|
||||||
import { CloseOutlined, SettingOutlined } from '@ant-design/icons'
|
import { CloseOutlined, SettingOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
|
import { makeDisplayValue } from '@utils'
|
||||||
|
|
||||||
import '@styles/widgets/base.less'
|
import '@styles/widgets/base.less'
|
||||||
|
|
||||||
export type WidgetSettings<T = any> = {
|
export type WidgetSettings<T = any> = {
|
||||||
@ -17,10 +19,14 @@ export type WidgetSettings<T = any> = {
|
|||||||
unitColor?: string,
|
unitColor?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emptyNumber = '----'
|
||||||
|
|
||||||
|
const defaultFormatter = makeDisplayValue({ def: emptyNumber, fixed: 2 })
|
||||||
|
|
||||||
export const defaultSettings: WidgetSettings = {
|
export const defaultSettings: WidgetSettings = {
|
||||||
unit: '----',
|
unit: '----',
|
||||||
label: 'Виджет',
|
label: 'Виджет',
|
||||||
formatter: v => isNaN(v) ? v : parseFloat(v).toFixed(2),
|
formatter: defaultFormatter,
|
||||||
|
|
||||||
labelColor: '#000000',
|
labelColor: '#000000',
|
||||||
valueColor: '#000000',
|
valueColor: '#000000',
|
||||||
@ -57,7 +63,7 @@ export const BaseWidget = memo<BaseWidgetProps>(({ value, onRemove, onEdit, ...s
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={'widget_value'} style={{ color: sets.valueColor }}>
|
<div className={'widget_value'} style={{ color: sets.valueColor }}>
|
||||||
{(sets.formatter === null ? value : sets.formatter?.(value)) ?? sets.defaultValue ?? '----'}
|
{(sets.formatter === null ? value : sets.formatter?.(value)) ?? sets.defaultValue ?? emptyNumber}
|
||||||
</div>
|
</div>
|
||||||
<div className={'widget_units'} style={{ color: sets.unitColor }}>{sets.unit}</div>
|
<div className={'widget_units'} style={{ color: sets.unitColor }}>{sets.unit}</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
|
|
||||||
|
import logo from '@images/logo_32.png'
|
||||||
|
|
||||||
export const Logo = memo<React.DetailedHTMLProps<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>>((props) => (
|
export const Logo = memo<React.DetailedHTMLProps<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>>((props) => (
|
||||||
<img src={'/images/logo_32.png'} alt={'АСБ'} className={'logo'} {...props} />
|
<img src={logo} alt={'АСБ'} className={'logo'} {...props} />
|
||||||
))
|
))
|
||||||
|
|
||||||
export default Logo
|
export default Logo
|
||||||
|
0
public/images/logo_32.png → src/images/logo_32.png
Executable file → Normal file
0
public/images/logo_32.png → src/images/logo_32.png
Executable file → Normal file
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
@ -1,15 +1,19 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import { createRoot } from 'react-dom/client'
|
||||||
import App from './App'
|
|
||||||
import reportWebVitals from './reportWebVitals'
|
import reportWebVitals from './reportWebVitals'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
import '@styles/index.css'
|
import '@styles/index.css'
|
||||||
|
|
||||||
ReactDOM.render((
|
const container = document.getElementById('root') ?? document.body
|
||||||
|
const root = createRoot(container)
|
||||||
|
|
||||||
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
), document.getElementById('root'))
|
)
|
||||||
|
|
||||||
// If you want to start measuring performance in your app, pass a function
|
// If you want to start measuring performance in your app, pass a function
|
||||||
// to log results (for example: reportWebVitals(console.log))
|
// to log results (for example: reportWebVitals(console.log))
|
||||||
|
@ -1,18 +1,43 @@
|
|||||||
|
import { Result, Typography } from 'antd'
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { CloseCircleOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
export const AccessDenied = memo(() => (
|
const { Paragraph, Text } = Typography
|
||||||
<div style={{
|
|
||||||
width: '100vw',
|
export const AccessDenied = memo(() => {
|
||||||
height: '100vh',
|
const navigate = useNavigate()
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
return (
|
||||||
justifyContent: 'center',
|
<Result
|
||||||
alignItems: 'center'
|
status={'error'}
|
||||||
}}>
|
title={'Доступ запрешён'}
|
||||||
<h2>Доступ запрещён</h2>
|
subTitle={'Страницы не существует или у вас отсутствует к ней доступ.'}
|
||||||
<Link to={'/login'}>На страницу входа</Link>
|
>
|
||||||
|
<div className={'desc'}>
|
||||||
|
<Paragraph>
|
||||||
|
<Text strong style={{ fontSize: 16 }}>Возможные причины данной проблемы:</Text>
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph>
|
||||||
|
<CloseCircleOutlined style={{ color: 'red' }} />
|
||||||
|
У вас отсутствует доступ к странице.
|
||||||
|
<Typography.Link href={'mailto://support@digitaldrilling.ru'} target={'_blank'}>
|
||||||
|
Обратиться в поддержку >
|
||||||
|
</Typography.Link>
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph>
|
||||||
|
<CloseCircleOutlined style={{ color: 'red' }} />
|
||||||
|
Страницы не существует.
|
||||||
|
<Link to={'#'} onClick={() => navigate(-1)}>Вернуться назад ></Link>
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph>
|
||||||
|
<CloseCircleOutlined style={{ color: 'red' }} />
|
||||||
|
Разрешения не обновились.
|
||||||
|
<Link to={'/login'}>Перезайти в аккаунт ></Link>
|
||||||
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
))
|
</Result>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
export default AccessDenied
|
export default AccessDenied
|
||||||
|
@ -5,20 +5,18 @@ import {
|
|||||||
EditableTable,
|
EditableTable,
|
||||||
makeColumn,
|
makeColumn,
|
||||||
makeSelectColumn,
|
makeSelectColumn,
|
||||||
makeActionHandler,
|
|
||||||
makeStringSorter,
|
makeStringSorter,
|
||||||
defaultPagination,
|
defaultPagination,
|
||||||
makeTimezoneColumn
|
makeTimezoneColumn
|
||||||
} 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 } from '@utils'
|
import { arrayOrDefault, wrapPrivateComponent } from '@utils'
|
||||||
import { min1 } from '@utils/validationRules'
|
import { min1 } from '@utils/validationRules'
|
||||||
import { hasPermission } from '@utils/permissions'
|
|
||||||
|
|
||||||
import { coordsFixed } from './DepositController'
|
import { coordsFixed } from './DepositController'
|
||||||
|
|
||||||
export const ClusterController = memo(() => {
|
const ClusterController = memo(() => {
|
||||||
const [deposits, setDeposits] = useState([])
|
const [deposits, setDeposits] = useState([])
|
||||||
const [clusters, setClusters] = useState([])
|
const [clusters, setClusters] = useState([])
|
||||||
const [showLoader, setShowLoader] = useState(false)
|
const [showLoader, setShowLoader] = useState(false)
|
||||||
@ -58,7 +56,8 @@ export const ClusterController = memo(() => {
|
|||||||
'Получение списка кустов'
|
'Получение списка кустов'
|
||||||
), [])
|
), [])
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
let deposits = arrayOrDefault(await AdminDepositService.getAll())
|
let deposits = arrayOrDefault(await AdminDepositService.getAll())
|
||||||
deposits = deposits.map((deposit) => ({ value: deposit.id, label: deposit.caption }))
|
deposits = deposits.map((deposit) => ({ value: deposit.id, label: deposit.caption }))
|
||||||
@ -67,16 +66,27 @@ export const ClusterController = memo(() => {
|
|||||||
setShowLoader,
|
setShowLoader,
|
||||||
`Не удалось загрузить список месторождений`,
|
`Не удалось загрузить список месторождений`,
|
||||||
'Получение списка месторождений'
|
'Получение списка месторождений'
|
||||||
), [])
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(updateTable, [updateTable])
|
useEffect(() => {
|
||||||
|
updateTable()
|
||||||
|
}, [updateTable])
|
||||||
|
|
||||||
const handlerProps = useMemo(() => ({
|
const tableHandlers = useMemo(() => {
|
||||||
|
const handlerProps = {
|
||||||
service: AdminClusterService,
|
service: AdminClusterService,
|
||||||
setLoader: setShowLoader,
|
setLoader: setShowLoader,
|
||||||
errorMsg: `Не удалось выполнить операцию`,
|
|
||||||
onComplete: updateTable,
|
onComplete: updateTable,
|
||||||
}), [updateTable])
|
permission: 'AdminCluster.edit'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
add: { ...handlerProps, action: 'insert', actionName: 'Добавление куста' },
|
||||||
|
edit: { ...handlerProps, action: 'update', actionName: 'Редактирование куста' },
|
||||||
|
delete: { ...handlerProps, action: 'delete', actionName: 'Удаление куста', permission: 'AdminCluster.delete' },
|
||||||
|
}
|
||||||
|
}, [updateTable])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -94,13 +104,17 @@ export const ClusterController = memo(() => {
|
|||||||
columns={clusterColumns}
|
columns={clusterColumns}
|
||||||
dataSource={filteredClusters}
|
dataSource={filteredClusters}
|
||||||
pagination={defaultPagination}
|
pagination={defaultPagination}
|
||||||
onRowAdd={hasPermission('AdminCluster.edit') && makeActionHandler('insert', handlerProps, null, 'Добавление куста')}
|
onRowAdd={tableHandlers.add}
|
||||||
onRowEdit={hasPermission('AdminCluster.edit') && makeActionHandler('update', handlerProps, null, 'Редактирование куста')}
|
onRowEdit={tableHandlers.edit}
|
||||||
onRowDelete={hasPermission('AdminCluster.delete') && makeActionHandler('delete', handlerProps, null, 'Удаление куста')}
|
onRowDelete={tableHandlers.delete}
|
||||||
tableName={'admin_cluster_controller'}
|
tableName={'admin_cluster_controller'}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default ClusterController
|
export default wrapPrivateComponent(ClusterController, {
|
||||||
|
requirements: ['AdminDeposit.get', 'AdminCluster.get'],
|
||||||
|
title: 'Кусты',
|
||||||
|
route: 'cluster',
|
||||||
|
})
|
||||||
|
@ -4,19 +4,16 @@ import { Input } from 'antd'
|
|||||||
import {
|
import {
|
||||||
EditableTable,
|
EditableTable,
|
||||||
makeColumn,
|
makeColumn,
|
||||||
makeActionHandler,
|
|
||||||
makeStringSorter,
|
makeStringSorter,
|
||||||
makeSelectColumn,
|
makeSelectColumn,
|
||||||
defaultPagination
|
defaultPagination
|
||||||
} 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 } from '@utils'
|
import { arrayOrDefault, wrapPrivateComponent } from '@utils'
|
||||||
import { min1 } from '@utils/validationRules'
|
import { min1 } from '@utils/validationRules'
|
||||||
import { hasPermission } from '@utils/permissions'
|
|
||||||
|
|
||||||
|
const CompanyController = memo(() => {
|
||||||
export const CompanyController = memo(() => {
|
|
||||||
const [columns, setColumns] = useState([])
|
const [columns, setColumns] = useState([])
|
||||||
const [companies, setCompanies] = useState([])
|
const [companies, setCompanies] = useState([])
|
||||||
const [showLoader, setShowLoader] = useState(false)
|
const [showLoader, setShowLoader] = useState(false)
|
||||||
@ -31,7 +28,8 @@ export const CompanyController = memo(() => {
|
|||||||
setCompanies(arrayOrDefault(companies))
|
setCompanies(arrayOrDefault(companies))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async() => {
|
async() => {
|
||||||
const companyTypes = arrayOrDefault(await AdminCompanyTypeService.getAll()).map((companyType) => ({
|
const companyTypes = arrayOrDefault(await AdminCompanyTypeService.getAll()).map((companyType) => ({
|
||||||
value: companyType.id,
|
value: companyType.id,
|
||||||
@ -56,19 +54,28 @@ export const CompanyController = memo(() => {
|
|||||||
setShowLoader,
|
setShowLoader,
|
||||||
`Не удалось загрузить список типов компаний`,
|
`Не удалось загрузить список типов компаний`,
|
||||||
'Получение списка типов команд'
|
'Получение списка типов команд'
|
||||||
), [updateTable])
|
)
|
||||||
|
}, [updateTable])
|
||||||
|
|
||||||
const handlerProps = useMemo(() => ({
|
const tableHandlers = useMemo(() => {
|
||||||
|
const handlerProps = {
|
||||||
service: AdminCompanyService,
|
service: AdminCompanyService,
|
||||||
setLoader: setShowLoader,
|
setLoader: setShowLoader,
|
||||||
errorMsg: `Не удалось выполнить операцию`,
|
|
||||||
onComplete: () => invokeWebApiWrapperAsync(
|
onComplete: () => invokeWebApiWrapperAsync(
|
||||||
updateTable,
|
updateTable,
|
||||||
setShowLoader,
|
setShowLoader,
|
||||||
`Не удалось обновить список компаний`,
|
`Не удалось обновить список компаний`,
|
||||||
'Получение списка компаний'
|
'Получение списка компаний'
|
||||||
),
|
),
|
||||||
}), [updateTable])
|
permission: 'AdminCompany.edit'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
add: { ...handlerProps, action: 'insert', actionName: 'Добавление компании' },
|
||||||
|
edit: { ...handlerProps, action: 'update', actionName: 'Редактирование компании' },
|
||||||
|
delete: { ...handlerProps, action: 'delete', actionName: 'Удаление компании', permission: 'AdminCompany.delete' },
|
||||||
|
}
|
||||||
|
}, [updateTable])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -86,13 +93,17 @@ export const CompanyController = memo(() => {
|
|||||||
loading={showLoader}
|
loading={showLoader}
|
||||||
dataSource={filteredCompanies}
|
dataSource={filteredCompanies}
|
||||||
pagination={defaultPagination}
|
pagination={defaultPagination}
|
||||||
onRowAdd={hasPermission('AdminCompany.edit') && makeActionHandler('insert', handlerProps, null, 'Добавлениее компаний')}
|
onRowAdd={tableHandlers.add}
|
||||||
onRowEdit={hasPermission('AdminCompany.edit') && makeActionHandler('update', handlerProps, null, 'Редактирование команий')}
|
onRowEdit={tableHandlers.edit}
|
||||||
onRowDelete={hasPermission('AdminCompany.delete') && makeActionHandler('delete', handlerProps, null, 'Удаление компаний')}
|
onRowDelete={tableHandlers.delete}
|
||||||
tableName={'admin_company_controller'}
|
tableName={'admin_company_controller'}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default CompanyController
|
export default wrapPrivateComponent(CompanyController, {
|
||||||
|
requirements: ['AdminCompany.get', 'AdminCompanyType.get'],
|
||||||
|
title: 'Компании',
|
||||||
|
route: 'company',
|
||||||
|
})
|
||||||
|
@ -1,18 +1,11 @@
|
|||||||
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 {
|
import { EditableTable, makeColumn, makeStringSorter, defaultPagination } from '@components/Table'
|
||||||
EditableTable,
|
|
||||||
makeColumn,
|
|
||||||
makeActionHandler,
|
|
||||||
makeStringSorter,
|
|
||||||
defaultPagination
|
|
||||||
} from '@components/Table'
|
|
||||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||||
import { AdminCompanyTypeService } from '@api'
|
import { arrayOrDefault, wrapPrivateComponent } from '@utils'
|
||||||
import { arrayOrDefault } from '@utils'
|
|
||||||
import { min1 } from '@utils/validationRules'
|
import { min1 } from '@utils/validationRules'
|
||||||
import { hasPermission } from '@utils/permissions'
|
import { AdminCompanyTypeService } from '@api'
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
makeColumn('Название', 'caption', {
|
makeColumn('Название', 'caption', {
|
||||||
@ -23,7 +16,7 @@ const columns = [
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
export const CompanyTypeController = memo(() => {
|
const CompanyTypeController = memo(() => {
|
||||||
const [companyTypes, setCompanyTypes] = useState([])
|
const [companyTypes, setCompanyTypes] = useState([])
|
||||||
const [showLoader, setShowLoader] = useState(false)
|
const [showLoader, setShowLoader] = useState(false)
|
||||||
const [searchValue, setSearchValue] = useState('')
|
const [searchValue, setSearchValue] = useState('')
|
||||||
@ -42,14 +35,25 @@ export const CompanyTypeController = memo(() => {
|
|||||||
'Получение списка типов компаний'
|
'Получение списка типов компаний'
|
||||||
), [])
|
), [])
|
||||||
|
|
||||||
useEffect(updateTable, [updateTable])
|
useEffect(() => {
|
||||||
|
updateTable()
|
||||||
|
}, [updateTable])
|
||||||
|
|
||||||
const handlerProps = useMemo(() => ({
|
const tableHandlers = useMemo(() => {
|
||||||
|
const handlerProps = {
|
||||||
service: AdminCompanyTypeService,
|
service: AdminCompanyTypeService,
|
||||||
setLoader: setShowLoader,
|
setLoader: setShowLoader,
|
||||||
errorMsg: `Не удалось выполнить операцию`,
|
errorMsg: `Не удалось выполнить операцию`,
|
||||||
onComplete: updateTable,
|
onComplete: updateTable,
|
||||||
}), [updateTable])
|
permission: 'AdminCompanyType.edit'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
add: { ...handlerProps, action: 'insert', actionName: 'Добавление типа компаний' },
|
||||||
|
edit: { ...handlerProps, action: 'update', actionName: 'Редактирование типа компаний' },
|
||||||
|
delete: { ...handlerProps, action: 'delete', actionName: 'Удаление типа компаний', permission: 'AdminCompanyType.delete' },
|
||||||
|
}
|
||||||
|
}, [updateTable])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -67,13 +71,17 @@ export const CompanyTypeController = memo(() => {
|
|||||||
loading={showLoader}
|
loading={showLoader}
|
||||||
pagination={defaultPagination}
|
pagination={defaultPagination}
|
||||||
dataSource={filteredCompanyTypes}
|
dataSource={filteredCompanyTypes}
|
||||||
onRowAdd={hasPermission('AdminCompanyType.edit') && makeActionHandler('insert', handlerProps, null, 'Добавление типа компаний')}
|
onRowAdd={tableHandlers.add}
|
||||||
onRowEdit={hasPermission('AdminCompanyType.edit') && makeActionHandler('update', handlerProps, null, 'Редактирование типа компаний')}
|
onRowEdit={tableHandlers.edit}
|
||||||
onRowDelete={hasPermission('AdminCompanyType.delete') && makeActionHandler('delete', handlerProps, null, 'Удаление типа компаний')}
|
onRowDelete={tableHandlers.delete}
|
||||||
tableName={'admin_company_type_controller'}
|
tableName={'admin_company_type_controller'}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default CompanyTypeController
|
export default wrapPrivateComponent(CompanyTypeController, {
|
||||||
|
requirements: ['AdminCompanyType.get'],
|
||||||
|
title: 'Типы компаний',
|
||||||
|
route: 'company_type',
|
||||||
|
})
|
||||||
|
@ -2,10 +2,9 @@ import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
|||||||
import { Input } from 'antd'
|
import { Input } from 'antd'
|
||||||
|
|
||||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||||
import { EditableTable, makeColumn, makeActionHandler, defaultPagination, makeTimezoneColumn } from '@components/Table'
|
import { EditableTable, makeColumn, defaultPagination, makeTimezoneColumn } from '@components/Table'
|
||||||
import { hasPermission } from '@utils/permissions'
|
import { arrayOrDefault, wrapPrivateComponent } from '@utils'
|
||||||
import { min1 } from '@utils/validationRules'
|
import { min1 } from '@utils/validationRules'
|
||||||
import { arrayOrDefault } from '@utils'
|
|
||||||
import { AdminDepositService } from '@api'
|
import { AdminDepositService } from '@api'
|
||||||
|
|
||||||
export const coordsFixed = (coords) => coords && isFinite(coords) ? (+coords).toPrecision(10) : '-'
|
export const coordsFixed = (coords) => coords && isFinite(coords) ? (+coords).toPrecision(10) : '-'
|
||||||
@ -17,7 +16,7 @@ const depositColumns = [
|
|||||||
makeTimezoneColumn('Зона', 'timezone', null, true, { width: 150 }),
|
makeTimezoneColumn('Зона', 'timezone', null, true, { width: 150 }),
|
||||||
]
|
]
|
||||||
|
|
||||||
export const DepositController = memo(() => {
|
const DepositController = memo(() => {
|
||||||
const [deposits, setDeposits] = useState([])
|
const [deposits, setDeposits] = useState([])
|
||||||
const [showLoader, setShowLoader] = useState(false)
|
const [showLoader, setShowLoader] = useState(false)
|
||||||
const [searchValue, setSearchValue] = useState('')
|
const [searchValue, setSearchValue] = useState('')
|
||||||
@ -39,14 +38,24 @@ export const DepositController = memo(() => {
|
|||||||
'Получение списка месторождений'
|
'Получение списка месторождений'
|
||||||
), [])
|
), [])
|
||||||
|
|
||||||
useEffect(updateTable, [updateTable])
|
useEffect(() => {
|
||||||
|
updateTable()
|
||||||
|
}, [updateTable])
|
||||||
|
|
||||||
const handlerProps = useMemo(() => ({
|
const tableHandlers = useMemo(() => {
|
||||||
|
const handlerProps = {
|
||||||
service: AdminDepositService,
|
service: AdminDepositService,
|
||||||
setLoader: setShowLoader,
|
setLoader: setShowLoader,
|
||||||
errorMsg: `Не удалось выполнить операцию`,
|
|
||||||
onComplete: updateTable,
|
onComplete: updateTable,
|
||||||
}), [updateTable])
|
permission: 'AdminDeposit.edit'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
add: { ...handlerProps, action: 'insert', actionName: 'Добавление месторождения' },
|
||||||
|
edit: { ...handlerProps, action: 'update', actionName: 'Редактирование месторождения' },
|
||||||
|
delete: { ...handlerProps, action: 'delete', actionName: 'Удаление месторождения', permission: 'AdminDeposit.delete' },
|
||||||
|
}
|
||||||
|
}, [updateTable])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -64,13 +73,17 @@ export const DepositController = memo(() => {
|
|||||||
columns={depositColumns}
|
columns={depositColumns}
|
||||||
dataSource={filteredDeposits}
|
dataSource={filteredDeposits}
|
||||||
pagination={defaultPagination}
|
pagination={defaultPagination}
|
||||||
onRowAdd={hasPermission('AdminDeposit.edit') && makeActionHandler('insert', handlerProps, null, 'Добавление месторождения')}
|
onRowAdd={tableHandlers.add}
|
||||||
onRowEdit={hasPermission('AdminDeposit.edit') && makeActionHandler('update', handlerProps, null, 'Редактирование месторождения')}
|
onRowEdit={tableHandlers.edit}
|
||||||
onRowDelete={hasPermission('AdminDeposit.delete') && makeActionHandler('delete', handlerProps, null, 'Удаление месторождения')}
|
onRowDelete={tableHandlers.delete}
|
||||||
tableName={'admin_deposit_controller'}
|
tableName={'admin_deposit_controller'}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default DepositController
|
export default wrapPrivateComponent(DepositController, {
|
||||||
|
requirements: ['AdminDeposit.get'],
|
||||||
|
title: 'Месторождения',
|
||||||
|
route: 'deposit',
|
||||||
|
})
|
||||||
|
@ -1,17 +1,11 @@
|
|||||||
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 {
|
import { EditableTable, makeColumn, makeStringSorter } from '@components/Table'
|
||||||
EditableTable,
|
|
||||||
makeActionHandler,
|
|
||||||
makeColumn,
|
|
||||||
makeStringSorter
|
|
||||||
} from '@components/Table'
|
|
||||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||||
import { AdminPermissionService } from '@api'
|
import { arrayOrDefault, wrapPrivateComponent } from '@utils'
|
||||||
import { arrayOrDefault } from '@utils'
|
|
||||||
import { min1 } from '@utils/validationRules'
|
import { min1 } from '@utils/validationRules'
|
||||||
import { hasPermission } from '@utils/permissions'
|
import { AdminPermissionService } from '@api'
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
makeColumn('Название', 'name', {
|
makeColumn('Название', 'name', {
|
||||||
@ -26,7 +20,7 @@ const columns = [
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
export const PermissionController = memo(() => {
|
const PermissionController = memo(() => {
|
||||||
const [permissions, setPermissions] = useState([])
|
const [permissions, setPermissions] = useState([])
|
||||||
const [showLoader, setShowLoader] = useState(false)
|
const [showLoader, setShowLoader] = useState(false)
|
||||||
const [searchValue, setSearchValue] = useState('')
|
const [searchValue, setSearchValue] = useState('')
|
||||||
@ -34,7 +28,9 @@ export const PermissionController = memo(() => {
|
|||||||
const filteredPermissions = useMemo(() => permissions.filter((permission) => permission && (!searchValue || [
|
const filteredPermissions = useMemo(() => permissions.filter((permission) => permission && (!searchValue || [
|
||||||
permission.name ?? '',
|
permission.name ?? '',
|
||||||
permission.description ?? '',
|
permission.description ?? '',
|
||||||
].join(' ').includes(searchValue.toLowerCase()))
|
].join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchValue.toLowerCase()))
|
||||||
), [permissions, searchValue])
|
), [permissions, searchValue])
|
||||||
|
|
||||||
const updateTable = useCallback(async () => invokeWebApiWrapperAsync(
|
const updateTable = useCallback(async () => invokeWebApiWrapperAsync(
|
||||||
@ -47,14 +43,24 @@ export const PermissionController = memo(() => {
|
|||||||
'Получение списка прав'
|
'Получение списка прав'
|
||||||
), [])
|
), [])
|
||||||
|
|
||||||
useEffect(() => updateTable(), [updateTable])
|
useEffect(() => {
|
||||||
|
updateTable()
|
||||||
|
}, [updateTable])
|
||||||
|
|
||||||
const handlerProps = useMemo(() => ({
|
const tableHandlers = useMemo(() => {
|
||||||
|
const handlerProps = {
|
||||||
service: AdminPermissionService,
|
service: AdminPermissionService,
|
||||||
setLoader: setShowLoader,
|
setLoader: setShowLoader,
|
||||||
errorMsg: `Не удалось выполнить операцию`,
|
onComplete: updateTable,
|
||||||
onComplete: updateTable
|
permission: 'AdminPermission.edit'
|
||||||
}), [updateTable])
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
add: { ...handlerProps, action: 'insert', actionName: 'Добавление разрешения' },
|
||||||
|
edit: { ...handlerProps, action: 'update', actionName: 'Редактирование разрешения' },
|
||||||
|
delete: { ...handlerProps, action: 'delete', actionName: 'Удаление разрешения', permission: 'AdminPermission.delete' },
|
||||||
|
}
|
||||||
|
}, [updateTable])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -72,13 +78,17 @@ export const PermissionController = memo(() => {
|
|||||||
loading={showLoader}
|
loading={showLoader}
|
||||||
dataSource={filteredPermissions}
|
dataSource={filteredPermissions}
|
||||||
pagination={{ showSizeChanger: true }}
|
pagination={{ showSizeChanger: true }}
|
||||||
onRowAdd={hasPermission('AdminPermission.edit') && makeActionHandler('insert', handlerProps, null, 'Добавление права')}
|
onRowAdd={tableHandlers.add}
|
||||||
onRowEdit={hasPermission('AdminPermission.edit') && makeActionHandler('update', handlerProps, null, 'Редактирование права')}
|
onRowEdit={tableHandlers.edit}
|
||||||
onRowDelete={hasPermission('AdminPermission.delete') && makeActionHandler('delete', handlerProps, null, 'Удаление права')}
|
onRowDelete={tableHandlers.delete}
|
||||||
tableName={'admin_permission_controller'}
|
tableName={'admin_permission_controller'}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default PermissionController
|
export default wrapPrivateComponent(PermissionController, {
|
||||||
|
requirements: ['AdminPermission.get'],
|
||||||
|
title: 'Разрешения',
|
||||||
|
route: 'permission',
|
||||||
|
})
|
||||||
|
@ -3,13 +3,12 @@ import { Input } from 'antd'
|
|||||||
|
|
||||||
import { PermissionView, RoleView } from '@components/views'
|
import { PermissionView, RoleView } from '@components/views'
|
||||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||||
import { EditableTable, makeActionHandler, makeTagColumn, makeTextColumn } from '@components/Table'
|
import { EditableTable, makeTagColumn, makeTextColumn } from '@components/Table'
|
||||||
import { AdminPermissionService, AdminUserRoleService } from '@api'
|
import { AdminPermissionService, AdminUserRoleService } from '@api'
|
||||||
import { arrayOrDefault } from '@utils'
|
import { arrayOrDefault, wrapPrivateComponent } from '@utils'
|
||||||
import { min1 } from '@utils/validationRules'
|
import { min1 } from '@utils/validationRules'
|
||||||
import { hasPermission } from '@utils/permissions'
|
|
||||||
|
|
||||||
export const RoleController = memo(() => {
|
const RoleController = memo(() => {
|
||||||
const [permissions, setPermissions] = useState([])
|
const [permissions, setPermissions] = useState([])
|
||||||
const [roles, setRoles] = useState([])
|
const [roles, setRoles] = useState([])
|
||||||
const [showLoader, setShowLoader] = useState(false)
|
const [showLoader, setShowLoader] = useState(false)
|
||||||
@ -38,7 +37,8 @@ export const RoleController = memo(() => {
|
|||||||
setRoles(arrayOrDefault(roles))
|
setRoles(arrayOrDefault(roles))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
const permissions = await AdminPermissionService.getAll()
|
const permissions = await AdminPermissionService.getAll()
|
||||||
setPermissions(arrayOrDefault(permissions))
|
setPermissions(arrayOrDefault(permissions))
|
||||||
@ -47,19 +47,28 @@ export const RoleController = memo(() => {
|
|||||||
setShowLoader,
|
setShowLoader,
|
||||||
`Не удалось загрузить список ролей`,
|
`Не удалось загрузить список ролей`,
|
||||||
'Получение списка ролей'
|
'Получение списка ролей'
|
||||||
), [loadRoles])
|
)
|
||||||
|
}, [loadRoles])
|
||||||
|
|
||||||
const handlerProps = useMemo(() => ({
|
const tableHandlers = useMemo(() => {
|
||||||
|
const handlerProps = {
|
||||||
service: AdminUserRoleService,
|
service: AdminUserRoleService,
|
||||||
setLoader: setShowLoader,
|
setLoader: setShowLoader,
|
||||||
errorMsg: `Не удалось выполнить операцию`,
|
|
||||||
onComplete: async () => invokeWebApiWrapperAsync(
|
onComplete: async () => invokeWebApiWrapperAsync(
|
||||||
loadRoles,
|
loadRoles,
|
||||||
setShowLoader,
|
setShowLoader,
|
||||||
`Не удалось загрузить список ролей`,
|
`Не удалось загрузить список ролей`,
|
||||||
'Получение списка ролей',
|
'Получение списка ролей',
|
||||||
)
|
),
|
||||||
}), [loadRoles])
|
permission: 'AdminUserRole.edit'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
add: { ...handlerProps, action: 'insert', actionName: 'Добавление роли' },
|
||||||
|
edit: { ...handlerProps, action: 'update', actionName: 'Редактирование роли' },
|
||||||
|
delete: { ...handlerProps, action: 'delete', actionName: 'Удаление роли', permission: 'AdminUserRole.delete' },
|
||||||
|
}
|
||||||
|
}, [loadRoles])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -76,13 +85,17 @@ export const RoleController = memo(() => {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
loading={showLoader}
|
loading={showLoader}
|
||||||
dataSource={filteredRoles}
|
dataSource={filteredRoles}
|
||||||
onRowAdd={hasPermission('AdminUserRole.edit') && makeActionHandler('insert', handlerProps, null, 'Добавление роли')}
|
onRowAdd={tableHandlers.add}
|
||||||
onRowEdit={hasPermission('AdminUserRole.edit') && makeActionHandler('update', handlerProps, null, 'Редактирование роли')}
|
onRowEdit={tableHandlers.edit}
|
||||||
onRowDelete={hasPermission('AdminUserRole.delete') && makeActionHandler('delete', handlerProps, null, 'Удаление роли')}
|
onRowDelete={tableHandlers.delete}
|
||||||
tableName={'admin_role_controller'}
|
tableName={'admin_role_controller'}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default RoleController
|
export default wrapPrivateComponent(RoleController, {
|
||||||
|
requirements: ['AdminPermission.get', 'AdminUserRole.get'],
|
||||||
|
title: 'Роли',
|
||||||
|
route: 'role',
|
||||||
|
})
|
||||||
|
@ -8,8 +8,8 @@ import LoaderPortal from '@components/LoaderPortal'
|
|||||||
import { lables } from '@components/views/TelemetryView'
|
import { lables } from '@components/views/TelemetryView'
|
||||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||||
import TelemetrySelect from '@components/selectors/TelemetrySelect'
|
import TelemetrySelect from '@components/selectors/TelemetrySelect'
|
||||||
|
import { arrayOrDefault, wrapPrivateComponent } from '@utils'
|
||||||
import { AdminTelemetryService } from '@api'
|
import { AdminTelemetryService } from '@api'
|
||||||
import { arrayOrDefault } from '@utils'
|
|
||||||
|
|
||||||
const { Item } = Descriptions
|
const { Item } = Descriptions
|
||||||
|
|
||||||
@ -32,7 +32,7 @@ export const TelemetryInfo = memo(({ info, danger, ...other }) => (
|
|||||||
</Descriptions>
|
</Descriptions>
|
||||||
))
|
))
|
||||||
|
|
||||||
export const TelemetryMerger = memo(() => {
|
const TelemetryMerger = memo(() => {
|
||||||
const [primary, setPrimary] = useState(null)
|
const [primary, setPrimary] = useState(null)
|
||||||
const [secondary, setSecondary] = useState(null)
|
const [secondary, setSecondary] = useState(null)
|
||||||
const [telemetry, setTelemetry] = useState(null)
|
const [telemetry, setTelemetry] = useState(null)
|
||||||
@ -68,7 +68,9 @@ export const TelemetryMerger = memo(() => {
|
|||||||
'Объединение телеметрий',
|
'Объединение телеметрий',
|
||||||
), [updateTelemetry, secondary, primary])
|
), [updateTelemetry, secondary, primary])
|
||||||
|
|
||||||
useEffect(updateTelemetry, [updateTelemetry])
|
useEffect(() => {
|
||||||
|
updateTelemetry()
|
||||||
|
}, [updateTelemetry])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const query = new URLSearchParams(location.search)
|
const query = new URLSearchParams(location.search)
|
||||||
@ -132,4 +134,9 @@ export const TelemetryMerger = memo(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default TelemetryMerger
|
export default wrapPrivateComponent(TelemetryMerger, {
|
||||||
|
requirements: [],
|
||||||
|
title: 'Объединение',
|
||||||
|
route: 'merger',
|
||||||
|
key: 'merger',
|
||||||
|
})
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { PullRequestOutlined } from '@ant-design/icons'
|
import { PullRequestOutlined } from '@ant-design/icons'
|
||||||
import { Button, Input } from 'antd'
|
import { Button, Input } from 'antd'
|
||||||
|
|
||||||
@ -13,18 +14,17 @@ import {
|
|||||||
} from '@components/Table'
|
} from '@components/Table'
|
||||||
import Poprompt from '@components/selectors/Poprompt'
|
import Poprompt from '@components/selectors/Poprompt'
|
||||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||||
|
import { arrayOrDefault, wrapPrivateComponent } from '@utils'
|
||||||
import { AdminTelemetryService } from '@api'
|
import { AdminTelemetryService } from '@api'
|
||||||
import { arrayOrDefault } from '@utils'
|
|
||||||
import { useHistory } from 'react-router-dom'
|
|
||||||
|
|
||||||
export const TelemetryController = memo(() => {
|
const TelemetryController = memo(() => {
|
||||||
const [telemetryData, setTelemetryData] = useState([])
|
const [telemetryData, setTelemetryData] = useState([])
|
||||||
const [showLoader, setShowLoader] = useState(false)
|
const [showLoader, setShowLoader] = useState(false)
|
||||||
const [searchValue, setSearchValue] = useState('')
|
const [searchValue, setSearchValue] = useState('')
|
||||||
|
|
||||||
const history = useHistory()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const toMerger = useCallback((type, id) => () => history.push(`/admin/telemetry/merger/?${type}=${id}`), [history])
|
const toMerger = useCallback((type, id) => () => navigate(`/admin/telemetry/merger/?${type}=${id}`), [navigate])
|
||||||
|
|
||||||
const mergeRender = useCallback((value, record) => (
|
const mergeRender = useCallback((value, record) => (
|
||||||
<Poprompt
|
<Poprompt
|
||||||
@ -81,7 +81,8 @@ export const TelemetryController = memo(() => {
|
|||||||
].join(' ').toLowerCase().includes(searchValue.toLowerCase()))
|
].join(' ').toLowerCase().includes(searchValue.toLowerCase()))
|
||||||
), [telemetryData, searchValue])
|
), [telemetryData, searchValue])
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
const telemetryData = arrayOrDefault(await AdminTelemetryService.getAll())
|
const telemetryData = arrayOrDefault(await AdminTelemetryService.getAll())
|
||||||
setTelemetryData(telemetryData.map((telemetry) => ({
|
setTelemetryData(telemetryData.map((telemetry) => ({
|
||||||
@ -94,7 +95,8 @@ export const TelemetryController = memo(() => {
|
|||||||
setShowLoader,
|
setShowLoader,
|
||||||
`Не удалось загрузить список телеметрии скважин`,
|
`Не удалось загрузить список телеметрии скважин`,
|
||||||
'Полученик списка телеметрии скважин'
|
'Полученик списка телеметрии скважин'
|
||||||
), [])
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -118,4 +120,9 @@ export const TelemetryController = memo(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default TelemetryController
|
export default wrapPrivateComponent(TelemetryController, {
|
||||||
|
requirements: [],
|
||||||
|
title: 'Просмотр',
|
||||||
|
route: 'viewer',
|
||||||
|
key: 'viewer',
|
||||||
|
})
|
||||||
|
@ -1,37 +1,34 @@
|
|||||||
import { Layout } from 'antd'
|
import { Layout } from 'antd'
|
||||||
import { lazy, memo, Suspense, useContext, useMemo } from 'react'
|
import { memo, useMemo } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
|
||||||
import { RootPathContext } from '@asb/context'
|
import { RootPathContext, useRootPath } from '@asb/context'
|
||||||
import { PrivateMenu, PrivateSwitch } from '@components/Private'
|
import { PrivateMenu } from '@components/Private'
|
||||||
|
import { NoAccessComponent, wrapPrivateComponent } from '@utils'
|
||||||
|
|
||||||
import { SuspenseFallback } from '@pages/SuspenseFallback'
|
import TelemetryViewer from './TelemetryViewer'
|
||||||
|
import TelemetryMerger from './TelemetryMerger'
|
||||||
|
|
||||||
const TelemetryViewer = lazy(() => import('./TelemetryViewer'))
|
const Telemetry = memo(() => {
|
||||||
const TelemetryMerger = lazy(() => import('./TelemetryMerger'))
|
const root = useRootPath()
|
||||||
|
|
||||||
export const Telemetry = memo(() => {
|
|
||||||
const { tab } = useParams()
|
|
||||||
|
|
||||||
const root = useContext(RootPathContext)
|
|
||||||
const rootPath = useMemo(() => `${root}/telemetry`, [root])
|
const rootPath = useMemo(() => `${root}/telemetry`, [root])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RootPathContext.Provider value={rootPath}>
|
<RootPathContext.Provider value={rootPath}>
|
||||||
<Layout>
|
<Layout>
|
||||||
<PrivateMenu mode={'horizontal'} selectable={true} selectedKeys={[tab]}>
|
<PrivateMenu>
|
||||||
<PrivateMenu.Link key={'viewer'} title={'Просмотр'} />
|
<PrivateMenu.Link content={TelemetryViewer} />
|
||||||
<PrivateMenu.Link key={'merger'} title={'Объединение'} />
|
<PrivateMenu.Link content={TelemetryMerger} />
|
||||||
</PrivateMenu>
|
</PrivateMenu>
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
<Layout.Content className={'site-layout-background'}>
|
<Layout.Content className={'site-layout-background'}>
|
||||||
<Suspense fallback={<SuspenseFallback />}>
|
<Routes>
|
||||||
<PrivateSwitch elseRedirect={['viewer', 'merger']}>
|
<Route index element={<Navigate to={TelemetryViewer.route} replace />} />
|
||||||
<TelemetryViewer key={'viewer'} />
|
<Route path={'*'} element={<NoAccessComponent />} />
|
||||||
<TelemetryMerger key={'merger'} />
|
<Route path={TelemetryViewer.route} element={<TelemetryViewer />} />
|
||||||
</PrivateSwitch>
|
<Route path={TelemetryMerger.route} element={<TelemetryMerger />} />
|
||||||
</Suspense>
|
</Routes>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
@ -39,4 +36,9 @@ export const Telemetry = memo(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default Telemetry
|
export default wrapPrivateComponent(Telemetry, {
|
||||||
|
requirements: ['AdminTelemetry.get'],
|
||||||
|
title: 'Телеметрия',
|
||||||
|
key: 'telemetry',
|
||||||
|
route: 'telemetry/*',
|
||||||
|
})
|
||||||
|
@ -6,7 +6,6 @@ import { BehaviorSubject, debounceTime, distinctUntilChanged, filter, map } from
|
|||||||
import {
|
import {
|
||||||
EditableTable,
|
EditableTable,
|
||||||
makeSelectColumn,
|
makeSelectColumn,
|
||||||
makeActionHandler,
|
|
||||||
makeNumericSorter,
|
makeNumericSorter,
|
||||||
defaultPagination,
|
defaultPagination,
|
||||||
makeTextColumn
|
makeTextColumn
|
||||||
@ -17,15 +16,14 @@ import { ChangePassword } from '@components/ChangePassword'
|
|||||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
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/table'
|
import { makeTextOnFilter, makeTextFilters, makeArrayOnFilter } from '@utils/filters'
|
||||||
import { hasPermission } from '@utils/permissions'
|
import { arrayOrDefault, wrapPrivateComponent } from '@utils'
|
||||||
import { arrayOrDefault } from '@utils'
|
|
||||||
|
|
||||||
import RoleTag from './RoleTag'
|
import RoleTag from './RoleTag'
|
||||||
|
|
||||||
const SEARCH_TIMEOUT = 400
|
const SEARCH_TIMEOUT = 400
|
||||||
|
|
||||||
export const UserController = memo(() => {
|
const UserController = memo(() => {
|
||||||
const [users, setUsers] = useState([])
|
const [users, setUsers] = useState([])
|
||||||
const [filteredUsers, setFilteredUsers] = useState([])
|
const [filteredUsers, setFilteredUsers] = useState([])
|
||||||
const [searchValue, setSearchValue] = useState('')
|
const [searchValue, setSearchValue] = useState('')
|
||||||
@ -35,7 +33,8 @@ export const UserController = memo(() => {
|
|||||||
const [selectedUser, setSelectedUser] = useState(null)
|
const [selectedUser, setSelectedUser] = useState(null)
|
||||||
const [subject, setSubject] = useState(null)
|
const [subject, setSubject] = useState(null)
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
const filteredUsers = users.filter((user) => user && (!searchValue || [
|
const filteredUsers = users.filter((user) => user && (!searchValue || [
|
||||||
user.login ?? '',
|
user.login ?? '',
|
||||||
@ -51,7 +50,8 @@ export const UserController = memo(() => {
|
|||||||
},
|
},
|
||||||
setIsSearching,
|
setIsSearching,
|
||||||
`Не удалось произвести поиск пользователей`
|
`Не удалось произвести поиск пользователей`
|
||||||
), [users, searchValue])
|
)
|
||||||
|
}, [users, searchValue])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!subject) {
|
if (!subject) {
|
||||||
@ -91,7 +91,8 @@ export const UserController = memo(() => {
|
|||||||
'Получение списка пользователей'
|
'Получение списка пользователей'
|
||||||
), [])
|
), [])
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
const roles = arrayOrDefault(await AdminUserRoleService.getAll())
|
const roles = arrayOrDefault(await AdminUserRoleService.getAll())
|
||||||
const companies = arrayOrDefault(await AdminCompanyService.getAll()).map((company) => ({
|
const companies = arrayOrDefault(await AdminCompanyService.getAll()).map((company) => ({
|
||||||
@ -170,14 +171,23 @@ export const UserController = memo(() => {
|
|||||||
setShowLoader,
|
setShowLoader,
|
||||||
`Не удалось загрузить список компаний`,
|
`Не удалось загрузить список компаний`,
|
||||||
'Получение списка компаний'
|
'Получение списка компаний'
|
||||||
), [])
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handlerProps = useMemo(() => ({
|
const tableHandlers = useMemo(() => {
|
||||||
|
const handlerProps = {
|
||||||
service: AdminUserService,
|
service: AdminUserService,
|
||||||
setLoader: setShowLoader,
|
setLoader: setShowLoader,
|
||||||
errorMsg: `Не удалось выполнить операцию`,
|
|
||||||
onComplete: updateTable,
|
onComplete: updateTable,
|
||||||
}), [updateTable])
|
permission: 'AdminUser.edit'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
add: { ...handlerProps, action: 'insert', actionName: 'Добавление пользователя' },
|
||||||
|
edit: { ...handlerProps, action: 'update', actionName: 'Редактирование пользователя' },
|
||||||
|
delete: { ...handlerProps, action: 'delete', actionName: 'Удаление пользователя', permission: 'AdminUser.delete' },
|
||||||
|
}
|
||||||
|
}, [updateTable])
|
||||||
|
|
||||||
const onSearchTextChange = useCallback((e) => subject?.next(e?.target?.value), [subject])
|
const onSearchTextChange = useCallback((e) => subject?.next(e?.target?.value), [subject])
|
||||||
|
|
||||||
@ -196,9 +206,9 @@ export const UserController = memo(() => {
|
|||||||
bordered
|
bordered
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={filteredUsers}
|
dataSource={filteredUsers}
|
||||||
onRowAdd={hasPermission('AdminUser.edit') && makeActionHandler('insert', handlerProps, null, 'Добавление пользователя')}
|
onRowAdd={tableHandlers.add}
|
||||||
onRowEdit={hasPermission('AdminUser.edit') && makeActionHandler('update', handlerProps, null, 'Редактирование пользователя')}
|
onRowEdit={tableHandlers.edit}
|
||||||
onRowDelete={hasPermission('AdminUser.delete') && makeActionHandler('delete', handlerProps, null, 'Удаление пользователя')}
|
onRowDelete={tableHandlers.delete}
|
||||||
additionalButtons={additionalButtons}
|
additionalButtons={additionalButtons}
|
||||||
buttonsWidth={120}
|
buttonsWidth={120}
|
||||||
pagination={defaultPagination}
|
pagination={defaultPagination}
|
||||||
@ -215,4 +225,8 @@ export const UserController = memo(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default UserController
|
export default wrapPrivateComponent(UserController, {
|
||||||
|
requirements: ['AdminUser.get', 'AdminCompany.get', 'AdminUserRole.get'],
|
||||||
|
title: 'Пользователи',
|
||||||
|
route: 'user',
|
||||||
|
})
|
||||||
|
@ -3,8 +3,8 @@ import { Input } from 'antd'
|
|||||||
|
|
||||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||||
import { defaultPagination, makeColumn, makeDateSorter, makeStringSorter, Table } from '@components/Table'
|
import { defaultPagination, makeColumn, makeDateSorter, makeStringSorter, Table } from '@components/Table'
|
||||||
|
import { arrayOrDefault, formatDate, wrapPrivateComponent } from '@utils'
|
||||||
import { RequestTrackerService } from '@api'
|
import { RequestTrackerService } from '@api'
|
||||||
import { arrayOrDefault, formatDate } from '@utils'
|
|
||||||
|
|
||||||
const logRecordCount = 1000
|
const logRecordCount = 1000
|
||||||
|
|
||||||
@ -17,7 +17,7 @@ const columns = [
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
export const VisitLog = memo(() => {
|
const VisitLog = memo(() => {
|
||||||
const [logData, setLogData] = useState([])
|
const [logData, setLogData] = useState([])
|
||||||
const [showLoader, setShowLoader] = useState(false)
|
const [showLoader, setShowLoader] = useState(false)
|
||||||
const [searchValue, setSearchValue] = useState('')
|
const [searchValue, setSearchValue] = useState('')
|
||||||
@ -29,7 +29,8 @@ export const VisitLog = memo(() => {
|
|||||||
].join(' ').toLowerCase().includes(searchValue.toLowerCase()))
|
].join(' ').toLowerCase().includes(searchValue.toLowerCase()))
|
||||||
), [logData, searchValue])
|
), [logData, searchValue])
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
const logData = arrayOrDefault(await RequestTrackerService.getUsersStat(logRecordCount))
|
const logData = arrayOrDefault(await RequestTrackerService.getUsersStat(logRecordCount))
|
||||||
logData.forEach((log) => log.key = `${log.login}${log.ip}`)
|
logData.forEach((log) => log.key = `${log.login}${log.ip}`)
|
||||||
@ -38,7 +39,8 @@ export const VisitLog = memo(() => {
|
|||||||
setShowLoader,
|
setShowLoader,
|
||||||
`Не удалось загрузить список последних посещений пользователей`,
|
`Не удалось загрузить список последних посещений пользователей`,
|
||||||
'Получение списка последних посещений'
|
'Получение списка последних посещений'
|
||||||
), [])
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -62,4 +64,8 @@ export const VisitLog = memo(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default VisitLog
|
export default wrapPrivateComponent(VisitLog, {
|
||||||
|
requirements: ['RequestTracker.get'],
|
||||||
|
title: 'Журнал посещений',
|
||||||
|
route: 'visit_log',
|
||||||
|
})
|
||||||
|
@ -12,7 +12,6 @@ import {
|
|||||||
EditableTable,
|
EditableTable,
|
||||||
makeColumn,
|
makeColumn,
|
||||||
makeSelectColumn,
|
makeSelectColumn,
|
||||||
makeActionHandler,
|
|
||||||
makeStringSorter,
|
makeStringSorter,
|
||||||
makeNumericSorter,
|
makeNumericSorter,
|
||||||
makeTagColumn,
|
makeTagColumn,
|
||||||
@ -22,8 +21,7 @@ import {
|
|||||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||||
import { TelemetryView, CompanyView } from '@components/views'
|
import { TelemetryView, CompanyView } from '@components/views'
|
||||||
import TelemetrySelect from '@components/selectors/TelemetrySelect'
|
import TelemetrySelect from '@components/selectors/TelemetrySelect'
|
||||||
import { hasPermission } from '@utils/permissions'
|
import { arrayOrDefault, wrapPrivateComponent } from '@utils'
|
||||||
import { arrayOrDefault } from '@utils'
|
|
||||||
|
|
||||||
import { coordsFixed } from '../DepositController'
|
import { coordsFixed } from '../DepositController'
|
||||||
|
|
||||||
@ -37,7 +35,7 @@ const recordParser = (record) => ({
|
|||||||
idTelemetry: record.telemetry?.id,
|
idTelemetry: record.telemetry?.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const WellController = memo(() => {
|
const WellController = memo(() => {
|
||||||
const [columns, setColumns] = useState([])
|
const [columns, setColumns] = useState([])
|
||||||
const [wells, setWells] = useState([])
|
const [wells, setWells] = useState([])
|
||||||
const [showLoader, setShowLoader] = useState(false)
|
const [showLoader, setShowLoader] = useState(false)
|
||||||
@ -74,7 +72,8 @@ export const WellController = memo(() => {
|
|||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
const companies = arrayOrDefault(await AdminCompanyService.getAll())
|
const companies = arrayOrDefault(await AdminCompanyService.getAll())
|
||||||
const telemetry = arrayOrDefault(await AdminTelemetryService.getAll())
|
const telemetry = arrayOrDefault(await AdminTelemetryService.getAll())
|
||||||
@ -118,14 +117,23 @@ export const WellController = memo(() => {
|
|||||||
setShowLoader,
|
setShowLoader,
|
||||||
`Не удалось загрузить список кустов`,
|
`Не удалось загрузить список кустов`,
|
||||||
'Получение списка кустов'
|
'Получение списка кустов'
|
||||||
), [updateTable])
|
)
|
||||||
|
}, [updateTable])
|
||||||
|
|
||||||
const handlerProps = useMemo(() => ({
|
const tableHandlers = useMemo(() => {
|
||||||
|
const handlerProps = {
|
||||||
service: AdminWellService,
|
service: AdminWellService,
|
||||||
setLoader: setShowLoader,
|
setLoader: setShowLoader,
|
||||||
errorMsg: `Не удалось выполнить операцию`,
|
onComplete: updateTable,
|
||||||
onComplete: updateTable
|
permission: 'AdminWell.edit'
|
||||||
}), [updateTable])
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
add: { ...handlerProps, action: 'insert', actionName: 'Добавление скважины', recordParser },
|
||||||
|
edit: { ...handlerProps, action: 'update', actionName: 'Редактирование скважины', recordParser },
|
||||||
|
delete: { ...handlerProps, action: 'delete', actionName: 'Удаление скважины', permission: 'AdminWell.delete' },
|
||||||
|
}
|
||||||
|
}, [updateTable])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -143,9 +151,9 @@ export const WellController = memo(() => {
|
|||||||
loading={showLoader}
|
loading={showLoader}
|
||||||
dataSource={filteredWells}
|
dataSource={filteredWells}
|
||||||
pagination={defaultPagination}
|
pagination={defaultPagination}
|
||||||
onRowAdd={hasPermission('AdminWell.edit') && makeActionHandler('insert', handlerProps, recordParser, 'Добавление скважины')}
|
onRowAdd={tableHandlers.add}
|
||||||
onRowEdit={hasPermission('AdminWell.edit') && makeActionHandler('update', handlerProps, recordParser, 'Редактирование скважины')}
|
onRowEdit={tableHandlers.edit}
|
||||||
onRowDelete={hasPermission('AdminWell.delete') && makeActionHandler('delete', handlerProps, null, 'Удаление скважины')}
|
onRowDelete={tableHandlers.delete}
|
||||||
//additionalButtons={addititonalButtons}
|
//additionalButtons={addititonalButtons}
|
||||||
buttonsWidth={95}
|
buttonsWidth={95}
|
||||||
tableName={'admin_well_controller'}
|
tableName={'admin_well_controller'}
|
||||||
@ -154,4 +162,8 @@ export const WellController = memo(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default WellController
|
export default wrapPrivateComponent(WellController, {
|
||||||
|
requirements: ['AdminCluster.get', 'AdminCompany.get', 'AdminTelemetry.get', 'AdminWell.get'],
|
||||||
|
title: 'Скважины',
|
||||||
|
route: 'well',
|
||||||
|
})
|
||||||
|
@ -1,66 +1,69 @@
|
|||||||
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
import { memo, useMemo } from 'react'
|
||||||
import { Layout } from 'antd'
|
import { Layout } from 'antd'
|
||||||
import { lazy, memo, Suspense, useContext, useMemo } from 'react'
|
|
||||||
import { useParams } from 'react-router-dom'
|
|
||||||
|
|
||||||
import { RootPathContext } from '@asb/context'
|
import { RootPathContext, useRootPath } from '@asb/context'
|
||||||
import { PrivateMenu, PrivateSwitch } from '@components/Private'
|
import { AdminLayoutPortal } from '@components/Layout'
|
||||||
|
import { PrivateMenu } from '@components/Private'
|
||||||
|
import { NoAccessComponent, wrapPrivateComponent } from '@utils'
|
||||||
|
|
||||||
import { SuspenseFallback } from '@pages/SuspenseFallback'
|
import ClusterController from './ClusterController'
|
||||||
|
import CompanyController from './CompanyController'
|
||||||
|
import DepositController from './DepositController'
|
||||||
|
import UserController from './UserController'
|
||||||
|
import WellController from './WellController'
|
||||||
|
import RoleController from './RoleController'
|
||||||
|
import CompanyTypeController from './CompanyTypeController'
|
||||||
|
import PermissionController from './PermissionController'
|
||||||
|
import Telemetry from './Telemetry'
|
||||||
|
import VisitLog from './VisitLog'
|
||||||
|
|
||||||
const ClusterController = lazy(() => import( './ClusterController'))
|
const AdminPanel = memo(() => {
|
||||||
const CompanyController = lazy(() => import( './CompanyController'))
|
const root = useRootPath()
|
||||||
const DepositController = lazy(() => import( './DepositController'))
|
|
||||||
const UserController = lazy(() => import( './UserController'))
|
|
||||||
const WellController = lazy(() => import( './WellController'))
|
|
||||||
const RoleController = lazy(() => import( './RoleController'))
|
|
||||||
const CompanyTypeController = lazy(() => import('./CompanyTypeController'))
|
|
||||||
const PermissionController = lazy(() => import( './PermissionController'))
|
|
||||||
const TelemetrySection = lazy(() => import( './Telemetry'))
|
|
||||||
const VisitLog = lazy(() => import( './VisitLog'))
|
|
||||||
|
|
||||||
export const AdminPanel = memo(() => {
|
|
||||||
const { tab } = useParams()
|
|
||||||
|
|
||||||
const root = useContext(RootPathContext)
|
|
||||||
const rootPath = useMemo(() => `${root}/admin`, [root])
|
const rootPath = useMemo(() => `${root}/admin`, [root])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RootPathContext.Provider value={rootPath}>
|
<RootPathContext.Provider value={rootPath}>
|
||||||
<Layout>
|
<AdminLayoutPortal title={'Администраторская панель'}>
|
||||||
<PrivateMenu mode={'horizontal'} selectable={true} selectedKeys={[tab]}>
|
<PrivateMenu>
|
||||||
<PrivateMenu.Link key={'deposit' } title={'Месторождения' } />
|
<PrivateMenu.Link content={DepositController} />
|
||||||
<PrivateMenu.Link key={'cluster' } title={'Кусты' } />
|
<PrivateMenu.Link content={ClusterController} />
|
||||||
<PrivateMenu.Link key={'well' } title={'Скважины' } />
|
<PrivateMenu.Link content={WellController} />
|
||||||
<PrivateMenu.Link key={'user' } title={'Пользователи' } />
|
<PrivateMenu.Link content={UserController} />
|
||||||
<PrivateMenu.Link key={'company' } title={'Компании' } />
|
<PrivateMenu.Link content={CompanyController} />
|
||||||
<PrivateMenu.Link key={'company_type'} title={'Типы компаний' } />
|
<PrivateMenu.Link content={CompanyTypeController} />
|
||||||
<PrivateMenu.Link key={'role' } title={'Роли' } />
|
<PrivateMenu.Link content={RoleController} />
|
||||||
<PrivateMenu.Link key={'permission' } title={'Разрешения' } />
|
<PrivateMenu.Link content={PermissionController} />
|
||||||
<PrivateMenu.Link key={'telemetry' } title={'Телеметрия' } />
|
<PrivateMenu.Link content={Telemetry} />
|
||||||
<PrivateMenu.Link key={'visit_log' } title={'Журнал посещений'} />
|
<PrivateMenu.Link content={VisitLog} />
|
||||||
</PrivateMenu>
|
</PrivateMenu>
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
<Layout.Content className={'site-layout-background'}>
|
<Layout.Content className={'site-layout-background'}>
|
||||||
<Suspense fallback={<SuspenseFallback />}>
|
<Routes>
|
||||||
<PrivateSwitch elseRedirect={['deposit', 'cluster', 'well', 'user', 'company', 'company_type', 'role', 'permission', 'telemetry', 'visit_log']}>
|
<Route index element={<Navigate to={VisitLog.route} replace />} />
|
||||||
<DepositController key={'deposit'} />
|
<Route path={'*'} element={<NoAccessComponent />} />
|
||||||
<ClusterController key={'cluster'} />
|
<Route path={DepositController.route} element={<DepositController />} />
|
||||||
<WellController key={'well'} />
|
<Route path={ClusterController.route} element={<ClusterController />} />
|
||||||
<UserController key={'user'} />
|
<Route path={WellController.route} element={<WellController />} />
|
||||||
<CompanyController key={'company'} />
|
<Route path={UserController.route} element={<UserController />} />
|
||||||
<CompanyTypeController key={'company_type'} />
|
<Route path={CompanyController.route} element={<CompanyController />} />
|
||||||
<RoleController key={'role'} />
|
<Route path={CompanyTypeController.route} element={<CompanyTypeController />} />
|
||||||
<PermissionController key={'permission'} />
|
<Route path={RoleController.route} element={<RoleController />} />
|
||||||
<TelemetrySection key={'telemetry/:tab?'} />
|
<Route path={PermissionController.route} element={<PermissionController />} />
|
||||||
<VisitLog key={'visit_log'} />
|
<Route path={Telemetry.route} element={<Telemetry />} />
|
||||||
</PrivateSwitch>
|
<Route path={VisitLog.route} element={<VisitLog />} />
|
||||||
</Suspense>
|
</Routes>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</AdminLayoutPortal>
|
||||||
</RootPathContext.Provider>
|
</RootPathContext.Provider>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default AdminPanel
|
export default wrapPrivateComponent(AdminPanel, {
|
||||||
|
requirements: ['RequestTracker.get'],
|
||||||
|
title: 'Панель администратора',
|
||||||
|
route: 'admin/*',
|
||||||
|
key: 'admin',
|
||||||
|
})
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import { Table as RawTable, Typography } from 'antd'
|
import { Table as RawTable, Typography } from 'antd'
|
||||||
import { Fragment, memo, useCallback, useContext, useEffect, useState } from 'react'
|
import { Fragment, memo, useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { IdWellContext } from '@asb/context'
|
import { useIdWell } from '@asb/context'
|
||||||
import LoaderPortal from '@components/LoaderPortal'
|
import LoaderPortal from '@components/LoaderPortal'
|
||||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||||
import { WellSelector } from '@components/selectors/WellSelector'
|
import { WellSelector } from '@components/selectors/WellSelector'
|
||||||
import { makeGroupColumn, makeNumericColumn, makeNumericRender, makeTextColumn, Table } from '@components/Table'
|
import { makeGroupColumn, makeNumericColumn, makeNumericRender, makeTextColumn, Table } from '@components/Table'
|
||||||
import { OperationStatService, WellOperationService } from '@api'
|
import { OperationStatService, WellOperationService } from '@api'
|
||||||
import { arrayOrDefault } from '@utils'
|
import { arrayOrDefault, wrapPrivateComponent } from '@utils'
|
||||||
|
|
||||||
|
|
||||||
import '@styles/index.css'
|
import '@styles/index.css'
|
||||||
import '@styles/statistics.less'
|
import '@styles/statistics.less'
|
||||||
@ -64,7 +63,7 @@ const getWellData = async (wellsList) => {
|
|||||||
return wellData
|
return wellData
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Statistics = memo(() => {
|
const Statistics = memo(() => {
|
||||||
const [sectionTypes, setSectionTypes] = useState([])
|
const [sectionTypes, setSectionTypes] = useState([])
|
||||||
const [avgColumns, setAvgColumns] = useState(defaultColumns)
|
const [avgColumns, setAvgColumns] = useState(defaultColumns)
|
||||||
const [cmpColumns, setCmpColumns] = useState(defaultColumns)
|
const [cmpColumns, setCmpColumns] = useState(defaultColumns)
|
||||||
@ -77,7 +76,7 @@ export const Statistics = memo(() => {
|
|||||||
const [cmpData, setCmpData] = useState([])
|
const [cmpData, setCmpData] = useState([])
|
||||||
const [avgRow, setAvgRow] = useState({})
|
const [avgRow, setAvgRow] = useState({})
|
||||||
|
|
||||||
const idWell = useContext(IdWellContext)
|
const idWell = useIdWell()
|
||||||
|
|
||||||
const cmpSpeedRender = useCallback((key) => (section) => {
|
const cmpSpeedRender = useCallback((key) => (section) => {
|
||||||
let spanClass = ''
|
let spanClass = ''
|
||||||
@ -97,7 +96,8 @@ export const Statistics = memo(() => {
|
|||||||
)
|
)
|
||||||
}, [avgRow])
|
}, [avgRow])
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
const types = await WellOperationService.getSectionTypes(idWell)
|
const types = await WellOperationService.getSectionTypes(idWell)
|
||||||
setSectionTypes(Object.entries(types))
|
setSectionTypes(Object.entries(types))
|
||||||
@ -105,9 +105,11 @@ export const Statistics = memo(() => {
|
|||||||
setIsPageLoading,
|
setIsPageLoading,
|
||||||
`Не удалось получить типы секции`,
|
`Не удалось получить типы секции`,
|
||||||
`Получение списка возможных секций`,
|
`Получение списка возможных секций`,
|
||||||
), [idWell])
|
)
|
||||||
|
}, [idWell])
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
const filteredSections = avgData?.length > 0 ? sectionTypes.filter(([id, _]) => avgData.some((row) => `section_${id}` in row)) : sectionTypes
|
const filteredSections = avgData?.length > 0 ? sectionTypes.filter(([id, _]) => avgData.some((row) => `section_${id}` in row)) : sectionTypes
|
||||||
|
|
||||||
@ -124,9 +126,11 @@ export const Statistics = memo(() => {
|
|||||||
},
|
},
|
||||||
setIsPageLoading,
|
setIsPageLoading,
|
||||||
'Не удалось установить необходимые столбцы'
|
'Не удалось установить необходимые столбцы'
|
||||||
), [sectionTypes, avgData, cmpSpeedRender])
|
)
|
||||||
|
}, [sectionTypes, avgData, cmpSpeedRender])
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
const avgData = await getWellData(avgWells)
|
const avgData = await getWellData(avgWells)
|
||||||
setAvgData(avgData)
|
setAvgData(avgData)
|
||||||
@ -148,16 +152,19 @@ export const Statistics = memo(() => {
|
|||||||
},
|
},
|
||||||
setIsAvgTableLoading,
|
setIsAvgTableLoading,
|
||||||
'Не удалось загрузить данные для расчёта средних значений',
|
'Не удалось загрузить данные для расчёта средних значений',
|
||||||
), [avgWells])
|
)
|
||||||
|
}, [avgWells])
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
const cmpData = await getWellData(cmpWells)
|
const cmpData = await getWellData(cmpWells)
|
||||||
setCmpData(cmpData)
|
setCmpData(cmpData)
|
||||||
},
|
},
|
||||||
setIsCmpTableLoading,
|
setIsCmpTableLoading,
|
||||||
'Не удалось получить скважины для сравнения',
|
'Не удалось получить скважины для сравнения',
|
||||||
), [cmpWells])
|
)
|
||||||
|
}, [cmpWells])
|
||||||
|
|
||||||
const getStatisticsAvgSummary = useCallback((data) => (
|
const getStatisticsAvgSummary = useCallback((data) => (
|
||||||
<Summary fixed={'bottom'}>
|
<Summary fixed={'bottom'}>
|
||||||
@ -234,4 +241,8 @@ export const Statistics = memo(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default Statistics
|
export default wrapPrivateComponent(Statistics, {
|
||||||
|
requirements: [],
|
||||||
|
title: 'Оценка по ЦБ',
|
||||||
|
route: 'statistics',
|
||||||
|
})
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { memo, useCallback, useContext, useEffect, useState } from 'react'
|
import { memo, useCallback, useEffect, useState } from 'react'
|
||||||
import { Button, Modal, Popconfirm } from 'antd'
|
import { Button, Modal, Popconfirm } from 'antd'
|
||||||
|
|
||||||
import { IdWellContext } from '@asb/context'
|
import { useIdWell } from '@asb/context'
|
||||||
import { Table } from '@components/Table'
|
import { Table } from '@components/Table'
|
||||||
import LoaderPortal from '@components/LoaderPortal'
|
import LoaderPortal from '@components/LoaderPortal'
|
||||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||||
@ -15,13 +15,11 @@ export const NewParamsTable = memo(({ selectedWellsKeys }) => {
|
|||||||
const [showParamsLoader, setShowParamsLoader] = useState(false)
|
const [showParamsLoader, setShowParamsLoader] = useState(false)
|
||||||
const [isParamsModalVisible, setIsParamsModalVisible] = useState(false)
|
const [isParamsModalVisible, setIsParamsModalVisible] = useState(false)
|
||||||
|
|
||||||
const idWell = useContext(IdWellContext)
|
const idWell = useIdWell()
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
async () => setParamsColumns(await getColumns(idWell))
|
invokeWebApiWrapperAsync(async () => setParamsColumns(await getColumns(idWell)))
|
||||||
), [idWell])
|
}, [idWell])
|
||||||
|
|
||||||
useEffect(() => console.log(paramsColumns), [paramsColumns])
|
|
||||||
|
|
||||||
const onParamButtonClick = useCallback(() => invokeWebApiWrapperAsync(
|
const onParamButtonClick = useCallback(() => invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
@ -61,7 +59,10 @@ export const NewParamsTable = memo(({ selectedWellsKeys }) => {
|
|||||||
width={1700}
|
width={1700}
|
||||||
footer={(
|
footer={(
|
||||||
<Popconfirm title={'Заменить существующие режимы выбранными?'} onConfirm={onParamsAddClick}>
|
<Popconfirm title={'Заменить существующие режимы выбранными?'} onConfirm={onParamsAddClick}>
|
||||||
<Button size={'large'} disabled={params.length <= 0}>Сохранить</Button>
|
<Button
|
||||||
|
size={'large'}
|
||||||
|
disabled={params.length <= 0}
|
||||||
|
>Сохранить</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -9,14 +9,15 @@ import LoaderPortal from '@components/LoaderPortal'
|
|||||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||||
import { makeTextColumn, makeNumericColumnPlanFact, makeNumericColumn } from '@components/Table'
|
import { makeTextColumn, makeNumericColumnPlanFact, makeNumericColumn } from '@components/Table'
|
||||||
import { WellCompositeService } from '@api'
|
import { WellCompositeService } from '@api'
|
||||||
import { hasPermission } from '@utils/permissions'
|
|
||||||
import {
|
import {
|
||||||
|
hasPermission,
|
||||||
|
wrapPrivateComponent,
|
||||||
calcAndUpdateStatsBySections,
|
calcAndUpdateStatsBySections,
|
||||||
makeFilterMinMaxFunction,
|
makeFilterMinMaxFunction,
|
||||||
getOperations
|
getOperations
|
||||||
} from '@utils/functions'
|
} from '@utils'
|
||||||
|
|
||||||
import { Tvd } from '@pages/WellOperations/Tvd'
|
import Tvd from '@pages/WellOperations/Tvd'
|
||||||
import WellOperationsTable from '@pages/Cluster/WellOperationsTable'
|
import WellOperationsTable from '@pages/Cluster/WellOperationsTable'
|
||||||
import NewParamsTable from './NewParamsTable'
|
import NewParamsTable from './NewParamsTable'
|
||||||
|
|
||||||
@ -30,7 +31,7 @@ const sortBySectionId = (a, b) => a?.sectionId - b?.sectionId
|
|||||||
const filtersSectionsType = []
|
const filtersSectionsType = []
|
||||||
const DAY_IN_MS = 1000 * 60 * 60 * 24
|
const DAY_IN_MS = 1000 * 60 * 60 * 24
|
||||||
|
|
||||||
export const WellCompositeSections = memo(({ statsWells, selectedSections }) => {
|
const WellCompositeSections = memo(({ statsWells, selectedSections }) => {
|
||||||
const [selectedWells, setSelectedWells] = useState([])
|
const [selectedWells, setSelectedWells] = useState([])
|
||||||
const [wellOperations, setWellOperations] = useState([])
|
const [wellOperations, setWellOperations] = useState([])
|
||||||
const [selectedWellsKeys, setSelectedWellsKeys] = useState([])
|
const [selectedWellsKeys, setSelectedWellsKeys] = useState([])
|
||||||
@ -92,8 +93,7 @@ export const WellCompositeSections = memo(({ statsWells, selectedSections }) =>
|
|||||||
'sectionBhaUpSpeedFact',
|
'sectionBhaUpSpeedFact',
|
||||||
'sectionCasingDownSpeedPlan',
|
'sectionCasingDownSpeedPlan',
|
||||||
'sectionCasingDownSpeedFact',
|
'sectionCasingDownSpeedFact',
|
||||||
'nonProductiveTimePlan',
|
'nonProductiveHours',
|
||||||
'nonProductiveTimeFact',
|
|
||||||
])
|
])
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
@ -239,4 +239,8 @@ export const WellCompositeSections = memo(({ statsWells, selectedSections }) =>
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default WellCompositeSections
|
export default wrapPrivateComponent(WellCompositeSections, {
|
||||||
|
requirements: ['WellComposite.get'],
|
||||||
|
title: 'Статистика по секциям',
|
||||||
|
route: 'sections',
|
||||||
|
})
|
||||||
|
@ -1,23 +1,31 @@
|
|||||||
import { useState, useEffect, memo, useContext } from 'react'
|
import { useState, useEffect, memo, useMemo } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { Col, Layout, Row } from 'antd'
|
import { Col, Layout, Row } from 'antd'
|
||||||
|
|
||||||
import { IdWellContext } from '@asb/context'
|
import { useIdWell, useRootPath } from '@asb/context'
|
||||||
|
import { PrivateMenu } from '@components/Private'
|
||||||
import LoaderPortal from '@components/LoaderPortal'
|
import LoaderPortal from '@components/LoaderPortal'
|
||||||
import WellSelector from '@components/selectors/WellSelector'
|
import WellSelector from '@components/selectors/WellSelector'
|
||||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||||
import { PrivateMenu, PrivateSwitch } from '@components/Private'
|
import { arrayOrDefault, NoAccessComponent, wrapPrivateComponent } from '@utils'
|
||||||
import { arrayOrDefault } from '@utils'
|
|
||||||
import { OperationStatService, WellCompositeService } from '@api'
|
import { OperationStatService, WellCompositeService } from '@api'
|
||||||
|
|
||||||
import ClusterWells from '@pages/Cluster/ClusterWells'
|
import ClusterWells from '@pages/Cluster/ClusterWells'
|
||||||
import { WellCompositeSections } from './WellCompositeSections'
|
import WellCompositeSections from './WellCompositeSections'
|
||||||
|
|
||||||
const { Content } = Layout
|
const { Content } = Layout
|
||||||
|
|
||||||
export const WellCompositeEditor = memo(({ rootPath }) => {
|
const properties = {
|
||||||
const { tab } = useParams()
|
requirements: ['OperationStat.get', 'WellComposite.get'],
|
||||||
const idWell = useContext(IdWellContext)
|
title: 'Композитная скважина',
|
||||||
|
route: 'composite/*',
|
||||||
|
key: 'composite',
|
||||||
|
}
|
||||||
|
|
||||||
|
const WellCompositeEditor = memo(() => {
|
||||||
|
const idWell = useIdWell()
|
||||||
|
const root = useRootPath()
|
||||||
|
const rootPath = useMemo(() => `${root}/${properties.key}`, [root])
|
||||||
|
|
||||||
const [statsWells, setStatsWells] = useState([])
|
const [statsWells, setStatsWells] = useState([])
|
||||||
const [showLoader, setShowLoader] = useState(false)
|
const [showLoader, setShowLoader] = useState(false)
|
||||||
@ -25,19 +33,21 @@ export const WellCompositeEditor = memo(({ rootPath }) => {
|
|||||||
const [selectedIdWells, setSelectedIdWells] = useState([])
|
const [selectedIdWells, setSelectedIdWells] = useState([])
|
||||||
const [selectedSections, setSelectedSections] = useState([])
|
const [selectedSections, setSelectedSections] = useState([])
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
const selected = await WellCompositeService.get(idWell)
|
setSelectedSections(arrayOrDefault(await WellCompositeService.get(idWell)))
|
||||||
setSelectedSections(arrayOrDefault(selected))
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
setSelectedSections([])
|
setSelectedSections([])
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setShowLoader,
|
setShowLoader,
|
||||||
'Не удалось загрузить список скважин',
|
'Не удалось загрузить список скважин',
|
||||||
'Получение списка скважин'
|
'Получение списка скважин'
|
||||||
), [idWell])
|
)
|
||||||
|
}, [idWell])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wellIds = selectedSections.map((value) => value.idWellSrc)
|
const wellIds = selectedSections.map((value) => value.idWellSrc)
|
||||||
@ -45,7 +55,8 @@ export const WellCompositeEditor = memo(({ rootPath }) => {
|
|||||||
setSelectedIdWells(wellIds)
|
setSelectedIdWells(wellIds)
|
||||||
}, [selectedSections])
|
}, [selectedSections])
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
const stats = arrayOrDefault(await OperationStatService.getWellsStat(selectedIdWells))
|
const stats = arrayOrDefault(await OperationStatService.getWellsStat(selectedIdWells))
|
||||||
setStatsWells(stats)
|
setStatsWells(stats)
|
||||||
@ -53,7 +64,8 @@ export const WellCompositeEditor = memo(({ rootPath }) => {
|
|||||||
setShowTabLoader,
|
setShowTabLoader,
|
||||||
'Не удалось загрузить статистику по скважинам/секциям',
|
'Не удалось загрузить статистику по скважинам/секциям',
|
||||||
'Получение статистики по скважинам/секциям'
|
'Получение статистики по скважинам/секциям'
|
||||||
), [selectedIdWells])
|
)
|
||||||
|
}, [selectedIdWells])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoaderPortal show={showLoader}>
|
<LoaderPortal show={showLoader}>
|
||||||
@ -66,19 +78,22 @@ export const WellCompositeEditor = memo(({ rootPath }) => {
|
|||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={6}>
|
<Col span={6}>
|
||||||
<PrivateMenu root={rootPath} mode={'horizontal'} selectable={true} className={'well_menu'} selectedKeys={[tab]}>
|
<PrivateMenu root={rootPath} className={'well_menu'}>
|
||||||
<PrivateMenu.Link key={'wells'} title={'Статистика по скважинам'} />
|
<PrivateMenu.Link content={ClusterWells} />
|
||||||
<PrivateMenu.Link key={'sections'} title={'Статистика по секциям'} />
|
<PrivateMenu.Link content={WellCompositeSections} />
|
||||||
</PrivateMenu>
|
</PrivateMenu>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Content className={'site-layout-background'}>
|
<Content className={'site-layout-background'}>
|
||||||
<LoaderPortal show={showTabLoader}>
|
<LoaderPortal show={showTabLoader}>
|
||||||
<PrivateSwitch root={rootPath} elseRedirect={['wells', 'sections']}>
|
<Routes>
|
||||||
<ClusterWells key={'wells'} statsWells={statsWells} />
|
<Route index element={<Navigate to={ClusterWells.route} replace/>} />
|
||||||
<WellCompositeSections key={'sections'} statsWells={statsWells} selectedSections={selectedSections} />
|
<Route path={'*'} element={<NoAccessComponent />} />
|
||||||
</PrivateSwitch>
|
|
||||||
|
<Route path={ClusterWells.route} element={<ClusterWells statsWells={statsWells} />} />
|
||||||
|
<Route path={WellCompositeSections.route} element={<WellCompositeSections statsWells={statsWells} selectedSections={selectedSections} />} />
|
||||||
|
</Routes>
|
||||||
</LoaderPortal>
|
</LoaderPortal>
|
||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
@ -86,4 +101,4 @@ export const WellCompositeEditor = memo(({ rootPath }) => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default WellCompositeEditor
|
export default wrapPrivateComponent(WellCompositeEditor, properties)
|
||||||
|
@ -1,31 +1,34 @@
|
|||||||
import { memo, useContext, useMemo } from 'react'
|
import { memo, useMemo } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { Layout } from 'antd'
|
import { Layout } from 'antd'
|
||||||
|
|
||||||
import { RootPathContext } from '@asb/context'
|
import { RootPathContext, useRootPath } from '@asb/context'
|
||||||
import { PrivateMenu, PrivateSwitch } from '@components/Private'
|
import { PrivateMenu } from '@components/Private'
|
||||||
|
import { NoAccessComponent, wrapPrivateComponent } from '@utils'
|
||||||
|
|
||||||
import Statistics from './Statistics'
|
import Statistics from './Statistics'
|
||||||
import WellCompositeEditor from './WellCompositeEditor'
|
import WellCompositeEditor from './WellCompositeEditor'
|
||||||
|
|
||||||
export const Analytics = memo(() => {
|
const Analytics = memo(() => {
|
||||||
const { tab } = useParams()
|
const root = useRootPath()
|
||||||
const root = useContext(RootPathContext)
|
|
||||||
const rootPath = useMemo(() => `${root}/analytics`, [root])
|
const rootPath = useMemo(() => `${root}/analytics`, [root])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RootPathContext.Provider value={rootPath}>
|
<RootPathContext.Provider value={rootPath}>
|
||||||
<Layout>
|
<Layout>
|
||||||
<PrivateMenu mode={'horizontal'} selectable={true} className={'well_menu'} selectedKeys={[tab]}>
|
<PrivateMenu className={'well_menu'}>
|
||||||
<PrivateMenu.Link key={'composite'} title={'Композитная скважина'} />
|
<PrivateMenu.Link content={WellCompositeEditor} />
|
||||||
<PrivateMenu.Link key={'statistics'} title={'Оценка по ЦБ'} />
|
<PrivateMenu.Link key={'statistics'} title={'Оценка по ЦБ'} content={Statistics} />
|
||||||
</PrivateMenu>
|
</PrivateMenu>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Layout.Content>
|
<Layout.Content className={'site-layout-background'}>
|
||||||
<PrivateSwitch elseRedirect={'composite'}>
|
<Routes>
|
||||||
<WellCompositeEditor key={'composite/:tab?'} rootPath={`${rootPath}/composite`} />
|
<Route index element={<Navigate to={WellCompositeEditor.getKey()} replace />} />
|
||||||
<Statistics key={'statistics'} />
|
<Route path={'*'} element={<NoAccessComponent />} />
|
||||||
</PrivateSwitch>
|
|
||||||
|
<Route path={WellCompositeEditor.route} element={<WellCompositeEditor />} />
|
||||||
|
<Route path={Statistics.route} element={<Statistics />} />
|
||||||
|
</Routes>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
@ -33,4 +36,9 @@ export const Analytics = memo(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default Analytics
|
export default wrapPrivateComponent(Analytics, {
|
||||||
|
requirements: [],
|
||||||
|
title: 'Аналитика',
|
||||||
|
route: 'analytics/*',
|
||||||
|
key: 'analytics',
|
||||||
|
})
|
||||||
|
@ -20,11 +20,12 @@ import { invokeWebApiWrapperAsync } from '@components/factory'
|
|||||||
import {
|
import {
|
||||||
getOperations,
|
getOperations,
|
||||||
calcAndUpdateStatsBySections,
|
calcAndUpdateStatsBySections,
|
||||||
makeFilterMinMaxFunction
|
isRawDate,
|
||||||
} from '@utils/functions'
|
makeFilterMinMaxFunction,
|
||||||
import { isRawDate } from '@utils'
|
wrapPrivateComponent
|
||||||
|
} from '@utils'
|
||||||
|
|
||||||
import { Tvd } from '@pages/WellOperations/Tvd'
|
import Tvd from '@pages/WellOperations/Tvd'
|
||||||
import WellOperationsTable from './WellOperationsTable'
|
import WellOperationsTable from './WellOperationsTable'
|
||||||
|
|
||||||
const filtersMinMax = [
|
const filtersMinMax = [
|
||||||
@ -39,7 +40,7 @@ const ONLINE_DEADTIME = 600_000
|
|||||||
const getDate = (str) => isRawDate(str) ? new Date(str).toLocaleString() : '-'
|
const getDate = (str) => isRawDate(str) ? new Date(str).toLocaleString() : '-'
|
||||||
const numericRender = makeNumericRender(1)
|
const numericRender = makeNumericRender(1)
|
||||||
|
|
||||||
export const ClusterWells = memo(({ statsWells }) => {
|
const ClusterWells = memo(({ statsWells }) => {
|
||||||
const [selectedWellId, setSelectedWellId] = useState(0)
|
const [selectedWellId, setSelectedWellId] = useState(0)
|
||||||
const [isTVDModalVisible, setIsTVDModalVisible] = useState(false)
|
const [isTVDModalVisible, setIsTVDModalVisible] = useState(false)
|
||||||
const [isOpsModalVisible, setIsOpsModalVisible] = useState(false)
|
const [isOpsModalVisible, setIsOpsModalVisible] = useState(false)
|
||||||
@ -187,4 +188,8 @@ export const ClusterWells = memo(({ statsWells }) => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default ClusterWells
|
export default wrapPrivateComponent(ClusterWells, {
|
||||||
|
requirements: [],
|
||||||
|
title: 'Статистика по скважинам',
|
||||||
|
route: 'wells',
|
||||||
|
})
|
||||||
|
@ -1,19 +1,21 @@
|
|||||||
import { useState, useEffect, memo } from 'react'
|
import { useState, useEffect, memo } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
|
||||||
|
|
||||||
import { arrayOrDefault } from '@utils'
|
import { LayoutPortal } from '@components/Layout'
|
||||||
import { OperationStatService } from '@api'
|
|
||||||
import LoaderPortal from '@components/LoaderPortal'
|
import LoaderPortal from '@components/LoaderPortal'
|
||||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||||
|
import { arrayOrDefault, wrapPrivateComponent } from '@utils'
|
||||||
|
import { OperationStatService } from '@api'
|
||||||
|
|
||||||
import ClusterWells from './ClusterWells'
|
import ClusterWells from './ClusterWells'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
export const Cluster = memo(() => {
|
const Cluster = memo(() => {
|
||||||
const { idCluster } = useParams()
|
const { idCluster } = useParams()
|
||||||
const [data, setData] = useState([])
|
const [data, setData] = useState([])
|
||||||
const [showLoader, setShowLoader] = useState(false)
|
const [showLoader, setShowLoader] = useState(false)
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
const clusterData = await OperationStatService.getStatCluster(idCluster)
|
const clusterData = await OperationStatService.getStatCluster(idCluster)
|
||||||
setData(arrayOrDefault(clusterData?.statsWells))
|
setData(arrayOrDefault(clusterData?.statsWells))
|
||||||
@ -21,13 +23,21 @@ export const Cluster = memo(() => {
|
|||||||
setShowLoader,
|
setShowLoader,
|
||||||
`Не удалось загрузить данные по кусту "${idCluster}"`,
|
`Не удалось загрузить данные по кусту "${idCluster}"`,
|
||||||
'Получение данных по кусту'
|
'Получение данных по кусту'
|
||||||
), [idCluster])
|
)
|
||||||
|
}, [idCluster])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<LayoutPortal title={'Анализ скважин куста'}>
|
||||||
<LoaderPortal show={showLoader}>
|
<LoaderPortal show={showLoader}>
|
||||||
<ClusterWells statsWells={data} />
|
<ClusterWells statsWells={data} />
|
||||||
</LoaderPortal>
|
</LoaderPortal>
|
||||||
|
</LayoutPortal>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default Cluster
|
export default wrapPrivateComponent(Cluster, {
|
||||||
|
requirements: ['OperationStat.get'],
|
||||||
|
title: 'Анализ скважин куста',
|
||||||
|
route: 'cluster/:idCluster/*',
|
||||||
|
key: 'cluster',
|
||||||
|
})
|
||||||
|
@ -1,17 +1,20 @@
|
|||||||
import { Map, Overlay } from 'pigeon-maps'
|
import { Map, Overlay } from 'pigeon-maps'
|
||||||
import { Link, useLocation } from 'react-router-dom'
|
|
||||||
import { useState, useEffect, memo } from 'react'
|
import { useState, useEffect, memo } from 'react'
|
||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import { ClusterService } from '@api'
|
|
||||||
import { arrayOrDefault } from '@utils'
|
|
||||||
import { PointerIcon } from '@components/icons'
|
import { PointerIcon } from '@components/icons'
|
||||||
|
import { LayoutPortal } from '@components/Layout'
|
||||||
import LoaderPortal from '@components/LoaderPortal'
|
import LoaderPortal from '@components/LoaderPortal'
|
||||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||||
|
import { arrayOrDefault, limitValue, wrapPrivateComponent } from '@utils'
|
||||||
|
import { ClusterService } from '@api'
|
||||||
|
|
||||||
import '@styles/index.css'
|
import '@styles/index.css'
|
||||||
|
|
||||||
const defaultViewParams = { center: [60.81226, 70.0562], zoom: 5 }
|
const defaultViewParams = { center: [60.81226, 70.0562], zoom: 5 }
|
||||||
|
|
||||||
|
const zoomLimit = limitValue(5, 15)
|
||||||
|
|
||||||
const calcViewParams = (clusters) => {
|
const calcViewParams = (clusters) => {
|
||||||
if ((clusters?.length ?? 0) <= 0)
|
if ((clusters?.length ?? 0) <= 0)
|
||||||
return defaultViewParams
|
return defaultViewParams
|
||||||
@ -33,19 +36,20 @@ const calcViewParams = (clusters) => {
|
|||||||
// zoom min = 1 (mega far)
|
// zoom min = 1 (mega far)
|
||||||
// 4 - full Russia (161.6 deg)
|
// 4 - full Russia (161.6 deg)
|
||||||
// 13.5 - Khanty-Mansiysk
|
// 13.5 - Khanty-Mansiysk
|
||||||
const zoom = Math.min(Math.max(5, 5 + 5 / (maxDeg + 0.5)), 15)
|
const zoom = zoomLimit(5 + 5 / (maxDeg + 0.5))
|
||||||
|
|
||||||
return { center, zoom }
|
return { center, zoom }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Deposit = memo(() => {
|
const Deposit = memo(() => {
|
||||||
const [clustersData, setClustersData] = useState([])
|
const [clustersData, setClustersData] = useState([])
|
||||||
const [showLoader, setShowLoader] = useState(false)
|
const [showLoader, setShowLoader] = useState(false)
|
||||||
const [viewParams, setViewParams] = useState(defaultViewParams)
|
const [viewParams, setViewParams] = useState(defaultViewParams)
|
||||||
|
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await ClusterService.getClusters()
|
const data = await ClusterService.getClusters()
|
||||||
setClustersData(arrayOrDefault(data))
|
setClustersData(arrayOrDefault(data))
|
||||||
@ -54,9 +58,11 @@ export const Deposit = memo(() => {
|
|||||||
setShowLoader,
|
setShowLoader,
|
||||||
`Не удалось загрузить список кустов`,
|
`Не удалось загрузить список кустов`,
|
||||||
'Получить список кустов'
|
'Получить список кустов'
|
||||||
), [])
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<LayoutPortal noSheet title={'Месторождение'}>
|
||||||
<LoaderPortal show={showLoader}>
|
<LoaderPortal show={showLoader}>
|
||||||
<div className={'h-100vh'}>
|
<div className={'h-100vh'}>
|
||||||
<Map {...viewParams}>
|
<Map {...viewParams}>
|
||||||
@ -75,7 +81,13 @@ export const Deposit = memo(() => {
|
|||||||
</Map>
|
</Map>
|
||||||
</div>
|
</div>
|
||||||
</LoaderPortal>
|
</LoaderPortal>
|
||||||
|
</LayoutPortal>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default Deposit
|
export default wrapPrivateComponent(Deposit, {
|
||||||
|
requirements: ['Cluster.get'],
|
||||||
|
title: 'Месторождение',
|
||||||
|
route: 'deposit/*',
|
||||||
|
key: 'deposit',
|
||||||
|
})
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback, useContext } from 'react'
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
import { DatePicker, Button, Input } from 'antd'
|
import { DatePicker, Input } from 'antd'
|
||||||
|
|
||||||
import { IdWellContext } from '@asb/context'
|
import { useIdWell } from '@asb/context'
|
||||||
|
import DownloadLink from '@components/DownloadLink'
|
||||||
import LoaderPortal from '@components/LoaderPortal'
|
import LoaderPortal from '@components/LoaderPortal'
|
||||||
import { UploadForm } from '@components/UploadForm'
|
import { UploadForm } from '@components/UploadForm'
|
||||||
import { CompanyView, UserView } from '@components/views'
|
import { CompanyView, UserView } from '@components/views'
|
||||||
import { invokeWebApiWrapperAsync, downloadFile, formatBytes } from '@components/factory'
|
import { invokeWebApiWrapperAsync, formatBytes } from '@components/factory'
|
||||||
import { EditableTable, makeColumn, makeDateColumn, makeNumericColumn, makePaginationObject } from '@components/Table'
|
import { EditableTable, makeColumn, makeDateColumn, makeNumericColumn, makePaginationObject } from '@components/Table'
|
||||||
import { hasPermission } from '@utils/permissions'
|
import { unique } from '@utils/filters'
|
||||||
|
import { hasPermission } from '@utils'
|
||||||
import { FileService } from '@api'
|
import { FileService } from '@api'
|
||||||
|
|
||||||
const pageSize = 12
|
const pageSize = 12
|
||||||
@ -20,9 +22,7 @@ const columns = [
|
|||||||
key: 'document',
|
key: 'document',
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
render: (name, row) => (
|
render: (name, row) => (
|
||||||
<Button type={'link'} onClick={() => downloadFile(row)} download={name}>
|
<DownloadLink file={row} name={name} />
|
||||||
{name}
|
|
||||||
</Button>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
makeDateColumn('Дата загрузки', 'uploadDate'),
|
makeDateColumn('Дата загрузки', 'uploadDate'),
|
||||||
@ -40,14 +40,14 @@ export const DocumentsTemplate = ({ idCategory, idWell: wellId, mimeTypes, heade
|
|||||||
const [files, setFiles] = useState([])
|
const [files, setFiles] = useState([])
|
||||||
const [showLoader, setShowLoader] = useState(false)
|
const [showLoader, setShowLoader] = useState(false)
|
||||||
|
|
||||||
const idwellContext = useContext(IdWellContext)
|
const idwellContext = useIdWell()
|
||||||
const idWell = useMemo(() => wellId ?? idwellContext, [wellId, idwellContext])
|
const idWell = useMemo(() => wellId ?? idwellContext, [wellId, idwellContext])
|
||||||
|
|
||||||
const uploadUrl = useMemo(() => `/api/well/${idWell}/files/?idCategory=${idCategory}`, [idWell, idCategory])
|
const uploadUrl = useMemo(() => `/api/well/${idWell}/files/?idCategory=${idCategory}`, [idWell, idCategory])
|
||||||
|
|
||||||
const mergedColumns = useMemo(() => [...columns, ...(customColumns ?? [])], [customColumns])
|
const mergedColumns = useMemo(() => [...columns, ...(customColumns ?? [])], [customColumns])
|
||||||
const companies = useMemo(() => [...new Set(files.map(file => file.company))].filter(company => company), [files])
|
const companies = useMemo(() => files.map(file => file?.author?.company?.caption).filter(Boolean).filter(unique), [files])
|
||||||
const filenames = useMemo(() => [...new Set(files.map(file => file.name))].filter(name => name), [files])
|
const filenames = useMemo(() => files.map(file => file.name).filter(Boolean).filter(unique), [files])
|
||||||
|
|
||||||
const update = useCallback(() => {
|
const update = useCallback(() => {
|
||||||
let begin = null
|
let begin = null
|
||||||
@ -83,8 +83,13 @@ export const DocumentsTemplate = ({ idCategory, idWell: wellId, mimeTypes, heade
|
|||||||
)
|
)
|
||||||
}, [filterCompanyName, filterDataRange, filterFileName, idCategory, idWell, page])
|
}, [filterCompanyName, filterDataRange, filterFileName, idCategory, idWell, page])
|
||||||
|
|
||||||
useEffect(update, [update])
|
useEffect(() => {
|
||||||
useEffect(() => onChange?.(files), [files, onChange])
|
update()
|
||||||
|
}, [update])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChange?.(files)
|
||||||
|
}, [files, onChange])
|
||||||
|
|
||||||
const handleFileDelete = useMemo(() => hasPermission(`File.edit${idCategory}`) && (async (file) => {
|
const handleFileDelete = useMemo(() => hasPermission(`File.edit${idCategory}`) && (async (file) => {
|
||||||
await FileService.delete(idWell, file.id)
|
await FileService.delete(idWell, file.id)
|
||||||
|
@ -1,40 +1,44 @@
|
|||||||
import { useParams } from 'react-router-dom'
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { memo, useContext, useMemo } from 'react'
|
import { memo, useMemo } from 'react'
|
||||||
import { FolderOutlined } from '@ant-design/icons'
|
import { FolderOutlined } from '@ant-design/icons'
|
||||||
import { Layout } from 'antd'
|
import { Layout } from 'antd'
|
||||||
|
|
||||||
import { RootPathContext } from '@asb/context'
|
import { RootPathContext, useRootPath } from '@asb/context'
|
||||||
import { PrivateMenu, PrivateSwitch } from '@components/Private'
|
import { PrivateMenu } from '@components/Private'
|
||||||
|
import { getTabname, wrapPrivateComponent, NoAccessComponent, hasPermission } from '@utils'
|
||||||
|
|
||||||
import DocumentsTemplate from './DocumentsTemplate'
|
import DocumentsTemplate from './DocumentsTemplate'
|
||||||
|
|
||||||
const { Content } = Layout
|
const { Content } = Layout
|
||||||
|
|
||||||
|
const makeDocCat = (id, key, title, permissions = ['File.get']) => ({ id, key, title, permissions })
|
||||||
|
|
||||||
export const documentCategories = [
|
export const documentCategories = [
|
||||||
{ id: 1, key: 'fluidService', title: 'Растворный сервис' },
|
makeDocCat(1 , 'fluidService' , 'Растворный сервис' ),
|
||||||
{ id: 2, key: 'cementing', title: 'Цементирование' },
|
makeDocCat(2 , 'cementing' , 'Цементирование' ),
|
||||||
{ id: 3, key: 'nnb', title: 'ННБ' },
|
makeDocCat(3 , 'nnb' , 'ННБ' ),
|
||||||
{ id: 4, key: 'gti', title: 'ГТИ' },
|
makeDocCat(4 , 'gti' , 'ГТИ' ),
|
||||||
{ id: 5, key: 'documentsForWell', title: 'Документы по скважине' },
|
makeDocCat(5 , 'documentsForWell', 'Документы по скважине' ),
|
||||||
{ id: 6, key: 'supervisor', title: 'Супервайзер' },
|
makeDocCat(6 , 'supervisor' , 'Супервайзер' ),
|
||||||
{ id: 7, key: 'master', title: 'Мастер' },
|
makeDocCat(7 , 'master' , 'Мастер' ),
|
||||||
{ id: 8, key: 'toolService', title: 'Долотный сервис' },
|
makeDocCat(8 , 'toolService' , 'Долотный сервис' ),
|
||||||
{ id: 9, key: 'drillService', title: 'Буровой подрядчик' },
|
makeDocCat(9 , 'drillService' , 'Буровой подрядчик' ),
|
||||||
{ id: 9, key: 'closingService', title: 'Сервис по заканчиванию скважины' },
|
makeDocCat(10, 'closingService' , 'Сервис по заканчиванию скважины'),
|
||||||
]
|
]
|
||||||
|
|
||||||
export const MenuDocuments = memo(() => {
|
const MenuDocuments = memo(() => {
|
||||||
const { category } = useParams()
|
const category = getTabname()
|
||||||
const root = useContext(RootPathContext)
|
const root = useRootPath()
|
||||||
const rootPath = useMemo(() => `${root}/document`, [root])
|
const rootPath = useMemo(() => `${root}/document`, [root])
|
||||||
|
|
||||||
|
const categories = useMemo(() => documentCategories.filter(({ permissions }) => hasPermission(permissions)))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RootPathContext.Provider value={rootPath}>
|
<RootPathContext.Provider value={rootPath}>
|
||||||
<PrivateMenu mode={'horizontal'} selectable={true} className={'well_menu'} selectedKeys={[category]}>
|
<PrivateMenu mode={'horizontal'} selectable={true} className={'well_menu'} selectedKeys={[category]}>
|
||||||
{documentCategories.map(category => (
|
{categories.map(category => (
|
||||||
<PrivateMenu.Link
|
<PrivateMenu.Link
|
||||||
key={`${category.key}`}
|
key={`${category.key}`}
|
||||||
className={'ant-menu-item'}
|
|
||||||
icon={<FolderOutlined/>}
|
icon={<FolderOutlined/>}
|
||||||
title={category.title}
|
title={category.title}
|
||||||
/>
|
/>
|
||||||
@ -42,19 +46,30 @@ export const MenuDocuments = memo(() => {
|
|||||||
</PrivateMenu>
|
</PrivateMenu>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Content className={'site-layout-background'}>
|
<Content className={'site-layout-background'}>
|
||||||
<PrivateSwitch elseRedirect={documentCategories.map((cat) => cat.key)}>
|
<Routes>
|
||||||
{documentCategories.map(category => (
|
{categories.length > 0 && (
|
||||||
|
<Route index element={<Navigate to={categories[0].key} replace />} />
|
||||||
|
)}
|
||||||
|
<Route path={'*'} element={<NoAccessComponent />} />
|
||||||
|
|
||||||
|
{categories.map(category => (
|
||||||
|
<Route key={category.key} path={category.key} element={(
|
||||||
<DocumentsTemplate
|
<DocumentsTemplate
|
||||||
key={category.key}
|
|
||||||
idCategory={category.id}
|
idCategory={category.id}
|
||||||
tableName={`documents_${category.key}`}
|
tableName={`documents_${category.key}`}
|
||||||
/>
|
/>
|
||||||
|
)} />
|
||||||
))}
|
))}
|
||||||
</PrivateSwitch>
|
</Routes>
|
||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
</RootPathContext.Provider>
|
</RootPathContext.Provider>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default MenuDocuments
|
export default wrapPrivateComponent(MenuDocuments, {
|
||||||
|
requirements: [ 'Deposit.get', 'File.get' ],
|
||||||
|
title: 'Документы',
|
||||||
|
route: 'document/*',
|
||||||
|
key: 'document',
|
||||||
|
})
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { Form, Select } from 'antd'
|
import { Form, Select } from 'antd'
|
||||||
import { FileAddOutlined } from '@ant-design/icons'
|
import { FileAddOutlined } from '@ant-design/icons'
|
||||||
import { memo, useCallback, useContext, useEffect, useState } from 'react'
|
import { memo, useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { IdWellContext } from '@asb/context'
|
import { useIdWell } from '@asb/context'
|
||||||
import Poprompt from '@components/selectors/Poprompt'
|
import Poprompt from '@components/selectors/Poprompt'
|
||||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||||
import { DrillingProgramService } from '@api'
|
import { DrillingProgramService } from '@api'
|
||||||
@ -21,9 +21,10 @@ export const CategoryAdder = memo(({ categories, onUpdate, className, ...other }
|
|||||||
const [showLoader, setShowLoader] = useState(false)
|
const [showLoader, setShowLoader] = useState(false)
|
||||||
const [showCatLoader, setShowCatLoader] = useState(false)
|
const [showCatLoader, setShowCatLoader] = useState(false)
|
||||||
|
|
||||||
const idWell = useContext(IdWellContext)
|
const idWell = useIdWell()
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
setOptions(categories.map((category) => ({
|
setOptions(categories.map((category) => ({
|
||||||
label: category.name ?? category.shortName,
|
label: category.name ?? category.shortName,
|
||||||
@ -32,7 +33,8 @@ export const CategoryAdder = memo(({ categories, onUpdate, className, ...other }
|
|||||||
},
|
},
|
||||||
setShowCatLoader,
|
setShowCatLoader,
|
||||||
`Не удалось установить список доступных категорий для добавления`
|
`Не удалось установить список доступных категорий для добавления`
|
||||||
), [categories])
|
)
|
||||||
|
}, [categories])
|
||||||
|
|
||||||
const onFinish = useCallback(({ categories }) => invokeWebApiWrapperAsync(
|
const onFinish = useCallback(({ categories }) => invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { Input, Modal, Radio } from 'antd'
|
import { Input, Modal, Radio } from 'antd'
|
||||||
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { BehaviorSubject, debounceTime, distinctUntilChanged, filter, map } from 'rxjs'
|
import { BehaviorSubject, debounceTime, distinctUntilChanged, filter, map } from 'rxjs'
|
||||||
|
|
||||||
import { IdWellContext } from '@asb/context'
|
import { useIdWell } from '@asb/context'
|
||||||
import { UserView } from '@components/views'
|
import { UserView } from '@components/views'
|
||||||
import LoaderPortal from '@components/LoaderPortal'
|
import LoaderPortal from '@components/LoaderPortal'
|
||||||
import { invokeWebApiWrapperAsync } from '@components/factory'
|
import { invokeWebApiWrapperAsync } from '@components/factory'
|
||||||
@ -30,11 +30,15 @@ export const CategoryEditor = memo(({ visible, category, onClosed }) => {
|
|||||||
const [subject, setSubject] = useState(null)
|
const [subject, setSubject] = useState(null)
|
||||||
const [needUpdate, setNeedUpdate] = useState(false)
|
const [needUpdate, setNeedUpdate] = useState(false)
|
||||||
|
|
||||||
const idWell = useContext(IdWellContext)
|
const idWell = useIdWell()
|
||||||
|
|
||||||
useEffect(() => visible && setNeedUpdate(false), [visible])
|
useEffect(() => {
|
||||||
|
if (visible)
|
||||||
|
setNeedUpdate(false)
|
||||||
|
}, [visible])
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
const filteredUsers = users.filter(({ user }) => user && [
|
const filteredUsers = users.filter(({ user }) => user && [
|
||||||
user.login ?? '',
|
user.login ?? '',
|
||||||
@ -50,7 +54,8 @@ export const CategoryEditor = memo(({ visible, category, onClosed }) => {
|
|||||||
},
|
},
|
||||||
setIsSearching,
|
setIsSearching,
|
||||||
`Не удалось произвести поиск пользователей`
|
`Не удалось произвести поиск пользователей`
|
||||||
), [users, searchValue])
|
)
|
||||||
|
}, [users, searchValue])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!subject) {
|
if (!subject) {
|
||||||
@ -71,14 +76,16 @@ export const CategoryEditor = memo(({ visible, category, onClosed }) => {
|
|||||||
}
|
}
|
||||||
}, [subject])
|
}, [subject])
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
const allUsers = arrayOrDefault(await DrillingProgramService.getAvailableUsers(idWell))
|
const allUsers = arrayOrDefault(await DrillingProgramService.getAvailableUsers(idWell))
|
||||||
setAllUsers(allUsers)
|
setAllUsers(allUsers)
|
||||||
},
|
},
|
||||||
setShowLoader,
|
setShowLoader,
|
||||||
`Не удалось загрузить список доступных пользователей скважины "${idWell}"`
|
`Не удалось загрузить список доступных пользователей скважины "${idWell}"`
|
||||||
), [idWell])
|
)
|
||||||
|
}, [idWell])
|
||||||
|
|
||||||
const calcUsers = useCallback(() => {
|
const calcUsers = useCallback(() => {
|
||||||
if (!visible) return
|
if (!visible) return
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useContext, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Button, DatePicker, Input, Modal } from 'antd'
|
import { Button, DatePicker, Input, Modal } from 'antd'
|
||||||
|
|
||||||
import { IdWellContext } from '@asb/context'
|
import { useIdWell } from '@asb/context'
|
||||||
import { CompanyView } from '@components/views'
|
import { CompanyView } from '@components/views'
|
||||||
import DownloadLink from '@components/DownloadLink'
|
import DownloadLink from '@components/DownloadLink'
|
||||||
import LoaderPortal from '@components/LoaderPortal'
|
import LoaderPortal from '@components/LoaderPortal'
|
||||||
@ -65,9 +65,10 @@ export const CategoryHistory = ({ idCategory, visible, onClose }) => {
|
|||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [companyName, setCompanyName] = useState('')
|
const [companyName, setCompanyName] = useState('')
|
||||||
|
|
||||||
const idWell = useContext(IdWellContext)
|
const idWell = useIdWell()
|
||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => {
|
||||||
|
invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
if (!visible) return
|
if (!visible) return
|
||||||
const [begin, end] = range?.length > 1 ? [range[0].toISOString(), range[1].toISOString()] : [null, null]
|
const [begin, end] = range?.length > 1 ? [range[0].toISOString(), range[1].toISOString()] : [null, null]
|
||||||
@ -79,7 +80,8 @@ export const CategoryHistory = ({ idCategory, visible, onClose }) => {
|
|||||||
},
|
},
|
||||||
setIsLoading,
|
setIsLoading,
|
||||||
`Не удалось загрузить историю категорий "${idCategory}" скважины "${idWell}"`
|
`Не удалось загрузить историю категорий "${idCategory}" скважины "${idWell}"`
|
||||||
), [idWell, idCategory, visible, range, companyName, fileName, page, pageSize])
|
)
|
||||||
|
}, [idWell, idCategory, visible, range, companyName, fileName, page, pageSize])
|
||||||
|
|
||||||
const onPaginationChange = useCallback((page, pageSize) => {
|
const onPaginationChange = useCallback((page, pageSize) => {
|
||||||
setPage(page)
|
setPage(page)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useContext, useMemo, useState } from 'react'
|
import { memo, useCallback, useMemo, useState } from 'react'
|
||||||
import { Button, Input, Popconfirm, Form } from 'antd'
|
import { Button, Input, Popconfirm, Form } from 'antd'
|
||||||
import {
|
import {
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
@ -6,7 +6,7 @@ import {
|
|||||||
TableOutlined,
|
TableOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
|
||||||
import { IdWellContext } from '@asb/context'
|
import { useIdWell } from '@asb/context'
|
||||||
import { UserView } from '@components/views'
|
import { UserView } from '@components/views'
|
||||||
import UploadForm from '@components/UploadForm'
|
import UploadForm from '@components/UploadForm'
|
||||||
import DownloadLink from '@components/DownloadLink'
|
import DownloadLink from '@components/DownloadLink'
|
||||||
@ -45,7 +45,7 @@ export const CategoryRender = memo(({ partData, onUpdate, onEdit, onHistory, set
|
|||||||
file // Информация о файле
|
file // Информация о файле
|
||||||
} = partData ?? {}
|
} = partData ?? {}
|
||||||
|
|
||||||
const idWell = useContext(IdWellContext)
|
const idWell = useIdWell()
|
||||||
|
|
||||||
const uploadUrl = useMemo(() => `/api/well/${idWell}/drillingProgram/part/${idFileCategory}`, [idWell, idFileCategory])
|
const uploadUrl = useMemo(() => `/api/well/${idWell}/drillingProgram/part/${idFileCategory}`, [idWell, idFileCategory])
|
||||||
const approvedMarks = useMemo(() => file?.fileMarks?.filter((mark) => mark.idMarkType === 1), [file])
|
const approvedMarks = useMemo(() => file?.fileMarks?.filter((mark) => mark.idMarkType === 1), [file])
|
||||||
|
@ -13,7 +13,7 @@ import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
|||||||
import { useIdWell } from '@asb/context'
|
import { useIdWell } from '@asb/context'
|
||||||
import LoaderPortal from '@components/LoaderPortal'
|
import LoaderPortal from '@components/LoaderPortal'
|
||||||
import { downloadFile, formatBytes, invokeWebApiWrapperAsync } from '@components/factory'
|
import { downloadFile, formatBytes, invokeWebApiWrapperAsync } from '@components/factory'
|
||||||
import { arrayOrDefault, formatDate } from '@utils'
|
import { arrayOrDefault, formatDate, wrapPrivateComponent } from '@utils'
|
||||||
import { DrillingProgramService } from '@api'
|
import { DrillingProgramService } from '@api'
|
||||||
|
|
||||||
import CategoryAdder from './CategoryAdder'
|
import CategoryAdder from './CategoryAdder'
|
||||||
@ -41,7 +41,7 @@ const STATE_STRING = {
|
|||||||
[ID_STATE.Unknown]: { icon: WarningOutlined, text: 'Неизвестно' },
|
[ID_STATE.Unknown]: { icon: WarningOutlined, text: 'Неизвестно' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DrillingProgram = memo(() => {
|
const DrillingProgram = memo(() => {
|
||||||
const [selectedCategory, setSelectedCategory] = useState()
|
const [selectedCategory, setSelectedCategory] = useState()
|
||||||
const [historyVisible, setHistoryVisible] = useState(false)
|
const [historyVisible, setHistoryVisible] = useState(false)
|
||||||
const [editorVisible, setEditorVisible] = useState(false)
|
const [editorVisible, setEditorVisible] = useState(false)
|
||||||
@ -79,7 +79,9 @@ export const DrillingProgram = memo(() => {
|
|||||||
`Не удалось загрузить название скважины "${idWell}"`
|
`Не удалось загрузить название скважины "${idWell}"`
|
||||||
), [idWell])
|
), [idWell])
|
||||||
|
|
||||||
useEffect(() => updateData(), [updateData])
|
useEffect(() => {
|
||||||
|
updateData()
|
||||||
|
}, [updateData])
|
||||||
|
|
||||||
const onCategoryEdit = useCallback((catId) => {
|
const onCategoryEdit = useCallback((catId) => {
|
||||||
setSelectedCategory(catId)
|
setSelectedCategory(catId)
|
||||||
@ -97,6 +99,15 @@ export const DrillingProgram = memo(() => {
|
|||||||
updateData()
|
updateData()
|
||||||
}, [updateData])
|
}, [updateData])
|
||||||
|
|
||||||
|
const clearError = useCallback(() => invokeWebApiWrapperAsync(
|
||||||
|
async () => {
|
||||||
|
await DrillingProgramService.clearError(idWell)
|
||||||
|
await updateData()
|
||||||
|
},
|
||||||
|
setShowLoader,
|
||||||
|
`Не удалось сбросить ошибку формирования программы бурения для скважины ${idWell}`
|
||||||
|
), [idWell])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoaderPortal show={showLoader}>
|
<LoaderPortal show={showLoader}>
|
||||||
<Layout style={{ backgroundColor: 'white' }}>
|
<Layout style={{ backgroundColor: 'white' }}>
|
||||||
@ -129,7 +140,7 @@ export const DrillingProgram = memo(() => {
|
|||||||
<StateIcon className={'m-10'} />
|
<StateIcon className={'m-10'} />
|
||||||
{error?.message ?? state.text}
|
{error?.message ?? state.text}
|
||||||
</h3>
|
</h3>
|
||||||
<Button icon={<ReloadOutlined />}>
|
<Button onClick={clearError} icon={<ReloadOutlined />}>
|
||||||
Сбросить статус ошибки
|
Сбросить статус ошибки
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@ -172,4 +183,8 @@ export const DrillingProgram = memo(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default DrillingProgram
|
export default wrapPrivateComponent(DrillingProgram, {
|
||||||
|
requirements: [ 'DrillingProgram.get' ],
|
||||||
|
title: 'Программа бурения',
|
||||||
|
route: 'drillingProgram',
|
||||||
|
})
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user