Merge branch 'dev'

This commit is contained in:
goodmice 2022-07-20 09:55:40 +05:00
commit ff520626cf
179 changed files with 14360 additions and 30069 deletions

1
__mocks__/fileMock.js Normal file
View File

@ -0,0 +1 @@
module.exports = 'test-file-stub'

1
__mocks__/styleMock.js Normal file
View File

@ -0,0 +1 @@
module.exports = {}

7
babel.config.js Normal file
View File

@ -0,0 +1,7 @@
module.exports = {
presets: [
'@babel/preset-env',
['@babel/preset-react', {runtime: 'automatic'}],
'@babel/preset-typescript',
],
}

View File

@ -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
View 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
}

34746
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
} }
} }

View File

@ -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>

View File

@ -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}>
<Router> <RootPathContext.Provider value={''}>
<Switch> <Router>
<Route path={'/login'}> <Routes>
<Login /> <Route index element={<Navigate to={Deposit.getKey()} replace />} />
</Route> <Route path={'*'} element={<NoAccessComponent />} />
<Route path={'/register'}>
<Register /> {/* Public pages */}
</Route> <Route path={Login.route} element={<Login />} />
<PrivateRoute path={'/'}> <Route path={Register.route} element={<Register />} />
<Main />
</PrivateRoute> {/* Admin pages */}
</Switch> <Route path={AdminPanel.route} element={<AdminPanel />} />
</Router>
{/* 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>
</RootPathContext.Provider>
</ConfigProvider> </ConfigProvider>
)) ))

View File

@ -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'

View 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

View File

@ -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)) {

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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'

View File

@ -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'}/>}

View File

@ -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}

View File

@ -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

View File

@ -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

View File

@ -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
if (key && visible !== false) {
key = key.slice(key.lastIndexOf('$') + 1) // Ключ автоматический преобразуется в "(.+)\$ключ"
const path = join(rootPath, key)
if (visible || isURLAvailable(path))
return cloneElement(element, { key, path, tabName: key })
}
return null
}), [children, rootPath])
return <Menu children={items} {...other} /> const items = useMemo(() => Children.map(children, (child) => {
if (!child || !isValidElement<PrivateMenuLinkProps>(child))
return null
const content: PrivateProps | undefined = child.props.content
const visible: boolean | undefined = child.props.visible
if (visible === false) return null
let key
if (content?.key)
key = content.key
else if (content?.route)
key = content.route
else if (child.key) {
key = child.key?.toString()
key = key.slice(key.lastIndexOf('$') + 1)
} else return null
const permissions = child.props.permissions ?? content?.requirements
const path = child.props.path ?? join(rootPath, key)
if (visible || hasPermission(permissions))
return {
...child.props,
key,
label: <PrivateMenuLink {...child.props} path={path} />,
}
return null
})?.filter((v) => v) ?? [], [children, rootPath])
return (
<Menu
selectable={selectable ?? true}
mode={mode ?? 'horizontal'}
selectedKeys={keys}
items={items as ItemType[]}
{...other}
/>
)
}) })
export const PrivateMenu = Object.assign(PrivateMenuMain, { Link: PrivateMenuLink }) export const PrivateMenu = Object.assign(PrivateMenuMain, { Link: PrivateMenuLink })

View File

@ -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>
) )
}) })

View File

@ -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)}
/> />
) )
}) })

View File

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

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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 ?? '--'
} }
}) })

View File

@ -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 '.'

View 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

View File

@ -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 '.'

View File

@ -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}

View File

@ -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> => {
value[0] ? (isUTC ? moment.utc(value[0]).local() : moment(value[0])) : null, if (!value) return [null, null]
value[1] ? (isUTC ? moment.utc(value[1]).local() : moment(value[1])) : null, return [
] value[0] ? (isUTC ? moment.utc(value[0]).local() : moment(value[0])) : 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

View File

@ -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
} }

View File

@ -1,223 +1,238 @@
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 = ({
(record) => invokeWebApiWrapperAsync( service,
async () => { permission,
const addIdWell = (...params) => idWell ? [idWell, ...params] : params action,
if (typeof recordParser === 'function') actionName,
record = recordParser(record) recordParser,
idWell,
const actionId = actions.findIndex((elm) => elm[0].includes(action)) idRecord = false,
const params = actions[actionId]?.[1](record)
if (params) await service[action](...addIdWell(...params))
await onComplete?.()
},
setLoader, setLoader,
errorMsg, errorMsg = 'Не удалось выполнить операцию',
actionName onComplete,
) }) => hasPermission(permission) && service && action && (
(record) => invokeWebApiWrapperAsync(
async () => {
const data = recordParser?.(record) ?? record
const params = actions[action]?.(data, idWell, idRecord).filter(Boolean)
if (params?.length > 0)
await service[action](...params)
await onComplete?.()
},
setLoader,
errorMsg,
actionName
)
) )
export const tryAddKeys = (items) => { export const tryAddKeys = (items) => {
if (!items?.length || !items[0]) if (!items?.length || !items[0])
return [] return []
if (items[0].key) if (items[0].key)
return items return items
return items.map((item, index) => ({...item, key: item.key ?? item.id ?? index })) return items.map((item, index) => ({...item, key: item.key ?? item.id ?? index }))
} }
const EditableTableComponents = { body: { cell: EditableCell }}
/**
* @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
}) => { }) => {
const [form] = Form.useForm() const [form] = Form.useForm()
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])
const edit = useCallback((record) => { const edit = useCallback((record) => {
form.setFieldsValue({...record}) form.setFieldsValue({...record})
setEditingKey(record.key) setEditingKey(record.key)
}, [form]) }, [form])
const cancel = useCallback(() => { const cancel = useCallback(() => {
if (editingKey === newRowKeyValue) { if (editingKey === newRowKeyValue) {
const newData = [...data] const newData = [...data]
const index = newData.findIndex((item) => newRowKeyValue === item.key) const index = newData.findIndex((item) => newRowKeyValue === item.key)
newData.splice(index, 1) newData.splice(index, 1)
setData(newData) setData(newData)
}
setEditingKey('')
}, [data, editingKey])
const addNewRow = useCallback(async () => {
let newRow = {
...form.initialValues,
key:newRowKeyValue
}
const newData = [newRow, ...data]
setData(newData)
edit(newRow)
}, [data, edit, form.initialValues])
const save = useCallback(async (record) => {
try {
const row = await form.validateFields()
const newData = [...data]
const index = newData.findIndex((item) => record.key === item.key)
const item = newData[index]
const newItem = { ...item, ...row }
newData.splice(index, 1, newItem)
if (item.key === newRowKeyValue)
item.key = newRowKeyValue + newData.length
const isAdding = editingKey === newRowKeyValue
setEditingKey('')
setData(newData)
if (isAdding)
try {
onRowAdd(newItem)
} catch (err) {
console.log('callback onRowAdd fault:', err)
} }
else setEditingKey('')
try { }, [data, editingKey])
onRowEdit(newItem)
} catch (err) { const addNewRow = useCallback(async () => {
console.log('callback onRowEdit fault:', err) let newRow = {
...form.initialValues,
key:newRowKeyValue
} }
try { const newData = [newRow, ...data]
setData(newData)
edit(newRow)
}, [data, edit, form.initialValues])
const save = useCallback(async (record) => {
try {
const row = await form.validateFields()
const newData = [...data]
const index = newData.findIndex((item) => record.key === item.key)
const item = newData[index]
const newItem = { ...item, ...row }
newData.splice(index, 1, newItem)
if (item.key === newRowKeyValue)
item.key = newRowKeyValue + newData.length
const isAdding = editingKey === newRowKeyValue
setEditingKey('')
setData(newData)
if (isAdding)
try {
onAdd(newItem)
} catch (err) {
console.log('callback onRowAdd fault:', err)
}
else
try {
onEdit(newItem)
} catch (err) {
console.log('callback onRowEdit fault:', err)
}
try {
onChange?.(newData)
} catch (err) {
console.log('callback onChange fault:', err)
}
} catch (errInfo) {
console.log('Validate Failed:', errInfo)
}
}, [data, editingKey, form, onChange, onAdd, onEdit])
const deleteRow = useCallback((record) => {
const newData = [...data]
const index = newData.findIndex((item) => record.key === item.key)
newData.splice(index, 1)
setData(newData)
onDelete(record)
onChange?.(newData) onChange?.(newData)
} catch (err) { }, [data, onChange, onDelete])
console.log('callback onChange fault:', err)
}
} catch (errInfo) { const handleColumn = useCallback((col) => {
console.log('Validate Failed:', errInfo) if (col.children)
} col.children = col.children.map(handleColumn)
}, [data, editingKey, form, onChange, onRowAdd, onRowEdit])
const deleteRow = useCallback((record) => { if (!col.editable)
const newData = [...data] return col
const index = newData.findIndex((item) => record.key === item.key)
newData.splice(index, 1) return {
setData(newData) ...col,
onCell: (record) => ({
...col.onCell?.(record),
editing: isEditing(record),
record,
dataIndex: col.dataIndex ?? col.key,
key: col.key ?? col.dataIndex,
input: col.input,
isRequired: col.isRequired,
title: col.title,
datatype: col.datatype,
formItemClass: col.formItemClass,
formItemRules: col.formItemRules,
initialValue: col.initialValue,
}),
}
}, [isEditing])
onRowDelete(record) const operationColumn = useMemo(() => ({
onChange?.(newData) width: buttonsWidth ?? 82,
}, [data, onChange, onRowDelete]) 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 operationColumn = { const mergedColumns = useMemo(() => [...columns.map(handleColumn), operationColumn], [columns, handleColumn, 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) => { useEffect(() => {
if (col.children) setData(tryAddKeys(dataSource))
col.children = col.children.map(handleColumn) }, [dataSource])
if (!col.editable) return (
return col <Form form={form}>
<Table
return { components={EditableTableComponents}
...col, columns={mergedColumns}
onCell: (record) => ({ dataSource={data}
...col.onCell?.(record), {...otherTableProps}
editing: isEditing(record), />
record, </Form>
dataIndex: col.dataIndex ?? col.key, )
key: col.key ?? col.dataIndex,
input: col.input,
isRequired: col.isRequired,
title: col.title,
datatype: col.datatype,
formItemClass: col.formItemClass,
formItemRules: col.formItemRules,
initialValue: col.initialValue,
}),
}
}, [isEditing])
const mergedColumns = [...columns.map(handleColumn), operationColumn]
return (
<Form form={form}>
<Table
components={{
body: {
cell: EditableCell,
},
}}
columns={mergedColumns}
dataSource={data}
{...otherTableProps}
/>
</Form>
)
}) })

View File

@ -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) => (

View File

@ -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={'Отображён'}

View 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

View File

@ -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 = {

View File

@ -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))
}

View File

@ -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 {

View File

@ -1,30 +1,31 @@
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) => {
setIsModalVisible(true) setIsModalVisible(true)
e.preventDefault() e.preventDefault()
}, []) }, [])
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>

View File

@ -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 = {

View 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

View 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

View 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

View 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]
}

View File

@ -0,0 +1,6 @@
export * from './D3Chart'
export type { D3ChartProps } from './D3Chart'
export * from './D3MonitoringCharts'
export * from './types'

View 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

View 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

View 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

View 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

View 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

View 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
}

View File

@ -0,0 +1,6 @@
export * from './base'
export * from './D3ContextMenu'
export * from './D3Cursor'
export * from './D3HorizontalCursor'
export * from './D3Legend'
export * from './D3Tooltip'

View 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
}

View File

@ -0,0 +1,5 @@
export * from './area'
export * from './line'
export * from './needle'
export * from './points'
export * from './rect_area'

View 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
}

View 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
}

View 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
}

View 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
View 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
}

View File

@ -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
} }
} }

View File

@ -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 минута' },

View File

@ -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,17 +40,19 @@ 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(() => {
async () => { invokeWebApiWrapperAsync(
const wellsTree = treeData ?? await getTreeData() async () => {
const labels = treeLabels ?? getTreeLabels(wellsTree) const wellsTree = treeData ?? await getTreeData()
setWellsTree(wellsTree) const labels = treeLabels ?? getTreeLabels(wellsTree)
setWellLabels(labels) setWellsTree(wellsTree)
}, setWellLabels(labels)
null, },
'Не удалось загрузить список скважин', null,
'Получение списка скважин' 'Не удалось загрузить список скважин',
), [idWell, treeData, treeLabels]) 'Получение списка скважин'
)
}, [idWell, treeData, treeLabels])
return ( return (
<TreeSelect <TreeSelect

View File

@ -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}>

View File

@ -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>

View File

@ -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
View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@ -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
<React.StrictMode> const root = createRoot(container)
<App />
</React.StrictMode> root.render(
), document.getElementById('root')) <React.StrictMode>
<App />
</React.StrictMode>
)
// 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))

View File

@ -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> <div className={'desc'}>
)) <Paragraph>
<Text strong style={{ fontSize: 16 }}>Возможные причины данной проблемы:</Text>
</Paragraph>
<Paragraph>
<CloseCircleOutlined style={{ color: 'red' }} />
&nbsp;У вас отсутствует доступ к странице.&nbsp;
<Typography.Link href={'mailto://support@digitaldrilling.ru'} target={'_blank'}>
Обратиться в поддержку &gt;
</Typography.Link>
</Paragraph>
<Paragraph>
<CloseCircleOutlined style={{ color: 'red' }} />
&nbsp;Страницы не существует.&nbsp;
<Link to={'#'} onClick={() => navigate(-1)}>Вернуться назад &gt;</Link>
</Paragraph>
<Paragraph>
<CloseCircleOutlined style={{ color: 'red' }} />
&nbsp;Разрешения не обновились.&nbsp;
<Link to={'/login'}>Перезайти в аккаунт &gt;</Link>
</Paragraph>
</div>
</Result>
)
})
export default AccessDenied export default AccessDenied

View File

@ -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,25 +56,37 @@ export const ClusterController = memo(() => {
'Получение списка кустов' 'Получение списка кустов'
), []) ), [])
useEffect(() => invokeWebApiWrapperAsync( useEffect(() => {
async () => { invokeWebApiWrapperAsync(
let deposits = arrayOrDefault(await AdminDepositService.getAll()) async () => {
deposits = deposits.map((deposit) => ({ value: deposit.id, label: deposit.caption })) let deposits = arrayOrDefault(await AdminDepositService.getAll())
setDeposits(deposits) deposits = deposits.map((deposit) => ({ value: deposit.id, label: deposit.caption }))
}, setDeposits(deposits)
setShowLoader, },
`Не удалось загрузить список месторождений`, setShowLoader,
'Получение списка месторождений' `Не удалось загрузить список месторождений`,
), []) 'Получение списка месторождений'
)
}, [])
useEffect(updateTable, [updateTable]) useEffect(() => {
updateTable()
}, [updateTable])
const handlerProps = useMemo(() => ({ const tableHandlers = useMemo(() => {
service: AdminClusterService, const handlerProps = {
setLoader: setShowLoader, service: AdminClusterService,
errorMsg: `Не удалось выполнить операцию`, setLoader: setShowLoader,
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',
})

View File

@ -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,44 +28,54 @@ export const CompanyController = memo(() => {
setCompanies(arrayOrDefault(companies)) setCompanies(arrayOrDefault(companies))
}, []) }, [])
useEffect(() => invokeWebApiWrapperAsync( useEffect(() => {
async() => { invokeWebApiWrapperAsync(
const companyTypes = arrayOrDefault(await AdminCompanyTypeService.getAll()).map((companyType) => ({ async() => {
value: companyType.id, const companyTypes = arrayOrDefault(await AdminCompanyTypeService.getAll()).map((companyType) => ({
label: companyType.caption, value: companyType.id,
})) label: companyType.caption,
}))
setColumns([ setColumns([
makeColumn('Название', 'caption', { makeColumn('Название', 'caption', {
width: 200, width: 200,
editable: true, editable: true,
sorter: makeStringSorter('caption'), sorter: makeStringSorter('caption'),
formItemRules: min1, formItemRules: min1,
}), }),
makeSelectColumn('Тип компании', 'idCompanyType', companyTypes, null, { makeSelectColumn('Тип компании', 'idCompanyType', companyTypes, null, {
width: 200, width: 200,
editable: true editable: true
}), }),
]) ])
await updateTable() await updateTable()
}, },
setShowLoader,
`Не удалось загрузить список типов компаний`,
'Получение списка типов команд'
), [updateTable])
const handlerProps = useMemo(() => ({
service: AdminCompanyService,
setLoader: setShowLoader,
errorMsg: `Не удалось выполнить операцию`,
onComplete: () => invokeWebApiWrapperAsync(
updateTable,
setShowLoader, setShowLoader,
`Не удалось обновить список компаний`, `Не удалось загрузить список типов компаний`,
'Получение списка компаний' 'Получение списка типов команд'
), )
}), [updateTable]) }, [updateTable])
const tableHandlers = useMemo(() => {
const handlerProps = {
service: AdminCompanyService,
setLoader: setShowLoader,
onComplete: () => invokeWebApiWrapperAsync(
updateTable,
setShowLoader,
`Не удалось обновить список компаний`,
'Получение списка компаний'
),
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',
})

View File

@ -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(() => {
service: AdminCompanyTypeService, const handlerProps = {
setLoader: setShowLoader, service: AdminCompanyTypeService,
errorMsg: `Не удалось выполнить операцию`, setLoader: setShowLoader,
onComplete: updateTable, errorMsg: `Не удалось выполнить операцию`,
}), [updateTable]) onComplete: 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',
})

View File

@ -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(() => {
service: AdminDepositService, const handlerProps = {
setLoader: setShowLoader, service: AdminDepositService,
errorMsg: `Не удалось выполнить операцию`, setLoader: setShowLoader,
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',
})

View File

@ -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(() => {
service: AdminPermissionService, const handlerProps = {
setLoader: setShowLoader, service: AdminPermissionService,
errorMsg: `Не удалось выполнить операцию`, setLoader: setShowLoader,
onComplete: updateTable onComplete: updateTable,
}), [updateTable]) permission: 'AdminPermission.edit'
}
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',
})

View File

@ -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,28 +37,38 @@ export const RoleController = memo(() => {
setRoles(arrayOrDefault(roles)) setRoles(arrayOrDefault(roles))
}, []) }, [])
useEffect(() => invokeWebApiWrapperAsync( useEffect(() => {
async () => { invokeWebApiWrapperAsync(
const permissions = await AdminPermissionService.getAll() async () => {
setPermissions(arrayOrDefault(permissions)) const permissions = await AdminPermissionService.getAll()
await loadRoles() setPermissions(arrayOrDefault(permissions))
}, await loadRoles()
setShowLoader, },
`Не удалось загрузить список ролей`,
'Получение списка ролей'
), [loadRoles])
const handlerProps = useMemo(() => ({
service: AdminUserRoleService,
setLoader: setShowLoader,
errorMsg: `Не удалось выполнить операцию`,
onComplete: async () => invokeWebApiWrapperAsync(
loadRoles,
setShowLoader, setShowLoader,
`Не удалось загрузить список ролей`, `Не удалось загрузить список ролей`,
'Получение списка ролей', 'Получение списка ролей'
) )
}), [loadRoles]) }, [loadRoles])
const tableHandlers = useMemo(() => {
const handlerProps = {
service: AdminUserRoleService,
setLoader: setShowLoader,
onComplete: async () => invokeWebApiWrapperAsync(
loadRoles,
setShowLoader,
`Не удалось загрузить список ролей`,
'Получение списка ролей',
),
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',
})

View File

@ -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',
})

View File

@ -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,20 +81,22 @@ export const TelemetryController = memo(() => {
].join(' ').toLowerCase().includes(searchValue.toLowerCase())) ].join(' ').toLowerCase().includes(searchValue.toLowerCase()))
), [telemetryData, searchValue]) ), [telemetryData, searchValue])
useEffect(() => invokeWebApiWrapperAsync( useEffect(() => {
async () => { invokeWebApiWrapperAsync(
const telemetryData = arrayOrDefault(await AdminTelemetryService.getAll()) async () => {
setTelemetryData(telemetryData.map((telemetry) => ({ const telemetryData = arrayOrDefault(await AdminTelemetryService.getAll())
...(telemetry?.info ?? []), setTelemetryData(telemetryData.map((telemetry) => ({
id: telemetry?.id, ...(telemetry?.info ?? []),
remoteUid: telemetry?.remoteUid, id: telemetry?.id,
realWell: telemetry?.well?.caption, remoteUid: telemetry?.remoteUid,
}))) realWell: telemetry?.well?.caption,
}, })))
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',
})

View File

@ -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/*',
})

View File

@ -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,23 +33,25 @@ 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(() => {
async () => { invokeWebApiWrapperAsync(
const filteredUsers = users.filter((user) => user && (!searchValue || [ async () => {
user.login ?? '', const filteredUsers = users.filter((user) => user && (!searchValue || [
user.name ?? '', user.login ?? '',
user.surname ?? '', user.name ?? '',
user.partonymic ?? '', user.surname ?? '',
user.email ?? '', user.partonymic ?? '',
user.phone ?? '', user.email ?? '',
user.position ?? '', user.phone ?? '',
user.company?.caption ?? '', user.position ?? '',
].join(' ').toLowerCase().includes(searchValue.toLowerCase()))) user.company?.caption ?? '',
setFilteredUsers(filteredUsers) ].join(' ').toLowerCase().includes(searchValue.toLowerCase())))
}, setFilteredUsers(filteredUsers)
setIsSearching, },
`Не удалось произвести поиск пользователей` setIsSearching,
), [users, searchValue]) `Не удалось произвести поиск пользователей`
)
}, [users, searchValue])
useEffect(() => { useEffect(() => {
if (!subject) { if (!subject) {
@ -91,93 +91,103 @@ export const UserController = memo(() => {
'Получение списка пользователей' 'Получение списка пользователей'
), []) ), [])
useEffect(() => invokeWebApiWrapperAsync( useEffect(() => {
async () => { invokeWebApiWrapperAsync(
const roles = arrayOrDefault(await AdminUserRoleService.getAll()) async () => {
const companies = arrayOrDefault(await AdminCompanyService.getAll()).map((company) => ({ const roles = arrayOrDefault(await AdminUserRoleService.getAll())
value: company.id, const companies = arrayOrDefault(await AdminCompanyService.getAll()).map((company) => ({
label: company.caption value: company.id,
})) label: company.caption
}))
const users = arrayOrDefault(await AdminUserService.getAll()) const users = arrayOrDefault(await AdminUserService.getAll())
setUsers(users) setUsers(users)
const filters = makeTextFilters(users, ['surname', 'name', 'patronymic', 'email']) const filters = makeTextFilters(users, ['surname', 'name', 'patronymic', 'email'])
const roleFilters = [{ text: 'Без роли', value: null }, ...roles.map((role) => ({ text: role.caption, value: role.caption }))] const roleFilters = [{ text: 'Без роли', value: null }, ...roles.map((role) => ({ text: role.caption, value: role.caption }))]
const rolesRender = (item) => item?.map((elm) => ( const rolesRender = (item) => item?.map((elm) => (
<Tag key={elm} color={'blue'}> <Tag key={elm} color={'blue'}>
<RoleView role={roles.find((role) => role.caption === elm)} /> <RoleView role={roles.find((role) => role.caption === elm)} />
</Tag> </Tag>
)) ?? '-' )) ?? '-'
setColumns([ setColumns([
makeTextColumn('Логин', 'login', null, null, null, { makeTextColumn('Логин', 'login', null, null, null, {
editable: true, editable: true,
formItemRules: [ formItemRules: [
{ required: true }, { required: true },
...createLoginRules, ...createLoginRules,
// () => ({ // () => ({
// validator(_, value) { // validator(_, value) {
// if (!value || users.findIndex((user) => user.login === value) < 0) // if (!value || users.findIndex((user) => user.login === value) < 0)
// return Promise.resolve() // return Promise.resolve()
// return Promise.reject(new Error('Логин уже занят!')) // return Promise.reject(new Error('Логин уже занят!'))
// } // }
// }) // })
// TODO: Для проверки уникальности логина необходимо исключить из выборки логин выбранного пользователя // TODO: Для проверки уникальности логина необходимо исключить из выборки логин выбранного пользователя
], ],
}), }),
makeTextColumn('Фамилия', 'surname', filters.surname, null, null, { makeTextColumn('Фамилия', 'surname', filters.surname, null, null, {
editable: true, editable: true,
formItemRules: [{ required: true }, ...nameRules], formItemRules: [{ required: true }, ...nameRules],
filterSearch: true, filterSearch: true,
onFilter: makeTextOnFilter('surname'), onFilter: makeTextOnFilter('surname'),
}), }),
makeTextColumn('Имя', 'name', filters.name, null, null, { makeTextColumn('Имя', 'name', filters.name, null, null, {
editable: true, editable: true,
formItemRules: nameRules, formItemRules: nameRules,
filterSearch: true, filterSearch: true,
onFilter: makeTextOnFilter('name'), onFilter: makeTextOnFilter('name'),
}), }),
makeTextColumn('Отчество', 'patronymic', filters.partonymic, null, null, { makeTextColumn('Отчество', 'patronymic', filters.partonymic, null, null, {
editable: true, editable: true,
formItemRules: nameRules, formItemRules: nameRules,
filterSearch: true, filterSearch: true,
onFilter: makeTextOnFilter('patronymic'), onFilter: makeTextOnFilter('patronymic'),
}), }),
makeTextColumn('E-mail', 'email', filters.email, null, null, { makeTextColumn('E-mail', 'email', filters.email, null, null, {
editable: true, editable: true,
formItemRules: [{ required: true }, ...emailRules], formItemRules: [{ required: true }, ...emailRules],
filterSearch: true, filterSearch: true,
onFilter: makeTextOnFilter('email'), onFilter: makeTextOnFilter('email'),
}), }),
makeTextColumn('Номер телефона', 'phone', null, null, null, { makeTextColumn('Номер телефона', 'phone', null, null, null, {
editable: true, editable: true,
formItemRules: phoneRules, formItemRules: phoneRules,
}), }),
makeTextColumn('Должность', 'position', null, null, null, { editable: true }), makeTextColumn('Должность', 'position', null, null, null, { editable: true }),
makeTextColumn('Роли', 'roleNames', roleFilters, null, rolesRender, { makeTextColumn('Роли', 'roleNames', roleFilters, null, rolesRender, {
editable: true, editable: true,
input: <RoleTag roles={roles} />, input: <RoleTag roles={roles} />,
onFilter: makeArrayOnFilter('roleNames'), onFilter: makeArrayOnFilter('roleNames'),
}), }),
makeSelectColumn('Компания', 'idCompany', companies, '--', { makeSelectColumn('Компания', 'idCompany', companies, '--', {
editable: true, editable: true,
sorter: makeNumericSorter('idCompany'), sorter: makeNumericSorter('idCompany'),
}) })
]) ])
}, },
setShowLoader, setShowLoader,
`Не удалось загрузить список компаний`, `Не удалось загрузить список компаний`,
'Получение списка компаний' 'Получение списка компаний'
), []) )
}, [])
const handlerProps = useMemo(() => ({ const tableHandlers = useMemo(() => {
service: AdminUserService, const handlerProps = {
setLoader: setShowLoader, service: AdminUserService,
errorMsg: `Не удалось выполнить операцию`, setLoader: setShowLoader,
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',
})

View File

@ -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,16 +29,18 @@ export const VisitLog = memo(() => {
].join(' ').toLowerCase().includes(searchValue.toLowerCase())) ].join(' ').toLowerCase().includes(searchValue.toLowerCase()))
), [logData, searchValue]) ), [logData, searchValue])
useEffect(() => invokeWebApiWrapperAsync( useEffect(() => {
async () => { invokeWebApiWrapperAsync(
const logData = arrayOrDefault(await RequestTrackerService.getUsersStat(logRecordCount)) async () => {
logData.forEach((log) => log.key = `${log.login}${log.ip}`) const logData = arrayOrDefault(await RequestTrackerService.getUsersStat(logRecordCount))
setLogData(logData) logData.forEach((log) => log.key = `${log.login}${log.ip}`)
}, setLogData(logData)
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',
})

View File

@ -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,58 +72,68 @@ export const WellController = memo(() => {
/> />
)) ))
useEffect(() => invokeWebApiWrapperAsync( useEffect(() => {
async () => { invokeWebApiWrapperAsync(
const companies = arrayOrDefault(await AdminCompanyService.getAll()) async () => {
const telemetry = arrayOrDefault(await AdminTelemetryService.getAll()) const companies = arrayOrDefault(await AdminCompanyService.getAll())
const clusters = arrayOrDefault(await AdminClusterService.getAll()).map((cluster) => ({ const telemetry = arrayOrDefault(await AdminTelemetryService.getAll())
value: cluster.id, const clusters = arrayOrDefault(await AdminClusterService.getAll()).map((cluster) => ({
label: cluster.caption value: cluster.id,
})) label: cluster.caption
}))
setColumns([ setColumns([
makeSelectColumn('Куст', 'idCluster', clusters, '--', { makeSelectColumn('Куст', 'idCluster', clusters, '--', {
width: '5rem', width: '5rem',
editable: true, editable: true,
sorter: makeNumericSorter('idCluster'), sorter: makeNumericSorter('idCluster'),
}), }),
makeColumn('Название', 'caption', { makeColumn('Название', 'caption', {
width: '5rem', width: '5rem',
editable: true, editable: true,
sorter: makeStringSorter('caption'), sorter: makeStringSorter('caption'),
}), }),
makeSelectColumn('Тип', 'idWellType', wellTypes, '--', { makeSelectColumn('Тип', 'idWellType', wellTypes, '--', {
width: 150, width: 150,
editable: true, editable: true,
sorter: makeNumericSorter('idWellType'), sorter: makeNumericSorter('idWellType'),
}), }),
makeColumn('Широта', 'latitude', { width: 150, editable: true, render: coordsFixed }), makeColumn('Широта', 'latitude', { width: 150, editable: true, render: coordsFixed }),
makeColumn('Долгота', 'longitude', { width: 150, editable: true, render: coordsFixed }), makeColumn('Долгота', 'longitude', { width: 150, editable: true, render: coordsFixed }),
makeColumn('Телеметрия', 'telemetry', { makeColumn('Телеметрия', 'telemetry', {
editable: true, editable: true,
render: (telemetry) => <TelemetryView telemetry={telemetry} />, render: (telemetry) => <TelemetryView telemetry={telemetry} />,
input: <TelemetrySelect telemetry={telemetry}/>, input: <TelemetrySelect telemetry={telemetry}/>,
}, ), }, ),
makeTimezoneColumn('Зона', 'timezone', null, true, { width: 170 }), makeTimezoneColumn('Зона', 'timezone', null, true, { width: 170 }),
makeTagColumn('Компании', 'companies', companies, 'id', 'caption', { makeTagColumn('Компании', 'companies', companies, 'id', 'caption', {
editable: true, editable: true,
render: (company) => <CompanyView company={company} />, render: (company) => <CompanyView company={company} />,
}), }),
]) ])
await updateTable() await updateTable()
}, },
setShowLoader, setShowLoader,
`Не удалось загрузить список кустов`, `Не удалось загрузить список кустов`,
'Получение списка кустов' 'Получение списка кустов'
), [updateTable]) )
}, [updateTable])
const handlerProps = useMemo(() => ({ const tableHandlers = useMemo(() => {
service: AdminWellService, const handlerProps = {
setLoader: setShowLoader, service: AdminWellService,
errorMsg: `Не удалось выполнить операцию`, setLoader: setShowLoader,
onComplete: updateTable onComplete: updateTable,
}), [updateTable]) permission: 'AdminWell.edit'
}
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',
})

View File

@ -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',
})

View File

@ -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,67 +96,75 @@ export const Statistics = memo(() => {
) )
}, [avgRow]) }, [avgRow])
useEffect(() => invokeWebApiWrapperAsync( useEffect(() => {
async () => { invokeWebApiWrapperAsync(
const types = await WellOperationService.getSectionTypes(idWell) async () => {
setSectionTypes(Object.entries(types)) const types = await WellOperationService.getSectionTypes(idWell)
}, setSectionTypes(Object.entries(types))
setIsPageLoading, },
`Не удалось получить типы секции`, setIsPageLoading,
`Получение списка возможных секций`, `Не удалось получить типы секции`,
), [idWell]) `Получение списка возможных секций`,
)
}, [idWell])
useEffect(() => invokeWebApiWrapperAsync( useEffect(() => {
async () => { invokeWebApiWrapperAsync(
const filteredSections = avgData?.length > 0 ? sectionTypes.filter(([id, _]) => avgData.some((row) => `section_${id}` in row)) : sectionTypes async () => {
const filteredSections = avgData?.length > 0 ? sectionTypes.filter(([id, _]) => avgData.some((row) => `section_${id}` in row)) : sectionTypes
setAvgColumns([ setAvgColumns([
...defaultColumns, ...defaultColumns,
...filteredSections.map(([id, name]) => makeSectionColumn(name, `section_${id}`)), ...filteredSections.map(([id, name]) => makeSectionColumn(name, `section_${id}`)),
]) ])
setCmpColumns([ setCmpColumns([
...defaultColumns, ...defaultColumns,
...filteredSections.map(([id, name]) => makeSectionColumn(name, `section_${id}`, { ...filteredSections.map(([id, name]) => makeSectionColumn(name, `section_${id}`, {
speedRender: cmpSpeedRender(`section_${id}`) speedRender: cmpSpeedRender(`section_${id}`)
}))
])
},
setIsPageLoading,
'Не удалось установить необходимые столбцы'
)
}, [sectionTypes, avgData, cmpSpeedRender])
useEffect(() => {
invokeWebApiWrapperAsync(
async () => {
const avgData = await getWellData(avgWells)
setAvgData(avgData)
const avgRow = {}
avgData.forEach((row) => row && Object.keys(row).forEach((key) => {
if (!key.startsWith('section_')) return
if (!avgRow[key]) avgRow[key] = { depth: 0, time: 0, speed: 0, count: 0 }
avgRow[key].depth += row[key].depth ?? 0
avgRow[key].time += row[key].time ?? 0
avgRow[key].speed += row[key].speed ?? 0
avgRow[key].count++
})) }))
])
},
setIsPageLoading,
'Не удалось установить необходимые столбцы'
), [sectionTypes, avgData, cmpSpeedRender])
useEffect(() => invokeWebApiWrapperAsync( Object.values(avgRow).forEach((section) => section.speed /= section.count)
async () => {
const avgData = await getWellData(avgWells)
setAvgData(avgData)
const avgRow = {} setAvgRow(avgRow)
},
setIsAvgTableLoading,
'Не удалось загрузить данные для расчёта средних значений',
)
}, [avgWells])
avgData.forEach((row) => row && Object.keys(row).forEach((key) => { useEffect(() => {
if (!key.startsWith('section_')) return invokeWebApiWrapperAsync(
if (!avgRow[key]) avgRow[key] = { depth: 0, time: 0, speed: 0, count: 0 } async () => {
avgRow[key].depth += row[key].depth ?? 0 const cmpData = await getWellData(cmpWells)
avgRow[key].time += row[key].time ?? 0 setCmpData(cmpData)
avgRow[key].speed += row[key].speed ?? 0 },
avgRow[key].count++ setIsCmpTableLoading,
})) 'Не удалось получить скважины для сравнения',
)
Object.values(avgRow).forEach((section) => section.speed /= section.count) }, [cmpWells])
setAvgRow(avgRow)
},
setIsAvgTableLoading,
'Не удалось загрузить данные для расчёта средних значений',
), [avgWells])
useEffect(() => invokeWebApiWrapperAsync(
async () => {
const cmpData = await getWellData(cmpWells)
setCmpData(cmpData)
},
setIsCmpTableLoading,
'Не удалось получить скважины для сравнения',
), [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',
})

View File

@ -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,33 +15,31 @@ 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 () => {
setIsParamsModalVisible(true) setIsParamsModalVisible(true)
const params = await DrillParamsService.getCompositeAll(idWell) const params = await DrillParamsService.getCompositeAll(idWell)
setParams(params) setParams(params)
}, },
setShowParamsLoader, setShowParamsLoader,
`Не удалось загрузить список режимов для скважины "${idWell}"`, `Не удалось загрузить список режимов для скважины "${idWell}"`,
'Получение списка режимов скважины' 'Получение списка режимов скважины'
), [idWell]) ), [idWell])
const onParamsAddClick = useCallback(() => invokeWebApiWrapperAsync( const onParamsAddClick = useCallback(() => invokeWebApiWrapperAsync(
async () => { async () => {
await DrillParamsService.save(idWell, params) await DrillParamsService.save(idWell, params)
setIsParamsModalVisible(false) setIsParamsModalVisible(false)
}, },
setShowParamsLoader, setShowParamsLoader,
`Не удалось добавить режимы в список скважины "${idWell}"`, `Не удалось добавить режимы в список скважины "${idWell}"`,
'Добавление режима скважины' 'Добавление режима скважины'
), [idWell, params]) ), [idWell, params])
return ( return (
@ -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>
)} )}
> >

View File

@ -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',
})

View File

@ -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(() => {
async () => { invokeWebApiWrapperAsync(
try { async () => {
const selected = await WellCompositeService.get(idWell) try {
setSelectedSections(arrayOrDefault(selected)) setSelectedSections(arrayOrDefault(await WellCompositeService.get(idWell)))
} 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,15 +55,17 @@ export const WellCompositeEditor = memo(({ rootPath }) => {
setSelectedIdWells(wellIds) setSelectedIdWells(wellIds)
}, [selectedSections]) }, [selectedSections])
useEffect(() => invokeWebApiWrapperAsync( useEffect(() => {
async () => { invokeWebApiWrapperAsync(
const stats = arrayOrDefault(await OperationStatService.getWellsStat(selectedIdWells)) async () => {
setStatsWells(stats) const stats = arrayOrDefault(await OperationStatService.getWellsStat(selectedIdWells))
}, setStatsWells(stats)
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)

View File

@ -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',
})

View File

@ -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',
})

View File

@ -1,33 +1,43 @@
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(() => {
async () => { invokeWebApiWrapperAsync(
const clusterData = await OperationStatService.getStatCluster(idCluster) async () => {
setData(arrayOrDefault(clusterData?.statsWells)) const clusterData = await OperationStatService.getStatCluster(idCluster)
}, setData(arrayOrDefault(clusterData?.statsWells))
setShowLoader, },
`Не удалось загрузить данные по кусту "${idCluster}"`, setShowLoader,
'Получение данных по кусту' `Не удалось загрузить данные по кусту "${idCluster}"`,
), [idCluster]) 'Получение данных по кусту'
)
}, [idCluster])
return ( return (
<LoaderPortal show={showLoader}> <LayoutPortal title={'Анализ скважин куста'}>
<ClusterWells statsWells={data} /> <LoaderPortal show={showLoader}>
</LoaderPortal> <ClusterWells statsWells={data} />
) </LoaderPortal>
</LayoutPortal>
)
}) })
export default Cluster export default wrapPrivateComponent(Cluster, {
requirements: ['OperationStat.get'],
title: 'Анализ скважин куста',
route: 'cluster/:idCluster/*',
key: 'cluster',
})

View File

@ -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,49 +36,58 @@ 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(() => {
async () => { invokeWebApiWrapperAsync(
const data = await ClusterService.getClusters() async () => {
setClustersData(arrayOrDefault(data)) const data = await ClusterService.getClusters()
setViewParams(calcViewParams(data)) setClustersData(arrayOrDefault(data))
}, setViewParams(calcViewParams(data))
setShowLoader, },
`Не удалось загрузить список кустов`, setShowLoader,
'Получить список кустов' `Не удалось загрузить список кустов`,
), []) 'Получить список кустов'
)
}, [])
return ( return (
<LoaderPortal show={showLoader}> <LayoutPortal noSheet title={'Месторождение'}>
<div className={'h-100vh'}> <LoaderPortal show={showLoader}>
<Map {...viewParams}> <div className={'h-100vh'}>
{clustersData.map(cluster => ( <Map {...viewParams}>
<Overlay {clustersData.map(cluster => (
width={32} <Overlay
anchor={[cluster.latitude, cluster.longitude]} width={32}
key={`${cluster.latitude} ${cluster.longitude}`} anchor={[cluster.latitude, cluster.longitude]}
> key={`${cluster.latitude} ${cluster.longitude}`}
<Link to={{ pathname: `/cluster/${cluster.id}`, state: { from: location.pathname }}}> >
<PointerIcon state={'active'} width={48} height={59} /> <Link to={{ pathname: `/cluster/${cluster.id}`, state: { from: location.pathname }}}>
<span>{cluster.caption}</span> <PointerIcon state={'active'} width={48} height={59} />
</Link> <span>{cluster.caption}</span>
</Overlay> </Link>
))} </Overlay>
</Map> ))}
</div> </Map>
</LoaderPortal> </div>
</LoaderPortal>
</LayoutPortal>
) )
}) })
export default Deposit export default wrapPrivateComponent(Deposit, {
requirements: ['Cluster.get'],
title: 'Месторождение',
route: 'deposit/*',
key: 'deposit',
})

View File

@ -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)

View File

@ -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 && (
<DocumentsTemplate <Route index element={<Navigate to={categories[0].key} replace />} />
key={category.key} )}
idCategory={category.id} <Route path={'*'} element={<NoAccessComponent />} />
tableName={`documents_${category.key}`}
/> {categories.map(category => (
<Route key={category.key} path={category.key} element={(
<DocumentsTemplate
idCategory={category.id}
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',
})

View File

@ -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,18 +21,20 @@ 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(() => {
async () => { invokeWebApiWrapperAsync(
setOptions(categories.map((category) => ({ async () => {
label: category.name ?? category.shortName, setOptions(categories.map((category) => ({
value: category.id label: category.name ?? category.shortName,
}))) value: category.id
}, })))
setShowCatLoader, },
`Не удалось установить список доступных категорий для добавления` setShowCatLoader,
), [categories]) `Не удалось установить список доступных категорий для добавления`
)
}, [categories])
const onFinish = useCallback(({ categories }) => invokeWebApiWrapperAsync( const onFinish = useCallback(({ categories }) => invokeWebApiWrapperAsync(
async () => { async () => {

View File

@ -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,27 +30,32 @@ 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(() => {
async () => { invokeWebApiWrapperAsync(
const filteredUsers = users.filter(({ user }) => user && [ async () => {
user.login ?? '', const filteredUsers = users.filter(({ user }) => user && [
user.name ?? '', user.login ?? '',
user.surname ?? '', user.name ?? '',
user.partonymic ?? '', user.surname ?? '',
user.email ?? '', user.partonymic ?? '',
user.phone ?? '', user.email ?? '',
user.position ?? '', user.phone ?? '',
user.company?.caption ?? '', user.position ?? '',
].join(' ').toLowerCase().includes(searchValue)) user.company?.caption ?? '',
setFilteredUsers(filteredUsers) ].join(' ').toLowerCase().includes(searchValue))
}, setFilteredUsers(filteredUsers)
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(() => {
async () => { invokeWebApiWrapperAsync(
const allUsers = arrayOrDefault(await DrillingProgramService.getAvailableUsers(idWell)) async () => {
setAllUsers(allUsers) const allUsers = arrayOrDefault(await DrillingProgramService.getAvailableUsers(idWell))
}, setAllUsers(allUsers)
setShowLoader, },
`Не удалось загрузить список доступных пользователей скважины "${idWell}"` setShowLoader,
), [idWell]) `Не удалось загрузить список доступных пользователей скважины "${idWell}"`
)
}, [idWell])
const calcUsers = useCallback(() => { const calcUsers = useCallback(() => {
if (!visible) return if (!visible) return

View File

@ -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,21 +65,23 @@ 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(() => {
async () => { invokeWebApiWrapperAsync(
if (!visible) return async () => {
const [begin, end] = range?.length > 1 ? [range[0].toISOString(), range[1].toISOString()] : [null, null] if (!visible) return
const skip = (page - 1) * pageSize const [begin, end] = range?.length > 1 ? [range[0].toISOString(), range[1].toISOString()] : [null, null]
const skip = (page - 1) * pageSize
const paginatedHistory = await FileService.getFilesInfo(idWell, idCategory, companyName, fileName, begin, end, skip, pageSize) const paginatedHistory = await FileService.getFilesInfo(idWell, idCategory, companyName, fileName, begin, end, skip, pageSize)
setTotal(paginatedHistory?.count ?? 0) setTotal(paginatedHistory?.count ?? 0)
setData(arrayOrDefault(paginatedHistory?.items)) setData(arrayOrDefault(paginatedHistory?.items))
}, },
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)

View File

@ -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])

View 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