Merge remote-tracking branch 'origin/dev'

This commit is contained in:
goodmice 2022-10-25 19:05:26 +05:00
commit d7669a2317
No known key found for this signature in database
GPG Key ID: 63EA771203189CF1
194 changed files with 14360 additions and 7257 deletions

4
.gitignore vendored
View File

@ -11,8 +11,10 @@
# testing # testing
/coverage /coverage
# production # build directories
/build /build
/dev_build
/prod_build
# misc # misc
.DS_Store .DS_Store

View File

@ -38,10 +38,10 @@ npx openapi -i http://{IP_ADDRESS}:{PORT}/swagger/v1/swagger.json -o src/service
| IP-адрес | Описание | | IP-адрес | Описание |
|:-|:-| |:-|:-|
| 127.0.0.1:5000 | Локальный адрес вашей машины (привязан к `update_openapi`) | | 127.0.0.1:5000 | Локальный адрес вашей машины (привязан к `update_openapi`) |
| 192.168.1.70:5000 | Локальный адрес development-сервера (привязан к `update_openapi_server`) | | 192.168.1.113:5000 | Локальный адрес development-сервера (привязан к `update_openapi_server`) |
| 46.146.209.148:89 | Внешний адрес development-сервера | | 46.146.209.148:89 | Внешний адрес development-сервера |
| 46.146.209.148 | Внешний адрес production-сервера | | cloud.digitaldrilling.ru | Внешний адрес production-сервера |
## 3. Компиляция production-версии приложения ## 3. Компиляция production-версии приложения
После выполнения вышеописанных пунктов приложение готово к компиляции. После выполнения вышеописанных пунктов приложение готово к компиляции.

15925
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,12 +16,18 @@
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
"scripts": { "scripts": {
"start": "webpack-dev-server --mode=development --open --hot",
"build": "webpack --mode=production",
"test": "jest", "test": "jest",
"build": "webpack --env=\"ENV=prod\"",
"dev_build": "webpack --env=\"ENV=dev\"",
"prod_build": "webpack --env=\"ENV=prod\"",
"start": "webpack-dev-server --env=\"ENV=dev\" --open --hot",
"prod": "webpack-dev-server --env=\"ENV=prod\" --open --hot",
"dev": "webpack-dev-server --env=\"ENV=dev\" --open --hot",
"oul": "npx openapi -i http://127.0.0.1:5000/swagger/v1/swagger.json -o src/services/api", "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.113: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 https://cloud.digitaldrilling.ru/swagger/v1/swagger.json -o src/services/api",
"oug_dev": "npx openapi -i http://46.146.209.148:89/swagger/v1/swagger.json -o src/services/api" "oug_dev": "npx openapi -i http://46.146.209.148:89/swagger/v1/swagger.json -o src/services/api"
}, },
"proxy": "http://46.146.209.148:89", "proxy": "http://46.146.209.148:89",
@ -83,24 +89,30 @@
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"babel-jest": "^28.1.0", "babel-jest": "^28.1.0",
"babel-loader": "^8.2.5", "babel-loader": "^8.2.5",
"colors": "^1.4.0",
"css-loader": "^6.7.1", "css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^4.2.0",
"extract-loader": "^5.1.0",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0", "html-webpack-plugin": "^5.5.0",
"interpolate-html-plugin": "^4.0.0", "interpolate-html-plugin": "^4.0.0",
"jest": "^28.1.0", "jest": "^28.1.0",
"less": "^4.1.3", "less": "^4.1.3",
"less-loader": "^11.0.0", "less-loader": "^11.0.0",
"mini-css-extract-plugin": "^2.6.1",
"openapi-typescript": "^5.4.0", "openapi-typescript": "^5.4.0",
"openapi-typescript-codegen": "^0.23.0", "openapi-typescript-codegen": "^0.23.0",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"react-test-renderer": "^18.1.0", "react-test-renderer": "^18.1.0",
"source-map-loader": "^3.0.1", "source-map-loader": "^3.0.1",
"style-loader": "^3.3.1", "style-loader": "^3.3.1",
"terser-webpack-plugin": "^5.3.6",
"ts-loader": "^9.3.0", "ts-loader": "^9.3.0",
"typescript": "^4.7.4", "typescript": "^4.7.4",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"webpack": "^5.73.0", "webpack": "^5.73.0",
"webpack-cli": "^4.9.2", "webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.9.1" "webpack-dev-server": "^4.9.1",
"webpack-merge": "^5.8.0"
} }
} }

View File

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

View File

@ -1,48 +1,62 @@
import { BrowserRouter as Router, Navigate, Route, Routes } from 'react-router-dom' import { BrowserRouter as Router, Navigate, Route, Routes } from 'react-router-dom'
import { memo } from 'react' import { lazy, memo, Suspense } from 'react'
import { ConfigProvider } from 'antd'
import locale from 'antd/lib/locale/ru_RU' import locale from 'antd/lib/locale/ru_RU'
import { ConfigProvider } from 'antd'
import { RootPathContext } from '@asb/context' import { RootPathContext } from '@asb/context'
import { getUserToken, NoAccessComponent } from '@utils' import { UserOutlet } from '@components/outlets'
import LayoutPortal from '@components/LayoutPortal'
import SuspenseFallback from '@components/SuspenseFallback'
import { getUser, NoAccessComponent } from '@utils'
import { OpenAPI } from '@api' import { OpenAPI } from '@api'
import AdminPanel from '@pages/AdminPanel' import '@styles/include/antd_theme.less'
import Well from '@pages/Well'
import Login from '@pages/Login'
import Cluster from '@pages/Cluster'
import Deposit from '@pages/Deposit'
import Register from '@pages/Register'
import FileDownload from '@pages/FileDownload'
import '@styles/App.less' import '@styles/App.less'
const Login = lazy(() => import('@pages/public/Login'))
const Register = lazy(() => import('@pages/public/Register'))
const FileDownload = lazy(() => import('@pages/FileDownload'))
const AdminPanel = lazy(() => import('@pages/AdminPanel'))
const Deposit = lazy(() => import('@pages/Deposit'))
const Cluster = lazy(() => import('@pages/Cluster'))
const Well = lazy(() => import('@asb/pages/Well'))
//OpenAPI.BASE = 'http://localhost:3000' //OpenAPI.BASE = 'http://localhost:3000'
OpenAPI.TOKEN = async () => getUserToken() ?? '' // TODO: Удалить взятие из 'token' в следующем релизе, вставлено для совместимости
OpenAPI.HEADERS = {'Content-Type': 'application/json'} OpenAPI.TOKEN = async () => getUser().token || localStorage.getItem('token') || ''
OpenAPI.HEADERS = { 'Content-Type': 'application/json' }
export const App = memo(() => ( export const App = memo(() => (
<ConfigProvider locale={locale}> <ConfigProvider locale={locale}>
<RootPathContext.Provider value={''}> <RootPathContext.Provider value={''}>
<Router> <Suspense fallback={<SuspenseFallback style={{ minHeight: '100vh' }} />}>
<Routes> <Router>
<Route index element={<Navigate to={Deposit.getKey()} replace />} /> <Routes>
<Route path={'*'} element={<NoAccessComponent />} /> <Route index element={<Navigate to={'deposit'} replace />} />
<Route path={'*'} element={<NoAccessComponent />} />
{/* Public pages */} {/* Public pages */}
<Route path={Login.route} element={<Login />} /> <Route path={'/login'} element={<Login />} />
<Route path={Register.route} element={<Register />} /> <Route path={'/register'} element={<Register />} />
{/* Admin pages */} {/* User pages */}
<Route path={AdminPanel.route} element={<AdminPanel />} /> <Route element={<UserOutlet />}>
<Route path={'/file_download/:idWell/:idFile/*'} element={<FileDownload />} />
{/* User pages */} <Route element={<LayoutPortal />}>
<Route path={Deposit.route} element={<Deposit />} /> {/* Admin pages */}
<Route path={Cluster.route} element={<Cluster />} /> <Route path={'/admin/*'} element={<AdminPanel />} />
<Route path={Well.route} element={<Well />} />
<Route path={FileDownload.route} element={<FileDownload />} /> {/* Client pages */}
</Routes> <Route path={'/deposit/*'} element={<Deposit />} />
</Router> <Route path={'/cluster/:idCluster'} element={<Cluster />} />
<Route path={'/well/:idWell/*'} element={<Well />} />
</Route>
</Route>
</Routes>
</Router>
</Suspense>
</RootPathContext.Provider> </RootPathContext.Provider>
</ConfigProvider> </ConfigProvider>
)) ))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

@ -12,7 +12,6 @@ export {
makeNumericColumnPlanFact, makeNumericColumnPlanFact,
makeNumericStartEnd, makeNumericStartEnd,
makeNumericMinMax, makeNumericMinMax,
makeNumericAvgRange
} from './numeric' } from './numeric'
export { makeColumnsPlanFact } from './plan_fact' export { makeColumnsPlanFact } from './plan_fact'
export { makeSelectColumn } from './select' export { makeSelectColumn } from './select'
@ -31,7 +30,7 @@ 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
export type columnPropsOther<T = any> = ColumnProps<T> & { export type columnPropsOther<T = any> = ColumnProps<DataType<T>> & {
// редактируемая колонка // редактируемая колонка
editable?: boolean editable?: boolean
// react компонента редактора // react компонента редактора

View File

@ -2,11 +2,14 @@ import { InputNumber } from 'antd'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { makeNumericSorter } from '../sorters' import { makeNumericSorter } from '../sorters'
import { columnPropsOther, makeGroupColumn, RenderMethod } from '.' import makeColumn, { columnPropsOther, DataType, makeGroupColumn, RenderMethod } from '.'
import { ColumnFilterItem, CompareFn } from 'antd/lib/table/interface'
export const RegExpIsFloat = /^[-+]?\d+\.?\d*$/ export const RegExpIsFloat = /^[-+]?\d+\.?\d*$/
export const makeNumericRender = <T extends unknown>(fixed?: number): RenderMethod<T> => (value) => { type FilterMethod<T> = (value: string | number | boolean, record: DataType<T>) => boolean
export const makeNumericRender = <T extends unknown>(fixed?: number): RenderMethod<T> => (value: T) => {
let val = '-' let val = '-'
if ((value ?? null) !== null && Number.isFinite(+value)) { if ((value ?? null) !== null && Number.isFinite(+value)) {
val = (fixed ?? null) !== null val = (fixed ?? null) !== null
@ -21,91 +24,74 @@ export const makeNumericRender = <T extends unknown>(fixed?: number): RenderMeth
) )
} }
export const makeNumericColumnOptions = (fixed?: number, sorterKey?: string): columnPropsOther => ({ export const makeNumericColumnOptions = <T extends unknown = any>(fixed?: number, sorterKey?: string): columnPropsOther<T> => ({
editable: true, editable: true,
initialValue: 0, initialValue: 0,
width: 100, width: 100,
sorter: sorterKey ? makeNumericSorter(sorterKey) : undefined, sorter: sorterKey ? makeNumericSorter<T>(sorterKey) : undefined,
formItemRules: [{ formItemRules: [{
required: true, required: true,
message: 'Введите число', message: 'Введите число',
pattern: RegExpIsFloat, pattern: RegExpIsFloat,
}], }],
render: makeNumericRender(fixed), render: makeNumericRender<T>(fixed),
}) })
export const makeNumericColumn = ( export const makeNumericColumn = <T extends unknown = any>(
title: ReactNode, title: ReactNode,
dataIndex: string, key: string,
filters: object[], filters?: ColumnFilterItem[],
filterDelegate: (key: string | number) => any, filterDelegate?: (key: string | number) => FilterMethod<T>,
renderDelegate: (_: any, row: object) => any, renderDelegate?: RenderMethod<T>,
width: string, width?: string | number,
other?: columnPropsOther other?: columnPropsOther,
) => ({ ) => makeColumn(title, key, {
title: title, filters,
dataIndex: dataIndex, onFilter: filterDelegate ? filterDelegate(key) : undefined,
key: dataIndex, sorter: makeNumericSorter(key),
filters: filters, width,
onFilter: filterDelegate ? filterDelegate(dataIndex) : null,
sorter: makeNumericSorter(dataIndex),
width: width,
input: <InputNumber style={{ width: '100%' }}/>, input: <InputNumber style={{ width: '100%' }}/>,
render: renderDelegate ?? makeNumericRender(), render: renderDelegate ?? makeNumericRender<T>(2),
align: 'right', align: 'right',
...other ...other
}) })
export const makeNumericColumnPlanFact = ( export const makeNumericColumnPlanFact = <T extends unknown = any>(
title: ReactNode, title: ReactNode,
dataIndex: string, key: string,
filters: object[], filters?: ColumnFilterItem[],
filterDelegate: (key: string | number) => any, filterDelegate?: (key: string | number) => FilterMethod<T>,
renderDelegate: (_: any, row: object) => any, renderDelegate?: RenderMethod<T>,
width: string width?: string | number
) => makeGroupColumn(title, [ ) => makeGroupColumn(title, [
makeNumericColumn('п', dataIndex + 'Plan', filters, filterDelegate, renderDelegate, width), makeNumericColumn<T>('п', key + 'Plan', filters, filterDelegate, renderDelegate, width),
makeNumericColumn('ф', dataIndex + 'Fact', filters, filterDelegate, renderDelegate, width), makeNumericColumn<T>('ф', key + 'Fact', filters, filterDelegate, renderDelegate, width),
]) ])
export const makeNumericStartEnd = ( export const makeNumericStartEnd = <T extends unknown = any>(
title: ReactNode, title: ReactNode,
dataIndex: string, key: string,
fixed: number, fixed: number,
filters: object[], filters?: ColumnFilterItem[],
filterDelegate: (key: string | number) => any, filterDelegate?: (key: string | number) => FilterMethod<T>,
renderDelegate: (_: any, row: object) => any, renderDelegate?: RenderMethod<T>,
width: string, width?: string | number,
) => makeGroupColumn(title, [ ) => makeGroupColumn(title, [
makeNumericColumn('старт', dataIndex + 'Start', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Start')), makeNumericColumn<T>('старт', key + 'Start', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, key + 'Start')),
makeNumericColumn('конец', dataIndex + 'End', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'End')) makeNumericColumn<T>('конец', key + 'End', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, key + 'End'))
]) ])
export const makeNumericMinMax = ( export const makeNumericMinMax = <T extends unknown = any>(
title: ReactNode, title: ReactNode,
dataIndex: string, key: string,
fixed: number, fixed: number,
filters: object[], filters?: ColumnFilterItem[],
filterDelegate: (key: string | number) => any, filterDelegate?: (key: string | number) => FilterMethod<T>,
renderDelegate: (_: any, row: object) => any, renderDelegate?: RenderMethod<T>,
width: string, width?: string | number,
) => makeGroupColumn(title, [ ) => makeGroupColumn(title, [
makeNumericColumn('мин', dataIndex + 'Min', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Min')), makeNumericColumn<T>('мин', key + 'Min', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, key + 'Min')),
makeNumericColumn('макс', dataIndex + 'Max', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Max')), makeNumericColumn<T>('макс', key + 'Max', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, key + 'Max')),
])
export const makeNumericAvgRange = (
title: ReactNode,
dataIndex: string,
fixed: number,
filters: object[],
filterDelegate: (key: string | number) => any,
renderDelegate: (_: any, row: object) => any,
width: string
) => makeGroupColumn(title, [
makeNumericColumn('мин', dataIndex + 'Min', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Min')),
makeNumericColumn('сред', dataIndex + 'Avg', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Avg')),
makeNumericColumn('макс', dataIndex + 'Max', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Max'))
]) ])
export default makeNumericColumn export default makeNumericColumn

View File

@ -1,6 +1,7 @@
import { ColumnFilterItem } from 'antd/lib/table/interface'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { columnPropsOther, DataType, RenderMethod, SorterMethod } from '.' import makeColumn, { columnPropsOther, DataType, RenderMethod, SorterMethod } from '.'
import { makeStringSorter } from '../sorters' import { makeStringSorter } from '../sorters'
export const makeFilterTextMatch = <T extends unknown>(key: keyof DataType<T>) => export const makeFilterTextMatch = <T extends unknown>(key: keyof DataType<T>) =>
@ -8,18 +9,15 @@ export const makeFilterTextMatch = <T extends unknown>(key: keyof DataType<T>) =
export const makeTextColumn = <T extends unknown = any>( export const makeTextColumn = <T extends unknown = any>(
title: ReactNode, title: ReactNode,
dataIndex: string, key: string,
filters: object[], filters?: ColumnFilterItem[],
sorter?: SorterMethod<T>, sorter?: SorterMethod<T>,
render?: RenderMethod<T>, render?: RenderMethod<T>,
other?: columnPropsOther other?: columnPropsOther
) => ({ ) => makeColumn(title, key, {
title: title, filters,
dataIndex: dataIndex, onFilter: filters ? makeFilterTextMatch(key) : undefined,
key: dataIndex, sorter: sorter ?? makeStringSorter(key),
filters: filters,
onFilter: filters ? makeFilterTextMatch(dataIndex) : null,
sorter: sorter ?? makeStringSorter(dataIndex),
render: render, render: render,
...other ...other
}) })

View File

@ -11,6 +11,7 @@ const { RangePicker } = DatePicker
export type DateRangeWrapperProps = RangePickerSharedProps<Moment> & { export type DateRangeWrapperProps = RangePickerSharedProps<Moment> & {
value?: RangeValue<Moment>, value?: RangeValue<Moment>,
isUTC?: boolean isUTC?: boolean
allowClear?: boolean
} }
const normalizeDates = (value?: RangeValue<Moment>, isUTC?: boolean): RangeValue<Moment> => { const normalizeDates = (value?: RangeValue<Moment>, isUTC?: boolean): RangeValue<Moment> => {
@ -21,10 +22,10 @@ const normalizeDates = (value?: RangeValue<Moment>, isUTC?: boolean): RangeValue
] ]
} }
export const DateRangeWrapper = memo<DateRangeWrapperProps>(({ value, isUTC, ...other }) => ( export const DateRangeWrapper = memo<DateRangeWrapperProps>(({ value, isUTC, allowClear = false, ...other }) => (
<RangePicker <RangePicker
showTime showTime
allowClear={false} allowClear={allowClear}
format={defaultFormat} format={defaultFormat}
defaultValue={[ defaultValue={[
moment().subtract(1, 'days').startOf('day'), moment().subtract(1, 'days').startOf('day'),

View File

@ -10,18 +10,17 @@ 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> = ColumnGroupType<T> | ColumnType<T>
export type TableColumns<T = any> = OmitExtends<BaseTableColumn<T>, TableColumnSettings>[] export type TableColumns<T> = OmitExtends<BaseTableColumn<T>, TableColumnSettings>[]
export type TableContainer = TableProps<any> & { export type TableContainer<T> = TableProps<T> & {
columns: TableColumns columns: TableColumns<T>
dataSource: any[]
tableName?: string tableName?: string
showSettingsChanger?: boolean showSettingsChanger?: boolean
} }
export const Table = memo<TableContainer>(({ columns, dataSource, tableName, showSettingsChanger, ...other }) => { const _Table = <T extends object>({ columns, dataSource, tableName, showSettingsChanger, ...other }: TableContainer<T>) => {
const [newColumns, setNewColumns] = useState<TableColumns>([]) const [newColumns, setNewColumns] = useState<TableColumns<T>>([])
const [settings, setSettings] = useState<TableSettings>({}) const [settings, setSettings] = useState<TableSettings>({})
const onSettingsChanged = useCallback((settings?: TableSettings | null) => { const onSettingsChanged = useCallback((settings?: TableSettings | null) => {
@ -52,6 +51,8 @@ export const Table = memo<TableContainer>(({ columns, dataSource, tableName, sho
{...other} {...other}
/> />
) )
}) }
export const Table = memo(_Table) as typeof _Table
export default Table export default Table

View File

@ -7,7 +7,7 @@ import { TableColumnSettings, makeTableSettings, mergeTableSettings, TableSettin
import { TableColumns } from './Table' import { TableColumns } from './Table'
import { makeColumn } from '.' import { makeColumn } from '.'
const parseSettings = (columns?: TableColumns, settings?: TableSettings | null): TableColumnSettings[] => { const parseSettings = <T extends object>(columns?: TableColumns<T>, settings?: TableSettings | null): TableColumnSettings[] => {
const newSettings = mergeTableSettings(makeTableSettings(columns ?? []), settings ?? {}) 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 }))
} }
@ -15,14 +15,14 @@ const parseSettings = (columns?: TableColumns, settings?: TableSettings | null):
const unparseSettings = (columns: TableColumnSettings[]): 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<T extends object> = {
title?: string title?: string
columns?: TableColumns columns?: TableColumns<T>
settings?: TableSettings | null settings?: TableSettings | null
onChange: (settings: TableSettings | null) => void onChange: (settings: TableSettings | null) => void
} }
export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, columns, settings, onChange }) => { const _TableSettingsChanger = <T extends object>({ title, columns, settings, onChange }: TableSettingsChangerProps<T>) => {
const [visible, setVisible] = useState<boolean>(false) const [visible, setVisible] = useState<boolean>(false)
const [newSettings, setNewSettings] = useState<TableColumnSettings[]>(parseSettings(columns, settings)) const [newSettings, setNewSettings] = useState<TableColumnSettings[]>(parseSettings(columns, settings))
const [tableColumns, setTableColumns] = useState<ColumnsType<TableColumnSettings>>([]) const [tableColumns, setTableColumns] = useState<ColumnsType<TableColumnSettings>>([])
@ -36,10 +36,12 @@ export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, co
}, []) }, [])
const toogleAll = useCallback((show: boolean) => { const toogleAll = useCallback((show: boolean) => {
setNewSettings((oldSettings) => oldSettings.map((column) => { setNewSettings((oldSettings) =>
column.visible = show oldSettings.map((column) => {
return column column.visible = show
})) return column
})
)
}, []) }, [])
useEffect(() => { useEffect(() => {
@ -49,7 +51,9 @@ export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, co
title: () => ( title: () => (
<> <>
Показать Показать
<Button type={'link'} onClick={() => toogleAll(true)}>Показать все</Button> <Button type={'link'} onClick={() => toogleAll(true)}>
Показать все
</Button>
</> </>
), ),
render: (visible: boolean, _?: TableColumnSettings, index: number = NaN) => ( render: (visible: boolean, _?: TableColumnSettings, index: number = NaN) => (
@ -59,7 +63,7 @@ export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, co
unCheckedChildren={'Скрыт'} unCheckedChildren={'Скрыт'}
onChange={(visible) => onVisibilityChange(index, visible)} onChange={(visible) => onVisibilityChange(index, visible)}
/> />
) ),
}), }),
]) ])
}, [toogleAll, onVisibilityChange]) }, [toogleAll, onVisibilityChange])
@ -80,7 +84,7 @@ export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, co
<> <>
<Modal <Modal
centered centered
visible={visible} open={visible}
onCancel={onModalCancel} onCancel={onModalCancel}
onOk={onModalOk} onOk={onModalOk}
title={title ?? 'Настройка отображения таблицы'} title={title ?? 'Настройка отображения таблицы'}
@ -88,9 +92,17 @@ export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, co
> >
<Table columns={tableColumns} dataSource={newSettings} /> <Table columns={tableColumns} dataSource={newSettings} />
</Modal> </Modal>
<Button size={'small'} style={{ position: 'absolute', left: 0, top: 0, opacity: .5 }} type={'link'} onClick={() => setVisible(true)} icon={<SettingOutlined />}/> <Button
size={'small'}
style={{ position: 'absolute', left: 0, top: 0, opacity: 0.5 }}
type={'link'}
onClick={() => setVisible(true)}
icon={<SettingOutlined />}
/>
</> </>
) )
}) }
export const TableSettingsChanger = memo(_TableSettingsChanger) as typeof _TableSettingsChanger
export default TableSettingsChanger export default TableSettingsChanger

View File

@ -20,7 +20,6 @@ export {
makeNumericColumnPlanFact, makeNumericColumnPlanFact,
makeNumericStartEnd, makeNumericStartEnd,
makeNumericMinMax, makeNumericMinMax,
makeNumericAvgRange,
makeSelectColumn, makeSelectColumn,
makeTagColumn, makeTagColumn,
makeTagInput, makeTagInput,

View File

@ -3,10 +3,14 @@ import { isRawDate } from '@utils'
import { TimeDto } from '@api' import { TimeDto } from '@api'
import { DataType } from './Columns' import { DataType } from './Columns'
import { CompareFn } from 'antd/lib/table/interface'
export const makeNumericSorter = <T extends unknown>(key: keyof DataType<T>) => export const makeNumericSorter = <T extends unknown>(key: keyof DataType<T>): CompareFn<DataType<T>> =>
(a: DataType<T>, b: DataType<T>) => Number(a[key]) - Number(b[key]) (a: DataType<T>, b: DataType<T>) => Number(a[key]) - Number(b[key])
export const makeNumericObjSorter = (key: [string, string]) =>
(a: DataType, b: DataType) => Number(a[key[0]][key[1]]) - Number(b[key[0]][key[1]])
export const makeStringSorter = <T extends unknown>(key: keyof DataType<T>) => (a?: DataType<T> | null, b?: DataType<T> | null) => { export const makeStringSorter = <T extends unknown>(key: keyof DataType<T>) => (a?: DataType<T> | null, b?: DataType<T> | null) => {
if (!a && !b) return 0 if (!a && !b) return 0
if (!a) return 1 if (!a) return 1

View File

@ -10,6 +10,7 @@ import { notify, upload } from './factory'
import { ErrorFetch } from './ErrorFetch' import { ErrorFetch } from './ErrorFetch'
export type UploadFormProps = { export type UploadFormProps = {
multiple?: boolean
url: string url: string
disabled?: boolean disabled?: boolean
accept?: string accept?: string
@ -22,7 +23,7 @@ export type UploadFormProps = {
onUploadError?: (error: unknown) => void onUploadError?: (error: unknown) => void
} }
export const UploadForm = memo<UploadFormProps>(({ url, disabled, style, formData, mimeTypes, onUploadStart, onUploadSuccess, onUploadComplete, onUploadError }) => { export const UploadForm = memo<UploadFormProps>(({ url, multiple, disabled, style, formData, mimeTypes, onUploadStart, onUploadSuccess, onUploadComplete, onUploadError }) => {
const [fileList, setfileList] = useState<UploadFile<any>[]>([]) const [fileList, setfileList] = useState<UploadFile<any>[]>([])
const checkMimeTypes = useCallback((file: RcFile) => { const checkMimeTypes = useCallback((file: RcFile) => {
@ -38,7 +39,7 @@ export const UploadForm = memo<UploadFormProps>(({ url, disabled, style, formDat
onUploadStart?.() onUploadStart?.()
try { try {
const formDataLocal = new FormData() const formDataLocal = new FormData()
fileList.forEach((val) => formDataLocal.append('files', val.originFileObj as Blob)) fileList.forEach((val) => formDataLocal.append(multiple ? 'files' : 'file', val.originFileObj as Blob))
if(formData) if(formData)
for(const propName in formData) for(const propName in formData)
@ -60,7 +61,7 @@ export const UploadForm = memo<UploadFormProps>(({ url, disabled, style, formDat
setfileList([]) setfileList([])
onUploadComplete?.() onUploadComplete?.()
} }
}, [fileList, formData, onUploadComplete, onUploadError, onUploadStart, onUploadSuccess, url]) }, [fileList, formData, onUploadComplete, onUploadError, onUploadStart, onUploadSuccess, url, multiple])
const isSendButtonEnabled = fileList.length > 0 const isSendButtonEnabled = fileList.length > 0
return( return(
@ -72,6 +73,7 @@ export const UploadForm = memo<UploadFormProps>(({ url, disabled, style, formDat
fileList={fileList} fileList={fileList}
onChange={(props) => setfileList(props.fileList)} onChange={(props) => setfileList(props.fileList)}
beforeUpload={checkMimeTypes} beforeUpload={checkMimeTypes}
maxCount={multiple ? undefined : 1}
> >
<Button disabled={disabled} icon={<UploadOutlined/>}>Загрузить файл</Button> <Button disabled={disabled} icon={<UploadOutlined/>}>Загрузить файл</Button>
</Upload> </Upload>

View File

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

View File

@ -27,6 +27,7 @@ import {
D3TooltipSettings, D3TooltipSettings,
} from './plugins' } from './plugins'
import type { import type {
BaseDataType,
ChartAxis, ChartAxis,
ChartDataset, ChartDataset,
ChartDomain, ChartDomain,
@ -50,13 +51,13 @@ export const getByAccessor = <DataType extends Record<any, any>, R>(accessor: ke
return (d) => d[accessor] return (d) => d[accessor]
} }
const createAxis = <DataType,>(config: ChartAxis<DataType>) => { const createAxis = <DataType extends BaseDataType>(config: ChartAxis<DataType>) => {
if (config.type === 'time') if (config.type === 'time')
return d3.scaleTime() return d3.scaleTime()
return d3.scaleLinear() return d3.scaleLinear()
} }
export type D3ChartProps<DataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & { export type D3ChartProps<DataType extends BaseDataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
/** Параметры общей горизонтальной оси */ /** Параметры общей горизонтальной оси */
xAxis: ChartAxis<DataType> xAxis: ChartAxis<DataType>
/** Параметры графиков */ /** Параметры графиков */
@ -94,7 +95,7 @@ export type D3ChartProps<DataType> = React.DetailedHTMLProps<React.HTMLAttribute
} }
} }
const getDefaultXAxisConfig = <DataType,>(): ChartAxis<DataType> => ({ const getDefaultXAxisConfig = <DataType extends BaseDataType>(): ChartAxis<DataType> => ({
type: 'time', type: 'time',
accessor: (d: any) => new Date(d.date) accessor: (d: any) => new Date(d.date)
}) })

View File

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

View File

@ -6,13 +6,14 @@ import { useD3MouseZone } from '@components/d3/D3MouseZone'
import { D3TooltipPosition } from '@components/d3/plugins/D3Tooltip' import { D3TooltipPosition } from '@components/d3/plugins/D3Tooltip'
import { getChartIcon, isDev, usePartialProps } from '@utils' import { getChartIcon, isDev, usePartialProps } from '@utils'
import { BaseDataType } from '../types'
import { ChartGroup, ChartSizes } from './D3MonitoringCharts' import { ChartGroup, ChartSizes } from './D3MonitoringCharts'
import '@styles/d3.less' import '@styles/d3.less'
type D3GroupRenderFunction<DataType> = (group: ChartGroup<DataType>, data: DataType[]) => ReactNode type D3GroupRenderFunction<DataType extends BaseDataType> = (group: ChartGroup<DataType>, data: DataType[]) => ReactNode
export type D3HorizontalCursorSettings<DataType> = { export type D3HorizontalCursorSettings<DataType extends BaseDataType> = {
width?: number width?: number
height?: number height?: number
render?: D3GroupRenderFunction<DataType> render?: D3GroupRenderFunction<DataType>
@ -23,11 +24,12 @@ export type D3HorizontalCursorSettings<DataType> = {
lineStyle?: SVGProps<SVGLineElement> lineStyle?: SVGProps<SVGLineElement>
} }
export type D3HorizontalCursorProps<DataType> = D3HorizontalCursorSettings<DataType> & { export type D3HorizontalCursorProps<DataType extends BaseDataType> = D3HorizontalCursorSettings<DataType> & {
groups: ChartGroup<DataType>[] groups: ChartGroup<DataType>[]
data: DataType[] data: DataType[]
sizes: ChartSizes sizes: ChartSizes
yAxis?: d3.ScaleTime<number, number> yAxis?: d3.ScaleTime<number, number>
spaceBetweenGroups?: number
} }
const defaultLineStyle: SVGProps<SVGLineElement> = { const defaultLineStyle: SVGProps<SVGLineElement> = {
@ -36,7 +38,7 @@ const defaultLineStyle: SVGProps<SVGLineElement> = {
const offsetY = 5 const offsetY = 5
const makeDefaultRender = <DataType,>(): D3GroupRenderFunction<DataType> => (group, data) => ( const makeDefaultRender = <DataType extends BaseDataType>(): D3GroupRenderFunction<DataType> => (group, data) => (
<> <>
{data.length > 0 ? group.charts.map((chart) => { {data.length > 0 ? group.charts.map((chart) => {
const xFormat = (d: number | Date) => chart.xAxis.format?.(d) ?? `${(+d).toFixed(2)} ${chart.xAxis.unit ?? ''}` const xFormat = (d: number | Date) => chart.xAxis.format?.(d) ?? `${(+d).toFixed(2)} ${chart.xAxis.unit ?? ''}`
@ -61,8 +63,8 @@ const makeDefaultRender = <DataType,>(): D3GroupRenderFunction<DataType> => (gro
</> </>
) )
const _D3HorizontalCursor = <DataType,>({ const _D3HorizontalCursor = <DataType extends BaseDataType>({
width = 220, spaceBetweenGroups = 30,
height = 200, height = 200,
render = makeDefaultRender<DataType>(), render = makeDefaultRender<DataType>(),
position: _position = 'bottom', position: _position = 'bottom',
@ -139,7 +141,7 @@ const _D3HorizontalCursor = <DataType,>({
if (!mouseState.visible || fixed) return if (!mouseState.visible || fixed) return
let top = mouseState.y + offsetY let top = mouseState.y + offsetY
if (top + height >= sizes.chartsHeight) { if (mouseState.y >= sizes.chartsHeight / 2) {
setPosition('bottom') setPosition('bottom')
top = mouseState.y - offsetY - height top = mouseState.y - offsetY - height
} else { } else {
@ -178,17 +180,21 @@ const _D3HorizontalCursor = <DataType,>({
{groups.map((_, i) => ( {groups.map((_, i) => (
<foreignObject <foreignObject
key={`${i}`} key={`${i}`}
width={width} width={sizes.groupWidth + spaceBetweenGroups}
height={height} height={height}
x={sizes.groupLeft(i) + (sizes.groupWidth - width) / 2} x={sizes.groupLeft(i) - spaceBetweenGroups / 2}
y={tooltipY} y={tooltipY}
opacity={fixed || mouseState.visible ? 1 : 0} opacity={fixed || mouseState.visible ? 1 : 0}
pointerEvents={fixed ? 'all' : 'none'} pointerEvents={fixed ? 'all' : 'none'}
style={{ transition: 'opacity .1s ease-out', userSelect: fixed ? 'auto' : 'none' }} style={{ transition: 'opacity .1s ease-out', userSelect: fixed ? 'auto' : 'none' }}
> >
<div className={`tooltip ${position} ${className}`}> <div className={'tooltip-wrapper'}>
<div className={'tooltip-content'}> <div className={`adaptive-tooltip tooltip ${position} ${className}`}
{tooltipBodies[i]} style={{height: 'auto', bottom: `${position === 'bottom' ? '0' : ''}`}}
>
<div className={'tooltip-content'}>
{tooltipBodies[i]}
</div>
</div> </div>
</div> </div>
</foreignObject> </foreignObject>

View File

@ -1,7 +1,7 @@
import { Button, Form, FormItemProps, Input, InputNumber, Select, Tooltip } from 'antd' import { Button, Checkbox, Form, FormItemProps, Input, InputNumber, Select, Tooltip } from 'antd'
import { memo, useCallback, useEffect, useMemo } from 'react' import { memo, useCallback, useEffect, useMemo } from 'react'
import { MinMax } from '@components/d3/types' import { BaseDataType, MinMax } from '@components/d3/types'
import { ColorPicker, Color } from '@components/ColorPicker' import { ColorPicker, Color } from '@components/ColorPicker'
import { ExtendedChartDataset } from './D3MonitoringCharts' import { ExtendedChartDataset } from './D3MonitoringCharts'
@ -18,13 +18,13 @@ const lineTypes = [
{ value: 'needle', label: 'Иглы' }, { value: 'needle', label: 'Иглы' },
] ]
export type D3MonitoringChartEditorProps<DataType> = { export type D3MonitoringChartEditorProps<DataType extends BaseDataType> = {
group: ExtendedChartDataset<DataType>[] group: ExtendedChartDataset<DataType>[]
chart: ExtendedChartDataset<DataType> chart: ExtendedChartDataset<DataType>
onChange: (value: ExtendedChartDataset<DataType>) => boolean onChange: (value: ExtendedChartDataset<DataType>) => boolean
} }
const _D3MonitoringChartEditor = <DataType,>({ const _D3MonitoringChartEditor = <DataType extends BaseDataType>({
group, group,
chart: value, chart: value,
onChange, onChange,
@ -43,10 +43,12 @@ const _D3MonitoringChartEditor = <DataType,>({
}, [value]) }, [value])
const onDomainChange = useCallback((mm: MinMax) => { const onDomainChange = useCallback((mm: MinMax) => {
onSave({ xDomain: { onSave({
min: ('min' in mm) ? mm.min : value.xDomain?.min, xDomain: {
max: ('max' in mm) ? mm.max : value.xDomain?.max, min: ('min' in mm) ? mm.min : value.xDomain?.min,
}}) max: ('max' in mm) ? mm.max : value.xDomain?.max,
}
})
}, [value]) }, [value])
const onColorChange = useCallback((color: Color) => { const onColorChange = useCallback((color: Color) => {
@ -91,8 +93,8 @@ const _D3MonitoringChartEditor = <DataType,>({
</Item> </Item>
<Item label={'Диапазон'}> <Item label={'Диапазон'}>
<Input.Group compact> <Input.Group compact>
<InputNumber disabled={!!value.linkedTo} value={value.xDomain?.min} onChange={(min) => onDomainChange({ min })} placeholder={'Мин'} /> <InputNumber disabled={!!value.linkedTo} value={value.xDomain?.min} onChange={(min) => onDomainChange({ min: min ?? undefined })} placeholder={'Мин'} />
<InputNumber disabled={!!value.linkedTo} value={value.xDomain?.max} onChange={(max) => onDomainChange({ max })} placeholder={'Макс'} /> <InputNumber disabled={!!value.linkedTo} value={value.xDomain?.max} onChange={(max) => onDomainChange({ max: max ?? undefined })} placeholder={'Макс'} />
<Button <Button
disabled={!!value.linkedTo || (!Number.isFinite(value.xDomain?.min) && !Number.isFinite(value.xDomain?.max))} disabled={!!value.linkedTo || (!Number.isFinite(value.xDomain?.min) && !Number.isFinite(value.xDomain?.max))}
onClick={() => onDomainChange({ min: undefined, max: undefined })} onClick={() => onDomainChange({ min: undefined, max: undefined })}
@ -100,6 +102,14 @@ const _D3MonitoringChartEditor = <DataType,>({
</Input.Group> </Input.Group>
</Item> </Item>
<Item label={'Цвет линий'}><ColorPicker onChange={onColorChange} value={value.color} /></Item> <Item label={'Цвет линий'}><ColorPicker onChange={onColorChange} value={value.color} /></Item>
<Item>
<Checkbox
checked={value.showCurrentValue}
onChange={(e) => onSave({ showCurrentValue: e.target.checked })}
>
Показать текущее значение сверху
</Checkbox>
</Item>
</Form> </Form>
) )
} }

View File

@ -8,6 +8,7 @@ import LoaderPortal from '@components/LoaderPortal'
import { isDev, usePartialProps, useUserSettings } from '@utils' import { isDev, usePartialProps, useUserSettings } from '@utils'
import { import {
BaseDataType,
ChartAxis, ChartAxis,
ChartDataset, ChartDataset,
ChartOffset, ChartOffset,
@ -25,6 +26,7 @@ import { getByAccessor, getChartClass, getGroupClass, getTicks } from '@componen
import { renderArea, renderLine, renderNeedle, renderPoint, renderRectArea } from '@components/d3/renders' import { renderArea, renderLine, renderNeedle, renderPoint, renderRectArea } from '@components/d3/renders'
import D3MonitoringEditor from './D3MonitoringEditor' import D3MonitoringEditor from './D3MonitoringEditor'
import D3MonitoringCurrentValues from './D3MonitoringCurrentValues'
import { D3HorizontalCursor, D3HorizontalCursorSettings } from './D3HorizontalCursor' import { D3HorizontalCursor, D3HorizontalCursorSettings } from './D3HorizontalCursor'
import D3MonitoringLimitChart, { TelemetryRegulators } from './D3MonitoringLimitChart' import D3MonitoringLimitChart, { TelemetryRegulators } from './D3MonitoringLimitChart'
@ -50,16 +52,18 @@ const calculateDomain = (mm: MinMax): Required<MinMax> => {
return { min, max } return { min, max }
} }
export type ExtendedChartDataset<DataType> = ChartDataset<DataType> & { export type ExtendedChartDataset<DataType extends BaseDataType> = ChartDataset<DataType> & {
/** Диапазон отображаемых значений по горизонтальной оси */ /** Диапазон отображаемых значений по горизонтальной оси */
xDomain: MinMax xDomain: MinMax
/** Скрыть отображение шкалы графика */ /** Скрыть отображение шкалы графика */
hideLabel?: boolean hideLabel?: boolean
/** Показать последнее значение сверху графика */
showCurrentValue?: boolean
} }
export type ExtendedChartRegistry<DataType> = ChartRegistry<DataType> & ExtendedChartDataset<DataType> export type ExtendedChartRegistry<DataType extends BaseDataType> = ChartRegistry<DataType> & ExtendedChartDataset<DataType>
export type ChartGroup<DataType> = { export type ChartGroup<DataType extends BaseDataType> = {
/** Получить D3 выборку, содержащую корневой G-элемент группы */ /** Получить D3 выборку, содержащую корневой G-элемент группы */
(): d3.Selection<SVGGElement, any, any, any> (): d3.Selection<SVGGElement, any, any, any>
/** Уникальный ключ группы (индекс) */ /** Уникальный ключ группы (индекс) */
@ -83,12 +87,12 @@ const defaultRegulators: TelemetryRegulators = {
5: { color: '#007070', label: 'Расход' }, 5: { color: '#007070', label: 'Расход' },
} }
const getDefaultYAxisConfig = <DataType,>(): ChartAxis<DataType> => ({ const getDefaultYAxisConfig = <DataType extends BaseDataType>(): ChartAxis<DataType> => ({
type: 'time', type: 'time',
accessor: (d: any) => new Date(d.date) accessor: (d: any) => new Date(d.date)
}) })
const getDefaultYTicks = <DataType,>(): Required<ChartTick<DataType>> => ({ const getDefaultYTicks = <DataType extends BaseDataType>(): Required<ChartTick<DataType>> => ({
visible: false, visible: false,
format: (d: d3.NumberValue, idx: number, data?: DataType) => String(d), format: (d: d3.NumberValue, idx: number, data?: DataType) => String(d),
color: 'lightgray', color: 'lightgray',
@ -98,7 +102,7 @@ const getDefaultYTicks = <DataType,>(): Required<ChartTick<DataType>> => ({
/** /**
* @template DataType тип данных отображаемых записей * @template DataType тип данных отображаемых записей
*/ */
export type D3MonitoringChartsProps<DataType extends Record<string, any>> = Omit<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, 'ref'> & { export type D3MonitoringChartsProps<DataType extends BaseDataType> = Omit<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, 'ref'> & {
/** Двумерный массив датасетов (группа-график) */ /** Двумерный массив датасетов (группа-график) */
datasetGroups: ExtendedChartDataset<DataType>[][] datasetGroups: ExtendedChartDataset<DataType>[][]
/** Ширина графика числом пикселей или CSS-значением (px/%/em/rem) */ /** Ширина графика числом пикселей или CSS-значением (px/%/em/rem) */
@ -291,9 +295,9 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
resetDatasets() resetDatasets()
}, [resetDatasets, resetRegulators]) }, [resetDatasets, resetRegulators])
useEffect(() => methods?.({ setSettingsVisible }), [methods]) useEffect(() => methods?.({ setSettingsVisible }), [methods]) /// Возвращаем в делегат доступные методы
useEffect(() => { useEffect(() => { /// Обновляем группы
if (isDev()) { if (isDev()) {
datasets.forEach((sets, i) => { datasets.forEach((sets, i) => {
sets.forEach((set, j) => { sets.forEach((set, j) => {
@ -366,7 +370,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
}) })
}, [yAxisConfig, chartArea, datasets, animDurationMs, createAxesGroup]) }, [yAxisConfig, chartArea, datasets, animDurationMs, createAxesGroup])
useEffect(() => { useEffect(() => { /// Обновляем группы и горизонтальные оси
const axesGroups = axesArea() const axesGroups = axesArea()
.selectAll('.charts-group') .selectAll('.charts-group')
.data(groups) .data(groups)
@ -542,7 +546,7 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
return <line key={`${i}`} x1={x} x2={x} y1={sizes.chartsTop} y2={offset.top + sizes.inlineHeight} /> return <line key={`${i}`} x1={x} x2={x} y1={sizes.chartsTop} y2={offset.top + sizes.inlineHeight} />
})} })}
</g> </g>
<D3MonitoringLimitChart <D3MonitoringLimitChart<DataType>
regulators={regulators} regulators={regulators}
data={data} data={data}
yAxis={yAxis} yAxis={yAxis}
@ -552,13 +556,20 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
top={sizes.chartsTop} top={sizes.chartsTop}
zoneWidth={sizes.inlineWidth} zoneWidth={sizes.inlineWidth}
/> />
<D3MonitoringCurrentValues<DataType>
groups={groups}
data={data}
sizes={sizes}
/>
<D3MouseZone width={width} height={height} offset={{ ...offset, top: sizes.chartsTop }}> <D3MouseZone width={width} height={height} offset={{ ...offset, top: sizes.chartsTop }}>
<D3HorizontalCursor <D3HorizontalCursor
{...plugins?.cursor} {...plugins?.cursor}
yAxis={yAxis} yAxis={yAxis}
groups={groups} groups={groups}
sizes={sizes} sizes={sizes}
spaceBetweenGroups={spaceBetweenGroups}
data={data} data={data}
height={height}
/> />
</D3MouseZone> </D3MouseZone>
</svg> </svg>
@ -581,6 +592,6 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
) )
} }
export const D3MonitoringCharts = memo(_D3MonitoringCharts) export const D3MonitoringCharts = memo(_D3MonitoringCharts) as typeof _D3MonitoringCharts
export default D3MonitoringCharts export default D3MonitoringCharts

View File

@ -0,0 +1,46 @@
import { memo } from 'react'
import { BaseDataType } from '@components/d3/types'
import { ChartGroup, ChartSizes } from '@components/d3/monitoring/D3MonitoringCharts'
import { makeDisplayValue } from '@utils'
export type D3MonitoringCurrentValuesProps<DataType extends BaseDataType> = {
/** Группы графиков */
groups: ChartGroup<DataType>[]
/** Массив данных графика */
data: DataType[]
/** Объект, хранящий полезные размеры и отступы графика (нужен только groupWidth, chartsTop и groupLeft) */
sizes: ChartSizes
}
const display = makeDisplayValue({ def: '---', fixed: 2 })
/// `Array.at` вместе с `??` возвращает странный тип, поэтому его пока пришлось пометить как `any`
/// TODO: Исправить тип
const _D3MonitoringCurrentValues = <DataType extends BaseDataType>({ groups, data, sizes }: D3MonitoringCurrentValuesProps<DataType>) => (
<g transform={`translate(${sizes.left}, ${sizes.chartsTop})`} pointerEvents={'none'}>
{groups.map((group) => (
<g key={group.key} transform={`translate(${sizes.groupLeft(group.key)}, 0)`}>
{group.charts.filter((chart) => chart.showCurrentValue).map((chart, i) => (
<g key={chart.key} stroke={'white'} fill={chart.color} strokeWidth={4} paintOrder={'stroke'} style={{ fontWeight: 600 }}>
<text x={sizes.groupWidth / 2 - 10} textAnchor={'end'} y={15 + i * 20}>{chart.shortLabel ?? chart.label}:</text>
<text x={sizes.groupWidth / 2 + 10} textAnchor={'start'} y={15 + i * 20}>{display(chart.x((data.at(-1) ?? {}) as any))}</text>
</g>
))}
</g>
))}
</g>
)
/**
* Отрисовывает последние значения графиков
*
* @typeParam DataType - тип данных для отрисовки графиков
*
* @param groups - Массив групп графиков
* @param data - Массив данных графиков
* @param sizes - Объект с полезными размерами и отступами внутри svg
*/
export const D3MonitoringCurrentValues = memo(_D3MonitoringCurrentValues) as typeof _D3MonitoringCurrentValues
export default D3MonitoringCurrentValues

View File

@ -1,17 +1,18 @@
import { CSSProperties, Key, memo, useCallback, useEffect, useMemo, useState } from 'react' import { CSSProperties, Key, memo, useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Divider, Empty, Modal, Popconfirm, Tooltip, Tree } from 'antd' import { Button, Divider, Empty, Modal, Popconfirm, Tooltip, Tree, TreeDataNode } from 'antd'
import { UndoOutlined } from '@ant-design/icons' import { UndoOutlined } from '@ant-design/icons'
import { EventDataNode } from 'antd/lib/tree' import { EventDataNode } from 'antd/lib/tree'
import { notify } from '@components/factory' import { notify } from '@components/factory'
import { getChartIcon } from '@utils' import { getChartIcon } from '@utils'
import { BaseDataType } from '../types'
import { ExtendedChartDataset } from './D3MonitoringCharts' import { ExtendedChartDataset } from './D3MonitoringCharts'
import { TelemetryRegulators } from './D3MonitoringLimitChart' import { TelemetryRegulators } from './D3MonitoringLimitChart'
import D3MonitoringChartEditor from './D3MonitoringChartEditor' import D3MonitoringChartEditor from './D3MonitoringChartEditor'
import D3MonitoringLimitEditor from './D3MonitoringLimitEditor' import D3MonitoringLimitEditor from './D3MonitoringLimitEditor'
export type D3MonitoringGroupsEditorProps<DataType> = { export type D3MonitoringGroupsEditorProps<DataType extends BaseDataType> = {
visible?: boolean visible?: boolean
groups: ExtendedChartDataset<DataType>[][] groups: ExtendedChartDataset<DataType>[][]
regulators: TelemetryRegulators regulators: TelemetryRegulators
@ -20,7 +21,7 @@ export type D3MonitoringGroupsEditorProps<DataType> = {
onReset: () => void onReset: () => void
} }
const getChartLabel = <DataType,>(chart: ExtendedChartDataset<DataType>) => ( const getChartLabel = <DataType extends BaseDataType>(chart: ExtendedChartDataset<DataType>) => (
<Tooltip title={chart.label}> <Tooltip title={chart.label}>
{getChartIcon(chart)} {chart.label} {getChartIcon(chart)} {chart.label}
</Tooltip> </Tooltip>
@ -34,14 +35,14 @@ const divStyle: CSSProperties = {
flexGrow: 1, flexGrow: 1,
} }
const getNodePos = (node: EventDataNode): { group: number, chart?: number } => { const getNodePos = (node: EventDataNode<TreeDataNode>): { group: number, chart?: number } => {
const out = node.pos.split('-').map(Number) const out = node.pos.split('-').map(Number)
return { group: out[1], chart: out[2] } return { group: out[1], chart: out[2] }
} }
type EditingMode = null | 'limit' | 'chart' type EditingMode = null | 'limit' | 'chart'
const _D3MonitoringEditor = <DataType,>({ const _D3MonitoringEditor = <DataType extends BaseDataType>({
visible, visible,
groups: oldGroups, groups: oldGroups,
regulators: oldRegulators, regulators: oldRegulators,
@ -61,8 +62,8 @@ const _D3MonitoringEditor = <DataType,>({
const onModalOk = useCallback(() => onChange(groups, regulators), [groups, regulators]) const onModalOk = useCallback(() => onChange(groups, regulators), [groups, regulators])
const onDrop = useCallback((info: { const onDrop = useCallback((info: {
node: EventDataNode node: EventDataNode<TreeDataNode>
dragNode: EventDataNode dragNode: EventDataNode<TreeDataNode>
dropPosition: number dropPosition: number
}) => { }) => {
const { dragNode, dropPosition, node } = info const { dragNode, dropPosition, node } = info
@ -134,7 +135,7 @@ const _D3MonitoringEditor = <DataType,>({
<Modal <Modal
centered centered
width={800} width={800}
visible={visible} open={visible}
title={'Настройка групп графиков'} title={'Настройка групп графиков'}
onCancel={onCancel} onCancel={onCancel}
footer={( footer={(
@ -152,18 +153,18 @@ const _D3MonitoringEditor = <DataType,>({
<Tree <Tree
draggable draggable
selectable selectable
onExpand={(keys) => setExpand(keys)} onExpand={(keys: Key[]) => setExpand(keys)}
expandedKeys={expand} expandedKeys={expand}
selectedKeys={selected} selectedKeys={selected}
treeData={treeItems} treeData={treeItems}
onDrop={onDrop} onDrop={onDrop}
onSelect={(value) => { onSelect={(value: Key[]) => {
setSelected(value) setSelected(value)
setMode('chart') setMode('chart')
}} }}
height={250} height={250}
/> />
<Button onClick={() => setMode('limit')}>Ограничение подачи</Button> {/* <Button onClick={() => setMode('limit')}>Ограничение подачи</Button> */}
</div> </div>
<Divider type={'vertical'} style={{ height: '100%', padding: '0 5px' }} /> <Divider type={'vertical'} style={{ height: '100%', padding: '0 5px' }} />
<div style={divStyle}> <div style={divStyle}>

View File

@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef } from 'react'
import { Property } from 'csstype' import { Property } from 'csstype'
import * as d3 from 'd3' import * as d3 from 'd3'
import { ChartRegistry } from '@components/d3/types' import { BaseDataType, ChartRegistry } from '@components/d3/types'
import { useD3MouseZone } from '@components/d3/D3MouseZone' import { useD3MouseZone } from '@components/d3/D3MouseZone'
import { usePartialProps } from '@utils' import { usePartialProps } from '@utils'
@ -32,12 +32,12 @@ export type D3LegendSettings = {
const defaultOffset = { x: 10, y: 10 } const defaultOffset = { x: 10, y: 10 }
export type D3LegendProps<DataType> = D3LegendSettings & { export type D3LegendProps<DataType extends BaseDataType> = D3LegendSettings & {
/** Массив графиков */ /** Массив графиков */
charts: ChartRegistry<DataType>[] charts: ChartRegistry<DataType>[]
} }
const _D3Legend = <DataType,>({ const _D3Legend = <DataType extends BaseDataType>({
charts, charts,
width, width,
height, height,

View File

@ -4,7 +4,7 @@ import * as d3 from 'd3'
import { isDev } from '@utils' import { isDev } from '@utils'
import { ChartRegistry } from '@components/d3/types' import { BaseDataType, ChartRegistry } from '@components/d3/types'
import { D3MouseState, useD3MouseZone } from '@components/d3/D3MouseZone' import { D3MouseState, useD3MouseZone } from '@components/d3/D3MouseZone'
import { getTouchedElements, wrapPlugin } from './base' import { getTouchedElements, wrapPlugin } from './base'
@ -12,7 +12,7 @@ import '@styles/d3.less'
export type D3TooltipPosition = 'bottom' | 'top' | 'left' | 'right' | 'none' export type D3TooltipPosition = 'bottom' | 'top' | 'left' | 'right' | 'none'
export type D3RenderData<DataType> = { export type D3RenderData<DataType extends BaseDataType> = {
/** Параметры графика */ /** Параметры графика */
chart: ChartRegistry<DataType> chart: ChartRegistry<DataType>
/** Данные графика */ /** Данные графика */
@ -21,9 +21,9 @@ export type D3RenderData<DataType> = {
selection?: d3.Selection<any, DataType, any, any> selection?: d3.Selection<any, DataType, any, any>
} }
export type D3RenderFunction<DataType> = (data: D3RenderData<DataType>[], mouseState: D3MouseState) => ReactNode export type D3RenderFunction<DataType extends BaseDataType> = (data: D3RenderData<DataType>[], mouseState: D3MouseState) => ReactNode
export type D3TooltipSettings<DataType> = { export type D3TooltipSettings<DataType extends BaseDataType> = {
/** Функция отрисоки тултипа */ /** Функция отрисоки тултипа */
render?: D3RenderFunction<DataType> render?: D3RenderFunction<DataType>
/** Ширина тултипа */ /** Ширина тултипа */
@ -39,7 +39,7 @@ export type D3TooltipSettings<DataType> = {
limit?: number limit?: number
} }
export const makeDefaultRender = <DataType,>(): D3RenderFunction<DataType> => (data, mouseState) => ( export const makeDefaultRender = <DataType extends BaseDataType>(): D3RenderFunction<DataType> => (data, mouseState) => (
<> <>
{data.length > 0 ? data.map(({ chart, data }) => { {data.length > 0 ? data.map(({ chart, data }) => {
let Icon let Icon
@ -74,11 +74,11 @@ export const makeDefaultRender = <DataType,>(): D3RenderFunction<DataType> => (d
</> </>
) )
export type D3TooltipProps<DataType> = Partial<D3TooltipSettings<DataType>> & { export type D3TooltipProps<DataType extends BaseDataType> = Partial<D3TooltipSettings<DataType>> & {
charts: ChartRegistry<DataType>[], charts: ChartRegistry<DataType>[],
} }
function _D3Tooltip<DataType extends Record<string, unknown>>({ function _D3Tooltip<DataType extends BaseDataType>({
width = 200, width = 200,
height = 120, height = 120,
render = makeDefaultRender<DataType>(), render = makeDefaultRender<DataType>(),

View File

@ -3,7 +3,7 @@ import * as d3 from 'd3'
import { getDistance, TouchType } from '@utils' import { getDistance, TouchType } from '@utils'
import { ChartRegistry } from '../types' import { BaseDataType, ChartRegistry } from '../types'
export type BasePluginSettings = { export type BasePluginSettings = {
enabled?: boolean enabled?: boolean
@ -16,7 +16,7 @@ export const wrapPlugin = <TProps,>(
const wrappedComponent = ({ enabled, ...props }: TProps & BasePluginSettings) => { const wrappedComponent = ({ enabled, ...props }: TProps & BasePluginSettings) => {
if (!(enabled ?? defaultEnabled)) return <></> if (!(enabled ?? defaultEnabled)) return <></>
return <Component {...(props as TProps)} /> return <Component {...(props as (TProps & JSX.IntrinsicAttributes))} /> // IntrinsicAttributes добавлено как необходимое ограничение
} }
return wrappedComponent return wrappedComponent
@ -89,7 +89,7 @@ const makeIsRectTouched = (x: number, y: number, limit: number, type: TouchType
} }
} }
export const getTouchedElements = <DataType,>( export const getTouchedElements = <DataType extends BaseDataType>(
chart: ChartRegistry<DataType>, chart: ChartRegistry<DataType>,
x: number, x: number,
y: number, y: number,

View File

@ -1,8 +1,8 @@
import * as d3 from 'd3' import * as d3 from 'd3'
import { ChartRegistry } from '../types' import { BaseDataType, ChartRegistry } from '../types'
export const appendTransition = <DataType, BaseType extends d3.BaseType, Datum, PElement extends d3.BaseType, PDatum>( export const appendTransition = <DataType extends BaseDataType, BaseType extends d3.BaseType, Datum, PElement extends d3.BaseType, PDatum>(
elms: d3.Selection<BaseType, Datum, PElement, PDatum>, elms: d3.Selection<BaseType, Datum, PElement, PDatum>,
chart: ChartRegistry<DataType> chart: ChartRegistry<DataType>
): d3.Selection<BaseType, Datum, PElement, PDatum> => { ): d3.Selection<BaseType, Datum, PElement, PDatum> => {

View File

@ -1,4 +1,4 @@
import { ChartRegistry, PointChartDataset } from '@components/d3/types' import { BaseDataType, ChartRegistry, PointChartDataset } from '@components/d3/types'
import { appendTransition } from './base' import { appendTransition } from './base'
@ -12,7 +12,7 @@ const defaultConfig: Required<Omit<PointChartDataset, 'type'>> = {
fillOpacity: 1, fillOpacity: 1,
} }
const getPointsRoot = <DataType,>(chart: ChartRegistry<DataType>, embeded?: boolean): d3.Selection<SVGGElement, any, any, any> => { const getPointsRoot = <DataType extends BaseDataType>(chart: ChartRegistry<DataType>, embeded?: boolean): d3.Selection<SVGGElement, any, any, any> => {
const root = chart() const root = chart()
if (!embeded) return root if (!embeded) return root
if (root.select('.points').empty()) if (root.select('.points').empty())

View File

@ -1,9 +1,9 @@
import { getByAccessor } from '@components/d3/functions' import { getByAccessor } from '@components/d3/functions'
import { ChartRegistry } from '@components/d3/types' import { BaseDataType, ChartRegistry } from '@components/d3/types'
import { appendTransition } from './base' import { appendTransition } from './base'
export const renderRectArea = <DataType extends Record<string, any>>( export const renderRectArea = <DataType extends BaseDataType>(
xAxis: (value: d3.NumberValue) => number, xAxis: (value: d3.NumberValue) => number,
yAxis: (value: d3.NumberValue) => number, yAxis: (value: d3.NumberValue) => number,
chart: ChartRegistry<DataType> chart: ChartRegistry<DataType>

View File

@ -3,9 +3,11 @@ import { Property } from 'csstype'
import { D3TooltipSettings } from './plugins' import { D3TooltipSettings } from './plugins'
export type AxisAccessor<DataType extends Record<string, any>> = keyof DataType | ((d: DataType) => any) export type BaseDataType = Record<string, any>
export type ChartAxis<DataType> = { export type AxisAccessor<DataType extends BaseDataType> = keyof DataType | ((d: DataType) => any)
export type ChartAxis<DataType extends BaseDataType> = {
/** Тип шкалы */ /** Тип шкалы */
type: 'linear' | 'time', type: 'linear' | 'time',
/** Ключ записи или метод по которому будет извлекаться значение оси из массива данных */ /** Ключ записи или метод по которому будет извлекаться значение оси из массива данных */
@ -34,7 +36,7 @@ export type PointChartDataset = {
fillOpacity?: number fillOpacity?: number
} }
export type BaseChartDataset<DataType> = { export type BaseChartDataset<DataType extends BaseDataType> = {
/** Уникальный ключ графика */ /** Уникальный ключ графика */
key: string | number key: string | number
/** Параметры вертикальной оси */ /** Параметры вертикальной оси */
@ -101,7 +103,7 @@ export type NeedleChartDataset = {
type: 'needle' type: 'needle'
} }
export type ChartDataset<DataType> = BaseChartDataset<DataType> & ( export type ChartDataset<DataType extends BaseDataType> = BaseChartDataset<DataType> & (
AreaChartDataset | AreaChartDataset |
LineChartDataset | LineChartDataset |
NeedleChartDataset | NeedleChartDataset |
@ -154,7 +156,7 @@ export type ChartTicks<DataType> = {
y?: ChartTick<DataType> y?: ChartTick<DataType>
} }
export type ChartRegistry<DataType> = ChartDataset<DataType> & { export type ChartRegistry<DataType extends BaseDataType> = ChartDataset<DataType> & {
/** Получить D3 выборку, содержащую корневой G-элемент графика */ /** Получить D3 выборку, содержащую корневой G-элемент графика */
(): d3.Selection<SVGGElement, DataType, any, any> (): d3.Selection<SVGGElement, DataType, any, any>
/** Получить значение по вертикальной оси из предоставленой записи */ /** Получить значение по вертикальной оси из предоставленой записи */

View File

@ -3,8 +3,7 @@ import { ArgsProps } from 'antd/lib/notification'
import { Dispatch, ReactNode, SetStateAction } from 'react' import { Dispatch, ReactNode, SetStateAction } from 'react'
import { WellView } from '@components/views' import { WellView } from '@components/views'
import { getUserToken } from '@utils' import { FunctionalValue, getFunctionalValue, getUser, isDev } from '@utils'
import { FunctionalValue, getFunctionalValue, isDev } from '@utils'
import { ApiError, FileInfoDto, WellDto } from '@api' import { ApiError, FileInfoDto, WellDto } from '@api'
export type NotifyType = 'error' | 'warning' | 'info' export type NotifyType = 'error' | 'warning' | 'info'
@ -30,7 +29,7 @@ export const notify = (body?: ReactNode, notifyType: NotifyType = 'info', well?:
const message = ( const message = (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{instance.message}</span> <span>{instance.message}</span>
<WellView well={well} /> <WellView placement={'leftBottom'} well={well} />
</div> </div>
) )
@ -104,7 +103,7 @@ export const invokeWebApiWrapperAsync = (
export const download = async (url: string, fileName?: string) => { export const download = async (url: string, fileName?: string) => {
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
Authorization: `Bearer ${getUserToken()}` Authorization: `Bearer ${getUser().token}`
}, },
method: 'Get' method: 'Get'
}) })
@ -132,7 +131,7 @@ export const download = async (url: string, fileName?: string) => {
export const upload = async (url: string, formData: FormData) => { export const upload = async (url: string, formData: FormData) => {
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
Authorization: `Bearer ${getUserToken()}` Authorization: `Bearer ${getUser().token}`
}, },
method: 'Post', method: 'Post',
body: formData, body: formData,

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
import { Button, Drawer, Skeleton, Tree, TreeProps, Typography } from 'antd' import { Button, Drawer, Skeleton, Tree, TreeDataNode, TreeProps, Typography } from 'antd'
import { DefaultValueType } from 'rc-tree-select/lib/interface' import { useState, useEffect, useCallback, memo, Key } from 'react'
import { useState, useEffect, ReactNode, useCallback, memo, Key } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { WellIcon, WellIconState } from '@components/icons' import { WellIcon, WellIconState } from '@components/icons'
@ -17,26 +16,18 @@ export const getWellState = (idState?: number): WellIconState => idState === 1 ?
export const checkIsWellOnline = (lastTelemetryDate: unknown): boolean => export const checkIsWellOnline = (lastTelemetryDate: unknown): boolean =>
isRawDate(lastTelemetryDate) && (Date.now() - +new Date(lastTelemetryDate) < 600_000) isRawDate(lastTelemetryDate) && (Date.now() - +new Date(lastTelemetryDate) < 600_000)
export type TreeNodeData = {
title?: string | null
key?: string
value?: DefaultValueType
icon?: ReactNode
children?: TreeNodeData[]
}
const getKeyByUrl = (url?: string): [Key | null, string | null] => { const getKeyByUrl = (url?: string): [Key | null, string | null] => {
const result = url?.match(/^\/([^\/]+)\/([^\/?]+)/) // pattern "/:type/:id" const result = url?.match(/^\/([^\/]+)\/([^\/?]+)/) // pattern "/:type/:id"
if (!result) return [null, null] if (!result) return [null, null]
return [result[0], result[1]] return [result[0], result[1]]
} }
const getLabel = (wellsTree: TreeNodeData[], value?: string): string | undefined => { const getLabel = (wellsTree: TreeDataNode[], value?: string): string | undefined => {
const [url, type] = getKeyByUrl(value) const [url, type] = getKeyByUrl(value)
if (!url) return if (!url) return
let deposit: TreeNodeData | undefined let deposit: TreeDataNode | undefined
let cluster: TreeNodeData | undefined let cluster: TreeDataNode | undefined
let well: TreeNodeData | undefined let well: TreeDataNode | undefined
switch (type) { switch (type) {
case 'deposit': case 'deposit':
deposit = wellsTree.find((deposit) => deposit.key === url) deposit = wellsTree.find((deposit) => deposit.key === url)
@ -46,7 +37,7 @@ const getLabel = (wellsTree: TreeNodeData[], value?: string): string | undefined
case 'cluster': case 'cluster':
deposit = wellsTree.find((deposit) => ( deposit = wellsTree.find((deposit) => (
cluster = deposit.children?.find((cluster: TreeNodeData) => cluster.key === url) cluster = deposit.children?.find((cluster: TreeDataNode) => cluster.key === url)
)) ))
if (deposit && cluster) if (deposit && cluster)
return `${deposit.title} / ${cluster.title}` return `${deposit.title} / ${cluster.title}`
@ -54,8 +45,8 @@ const getLabel = (wellsTree: TreeNodeData[], value?: string): string | undefined
case 'well': case 'well':
deposit = wellsTree.find((deposit) => ( deposit = wellsTree.find((deposit) => (
cluster = deposit.children?.find((cluster: TreeNodeData) => ( cluster = deposit.children?.find((cluster: TreeDataNode) => (
well = cluster.children?.find((well: TreeNodeData) => well.key === url) well = cluster.children?.find((well: TreeDataNode) => well.key === url)
)) ))
)) ))
if (deposit && cluster && well) if (deposit && cluster && well)
@ -79,38 +70,49 @@ const sortWellsByActive = (a: WellDto, b: WellDto): number => {
return (a.caption || '')?.localeCompare(b.caption || '') return (a.caption || '')?.localeCompare(b.caption || '')
} }
export const WellTreeSelector = memo(({ show, ...other }: TreeProps<TreeNodeData> & { show?: boolean }) => { export type WellTreeSelectorProps = TreeProps<TreeDataNode> & {
const [wellsTree, setWellsTree] = useState<TreeNodeData[]>([]) show?: boolean
expand?: boolean | Key[]
current?: Key
onClose?: () => void
onChange?: (value: string | undefined) => void
open?: boolean
}
const getExpandKeys = (treeData: TreeDataNode[], depositKeys?: Key[] | boolean): Key[] => {
const out: Key[] = []
treeData.forEach((deposit) => {
if (Array.isArray(depositKeys) && !depositKeys.includes(deposit.key)) return
if (deposit.key) out.push(deposit.key)
deposit.children?.forEach((cluster) => {
if (cluster.key) out.push(cluster.key)
})
})
return out
}
export const WellTreeSelector = memo<WellTreeSelectorProps>(({ expand, current, onChange, onClose, open, ...other }) => {
const [wellsTree, setWellsTree] = useState<TreeDataNode[]>([])
const [showLoader, setShowLoader] = useState<boolean>(false) const [showLoader, setShowLoader] = useState<boolean>(false)
const [visible, setVisible] = useState<boolean>(false)
const [expanded, setExpanded] = useState<Key[]>([]) const [expanded, setExpanded] = useState<Key[]>([])
const [selected, setSelected] = useState<Key[]>([]) const [selected, setSelected] = useState<Key[]>([])
const [value, setValue] = useState<string>()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
useEffect(() => { useEffect(() => {
setVisible((prev) => show ?? prev) if (current) setSelected([current])
setExpanded((prev) => { }, [current])
if (typeof show === 'undefined') return prev
if (!show) return [] useEffect(() => {
const out: Key[] = [] setExpanded((prev) => expand ? getExpandKeys(wellsTree, expand) : prev)
wellsTree.forEach((deposit) => { }, [wellsTree, expand])
if (deposit.key) out.push(deposit.key)
deposit.children?.forEach((cluster) => {
if (cluster.key) out.push(cluster.key)
})
})
return out
})
}, [wellsTree, show])
useEffect(() => { useEffect(() => {
invokeWebApiWrapperAsync( invokeWebApiWrapperAsync(
async () => { async () => {
const deposits: Array<DepositDto> = await DepositService.getDeposits() const deposits: Array<DepositDto> = await DepositService.getDeposits()
const wellsTree: TreeNodeData[] = deposits.map(deposit =>({ const wellsTree: TreeDataNode[] = deposits.map(deposit =>({
title: deposit.caption, title: deposit.caption,
key: `/deposit/${deposit.id}`, key: `/deposit/${deposit.id}`,
value: `/deposit/${deposit.id}`, value: `/deposit/${deposit.id}`,
@ -146,36 +148,43 @@ export const WellTreeSelector = memo(({ show, ...other }: TreeProps<TreeNodeData
) )
}, []) }, [])
const onChange = useCallback((value?: string): void => { const onValueChange = useCallback((value?: string): void => {
const key = getKeyByUrl(value)[0] const key = getKeyByUrl(value)[0]
setSelected(key ? [key] : []) setSelected(key ? [key] : [])
setValue(getLabel(wellsTree, value)) onChange?.(getLabel(wellsTree, value))
}, [wellsTree]) }, [wellsTree])
const onSelect = useCallback((value: Key[]): void => { const onSelect = useCallback((value: Key[]): void => {
navigate(String(value), { state: { from: location.pathname }}) const newRoot = /\/(\w+)\/\d+/.exec(String(value))
const oldRoot = /\/(\w+)(?:\/\d+)?/.exec(location.pathname)
if (!newRoot || !oldRoot) return
let newPath = newRoot[0]
if (oldRoot[1] === newRoot[1]) {
/// Если типы страницы одинаковые (deposit, cluster, well), добавляем остаток старого пути
const url = location.pathname.substring(oldRoot[0].length)
newPath = newPath + url
}
navigate(newPath, { state: { from: location.pathname }})
}, [navigate, location]) }, [navigate, location])
useEffect(() => onChange(location.pathname), [onChange, location]) useEffect(() => onValueChange(location.pathname), [onValueChange, location])
return ( return (
<> <Drawer open={open} mask={false} onClose={onClose} title={'Список скважин'}>
<Button loading={showLoader} onClick={() => setVisible(true)}>{value ?? 'Выберите месторождение'}</Button> <Skeleton active loading={showLoader}>
<Drawer visible={visible} mask={false} onClose={() => setVisible(false)}> <Tree
<Typography.Title level={3}>Список скважин</Typography.Title> {...other}
<Skeleton active loading={showLoader}> showIcon
<Tree selectedKeys={selected}
{...other} treeData={wellsTree}
showIcon onSelect={onSelect}
selectedKeys={selected} onExpand={setExpanded}
treeData={wellsTree} expandedKeys={expanded}
onSelect={onSelect} />
onExpand={setExpanded} </Skeleton>
expandedKeys={expanded} </Drawer>
/>
</Skeleton>
</Drawer>
</>
) )
}) })

View File

@ -1,8 +1,9 @@
import { Fragment, memo } from 'react' import { Fragment, memo } from 'react'
import { Tooltip } from 'antd' import { Tooltip } from 'antd'
import { TelemetryDto, TelemetryInfoDto } from '@api'
import { Grid, GridItem } from '@components/Grid' import { Grid, GridItem } from '@components/Grid'
import { formatDate } from '@utils'
import { TelemetryDto, TelemetryInfoDto } from '@api'
export const lables: Record<string, string> = { export const lables: Record<string, string> = {
timeZoneId: 'Временная зона', timeZoneId: 'Временная зона',
@ -30,12 +31,17 @@ export const TelemetryView = memo<TelemetryViewProps>(({ telemetry }) => telemet
overlayInnerStyle={{ width: '400px' }} overlayInnerStyle={{ width: '400px' }}
title={ title={
<Grid> <Grid>
{(Object.keys(telemetry.info) as Array<keyof TelemetryInfoDto>).map((key, i) => ( {(Object.keys(telemetry.info) as Array<keyof TelemetryInfoDto>).map((key, i) => {
<Fragment key={i}> let value = telemetry.info?.[key]
<GridItem row={i+1} col={1}>{lables[key] ?? key}:</GridItem> value = key === 'drillingStartDate' ? formatDate(value) : value
<GridItem row={i+1} col={2}>{telemetry.info?.[key]}</GridItem>
</Fragment> return (
))} <Fragment key={i}>
<GridItem row={i+1} col={1}>{lables[key] ?? key}:</GridItem>
<GridItem row={i+1} col={2}>{value}</GridItem>
</Fragment>
)
})}
</Grid> </Grid>
} }
> >

View File

@ -1,4 +1,4 @@
import { memo } from 'react' import { HTMLProps, memo } from 'react'
import { Tooltip } from 'antd' import { Tooltip } from 'antd'
import { UserOutlined } from '@ant-design/icons' import { UserOutlined } from '@ant-design/icons'
@ -6,33 +6,58 @@ import { UserDto } from '@api'
import { Grid, GridItem } from '@components/Grid' import { Grid, GridItem } from '@components/Grid'
import { CompanyView } from './CompanyView' import { CompanyView } from './CompanyView'
export type UserViewProps = { export type UserViewProps = HTMLProps<HTMLSpanElement> & {
user?: UserDto user?: UserDto
} }
export const UserView = memo<UserViewProps>(({ user }) => user ? ( export const UserView = memo<UserViewProps>(({ user, ...other }) =>
<Tooltip title={( user ? (
<Grid style={{ columnGap: '8px' }}> <Tooltip
<GridItem row={1} col={1}>Фамилия:</GridItem> title={
<GridItem row={1} col={2}>{user?.surname}</GridItem> <Grid style={{ columnGap: '8px' }}>
<GridItem row={1} col={1}>
Фамилия:
</GridItem>
<GridItem row={1} col={2}>
{user?.surname}
</GridItem>
<GridItem row={2} col={1}>Имя:</GridItem> <GridItem row={2} col={1}>
<GridItem row={2} col={2}>{user?.name}</GridItem> Имя:
</GridItem>
<GridItem row={2} col={2}>
{user?.name}
</GridItem>
<GridItem row={3} col={1}>Отчество:</GridItem> <GridItem row={3} col={1}>
<GridItem row={3} col={2}>{user?.patronymic}</GridItem> Отчество:
</GridItem>
<GridItem row={3} col={2}>
{user?.patronymic}
</GridItem>
<GridItem row={4} col={1}>Компания:</GridItem> <GridItem row={4} col={1}>
<GridItem row={4} col={2}> Компания:
<CompanyView company={user?.company}/> </GridItem>
</GridItem> <GridItem row={4} col={2}>
</Grid> <CompanyView company={user?.company} />
)}> </GridItem>
<UserOutlined style={{ marginRight: 8 }}/> </Grid>
{user?.login} }
</Tooltip> >
) : ( <span {...other}>
<Tooltip title='нет пользователя'>-</Tooltip> <UserOutlined style={{ marginRight: 8 }} />
)) {user?.login}
</span>
</Tooltip>
) : (
<Tooltip title={'нет пользователя'}>
<span {...other}>
<UserOutlined style={{ marginRight: 8 }} />
---
</span>
</Tooltip>
)
)
export default UserView export default UserView

View File

@ -1,5 +1,5 @@
import { memo } from 'react' import { memo } from 'react'
import { Tooltip } from 'antd' import { Tooltip, TooltipProps } from 'antd'
import { Grid, GridItem } from '@components/Grid' import { Grid, GridItem } from '@components/Grid'
import { WellIcon, WellIconState } from '@components/icons' import { WellIcon, WellIconState } from '@components/icons'
@ -13,12 +13,12 @@ const wellState: Record<number, { enum: WellIconState, label: string }> = {
2: { enum: 'inactive', label: 'Завершена' }, 2: { enum: 'inactive', label: 'Завершена' },
} }
export type WellViewProps = { export type WellViewProps = TooltipProps & {
well?: WellDto well?: WellDto
} }
export const WellView = memo<WellViewProps>(({ well }) => well ? ( export const WellView = memo<WellViewProps>(({ well, ...other }) => well ? (
<Tooltip title={( <Tooltip {...other} title={(
<Grid style={{ columnGap: '8px' }}> <Grid style={{ columnGap: '8px' }}>
<GridItem row={1} col={1}>Название:</GridItem> <GridItem row={1} col={1}>Название:</GridItem>
<GridItem row={1} col={2}>{well.caption ?? '---'}</GridItem> <GridItem row={1} col={2}>{well.caption ?? '---'}</GridItem>

View File

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

View File

@ -1,22 +1,49 @@
import { createContext, useContext } from 'react' import { createContext, useContext, useEffect } from 'react'
import { WellDto } from '@api' import { LayoutPortalProps } from '@components/LayoutPortal'
import { UserTokenDto, WellDto } from '@api'
/** Контекст текущей скважины */ /** Контекст текущей скважины */
export const WellContext = createContext<[WellDto, (well: WellDto) => void]>([{}, () => {}]) export const WellContext = createContext<[WellDto, (well: WellDto) => void]>([{}, () => {}])
/** Контекст текущего корневого пути */ /** Контекст текущего корневого пути */
export const RootPathContext = createContext<string>('') export const RootPathContext = createContext<string>('/')
/** Контекст текущего пользователя */
export const UserContext = createContext<UserTokenDto>({})
/** Контекст метода редактирования параметров заголовка и меню */
export const LayoutPropsContext = createContext<(props: LayoutPortalProps) => void>(() => {})
/** /**
* Получение текущей скважины * Получить текущую скважину
* *
* @returns Текущая скважина, либо `null` * @returns Текущая скважина, либо `null`
*/ */
export const useWell = () => useContext(WellContext) export const useWell = () => useContext(WellContext)
/** /**
* Получает текущий корневой путь * Получить текущий корневой путь
* *
* @returns Текущий корневой путь * @returns Текущий корневой путь
*/ */
export const useRootPath = () => useContext(RootPathContext) export const useRootPath = () => useContext(RootPathContext)
/**
* Получить текущего пользователя
*
* @returns Текущий пользователь, либо `null`
*/
export const useUser = () => useContext(UserContext)
/**
* Получить метод задания параметров заголовка и меню
*
* @returns Получить метод задания параметров заголовка и меню
*/
export const useLayoutProps = (props?: LayoutPortalProps) => {
const setLayoutProps = useContext(LayoutPropsContext)
useEffect(() => {
if (props) setLayoutProps(props)
}, [setLayoutProps, props])
return setLayoutProps
}

View File

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

View File

@ -11,11 +11,9 @@ import {
} from '@components/Table' } from '@components/Table'
import { invokeWebApiWrapperAsync } from '@components/factory' import { invokeWebApiWrapperAsync } from '@components/factory'
import { AdminClusterService, AdminDepositService } from '@api' import { AdminClusterService, AdminDepositService } from '@api'
import { arrayOrDefault, wrapPrivateComponent } from '@utils' import { arrayOrDefault, coordsFormat, wrapPrivateComponent } from '@utils'
import { min1 } from '@utils/validationRules' import { min1 } from '@utils/validationRules'
import { coordsFixed } from './DepositController'
const ClusterController = memo(() => { const ClusterController = memo(() => {
const [deposits, setDeposits] = useState([]) const [deposits, setDeposits] = useState([])
const [clusters, setClusters] = useState([]) const [clusters, setClusters] = useState([])
@ -41,8 +39,8 @@ const ClusterController = memo(() => {
sorter: makeStringSorter('caption'), sorter: makeStringSorter('caption'),
formItemRules: min1, formItemRules: min1,
}), }),
makeColumn('Широта', 'latitude', { width: 150, editable: true, render: coordsFixed }), makeColumn('Широта', 'latitude', { width: 150, editable: true, render: coordsFormat }),
makeColumn('Долгота', 'longitude', { width: 150, editable: true, render: coordsFixed }), makeColumn('Долгота', 'longitude', { width: 150, editable: true, render: coordsFormat }),
makeTimezoneColumn('Зона', 'timezone', null, true, { width: 150 }), makeTimezoneColumn('Зона', 'timezone', null, true, { width: 150 }),
], [deposits]) ], [deposits])
@ -108,6 +106,7 @@ const ClusterController = memo(() => {
onRowEdit={tableHandlers.edit} onRowEdit={tableHandlers.edit}
onRowDelete={tableHandlers.delete} onRowDelete={tableHandlers.delete}
tableName={'admin_cluster_controller'} tableName={'admin_cluster_controller'}
scroll={{ x: true }}
/> />
</> </>
) )

View File

@ -97,6 +97,7 @@ const CompanyController = memo(() => {
onRowEdit={tableHandlers.edit} onRowEdit={tableHandlers.edit}
onRowDelete={tableHandlers.delete} onRowDelete={tableHandlers.delete}
tableName={'admin_company_controller'} tableName={'admin_company_controller'}
scroll={{ x: true }}
/> />
</> </>
) )

View File

@ -75,6 +75,7 @@ const CompanyTypeController = memo(() => {
onRowEdit={tableHandlers.edit} onRowEdit={tableHandlers.edit}
onRowDelete={tableHandlers.delete} onRowDelete={tableHandlers.delete}
tableName={'admin_company_type_controller'} tableName={'admin_company_type_controller'}
scroll={{ x: true }}
/> />
</> </>
) )

View File

@ -3,16 +3,14 @@ import { Input } from 'antd'
import { invokeWebApiWrapperAsync } from '@components/factory' import { invokeWebApiWrapperAsync } from '@components/factory'
import { EditableTable, makeColumn, defaultPagination, makeTimezoneColumn } from '@components/Table' import { EditableTable, makeColumn, defaultPagination, makeTimezoneColumn } from '@components/Table'
import { arrayOrDefault, wrapPrivateComponent } from '@utils' import { arrayOrDefault, coordsFormat, wrapPrivateComponent } from '@utils'
import { min1 } from '@utils/validationRules' import { min1 } from '@utils/validationRules'
import { AdminDepositService } from '@api' import { AdminDepositService } from '@api'
export const coordsFixed = (coords) => coords && isFinite(coords) ? (+coords).toPrecision(10) : '-'
const depositColumns = [ const depositColumns = [
makeColumn('Название', 'caption', { width: 200, editable: true, formItemRules: min1 }), makeColumn('Название', 'caption', { width: 200, editable: true, formItemRules: min1 }),
makeColumn('Широта', 'latitude', { width: 150, editable: true, render: coordsFixed }), makeColumn('Широта', 'latitude', { width: 150, editable: true, render: coordsFormat }),
makeColumn('Долгота', 'longitude', { width: 150, editable: true, render: coordsFixed }), makeColumn('Долгота', 'longitude', { width: 150, editable: true, render: coordsFormat }),
makeTimezoneColumn('Зона', 'timezone', null, true, { width: 150 }), makeTimezoneColumn('Зона', 'timezone', null, true, { width: 150 }),
] ]
@ -77,6 +75,7 @@ const DepositController = memo(() => {
onRowEdit={tableHandlers.edit} onRowEdit={tableHandlers.edit}
onRowDelete={tableHandlers.delete} onRowDelete={tableHandlers.delete}
tableName={'admin_deposit_controller'} tableName={'admin_deposit_controller'}
scroll={{ x: true }}
/> />
</> </>
) )

View File

@ -82,6 +82,7 @@ const PermissionController = memo(() => {
onRowEdit={tableHandlers.edit} onRowEdit={tableHandlers.edit}
onRowDelete={tableHandlers.delete} onRowDelete={tableHandlers.delete}
tableName={'admin_permission_controller'} tableName={'admin_permission_controller'}
scroll={{ x: true }}
/> />
</> </>
) )

View File

@ -89,6 +89,7 @@ const RoleController = memo(() => {
onRowEdit={tableHandlers.edit} onRowEdit={tableHandlers.edit}
onRowDelete={tableHandlers.delete} onRowDelete={tableHandlers.delete}
tableName={'admin_role_controller'} tableName={'admin_role_controller'}
scroll={{ x: true }}
/> />
</> </>
) )

View File

@ -6,7 +6,7 @@ import { Button, Input } from 'antd'
import { import {
defaultPagination, defaultPagination,
makeColumn, makeColumn,
makeDateSorter, makeDateColumn,
makeNumericColumn, makeNumericColumn,
makeNumericRender, makeNumericRender,
makeTextColumn, makeTextColumn,
@ -53,7 +53,7 @@ const TelemetryController = memo(() => {
makeNumericColumn('ID', 'id', null, null, makeNumericRender(0)), makeNumericColumn('ID', 'id', null, null, makeNumericRender(0)),
makeTextColumn('UID', 'remoteUid'), makeTextColumn('UID', 'remoteUid'),
makeTextColumn('Назначена на скважину', 'realWell'), makeTextColumn('Назначена на скважину', 'realWell'),
makeTextColumn('Дата начала бурения', 'drillingStartDate', null, makeDateSorter('drillingStartDate')), makeDateColumn('Дата начала бурения', 'drillingStartDate'),
makeTextColumn('Часовой пояс', 'timeZoneId'), makeTextColumn('Часовой пояс', 'timeZoneId'),
makeTextColumn('Скважина', 'well'), makeTextColumn('Скважина', 'well'),
makeTextColumn('Куст', 'cluster'), makeTextColumn('Куст', 'cluster'),
@ -115,6 +115,7 @@ const TelemetryController = memo(() => {
pagination={defaultPagination} pagination={defaultPagination}
dataSource={filteredTelemetryData} dataSource={filteredTelemetryData}
tableName={'admin_telemetry_controller'} tableName={'admin_telemetry_controller'}
scroll={{ x: true }}
/> />
</> </>
) )

View File

@ -1,13 +1,8 @@
import { Layout } from 'antd'
import { memo, useMemo } from 'react' import { memo, useMemo } from 'react'
import { Navigate, Route, Routes } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import { RootPathContext, useRootPath } from '@asb/context' import { RootPathContext, useRootPath } from '@asb/context'
import { PrivateMenu } from '@components/Private' import { wrapPrivateComponent } from '@utils'
import { NoAccessComponent, wrapPrivateComponent } from '@utils'
import TelemetryViewer from './TelemetryViewer'
import TelemetryMerger from './TelemetryMerger'
const Telemetry = memo(() => { const Telemetry = memo(() => {
const root = useRootPath() const root = useRootPath()
@ -15,23 +10,7 @@ const Telemetry = memo(() => {
return ( return (
<RootPathContext.Provider value={rootPath}> <RootPathContext.Provider value={rootPath}>
<Layout> <Outlet />
<PrivateMenu>
<PrivateMenu.Link content={TelemetryViewer} />
<PrivateMenu.Link content={TelemetryMerger} />
</PrivateMenu>
<Layout>
<Layout.Content className={'site-layout-background'}>
<Routes>
<Route index element={<Navigate to={TelemetryViewer.route} replace />} />
<Route path={'*'} element={<NoAccessComponent />} />
<Route path={TelemetryViewer.route} element={<TelemetryViewer />} />
<Route path={TelemetryMerger.route} element={<TelemetryMerger />} />
</Routes>
</Layout.Content>
</Layout>
</Layout>
</RootPathContext.Provider> </RootPathContext.Provider>
) )
}) })

View File

@ -214,6 +214,7 @@ const UserController = memo(() => {
buttonsWidth={120} buttonsWidth={120}
pagination={defaultPagination} pagination={defaultPagination}
tableName={'admin_user_controller'} tableName={'admin_user_controller'}
scroll={{ x: true }}
/> />
</LoaderPortal> </LoaderPortal>
<ChangePassword <ChangePassword

View File

@ -59,6 +59,7 @@ const VisitLog = memo(() => {
dataSource={filteredLogData} dataSource={filteredLogData}
pagination={defaultPagination} pagination={defaultPagination}
tableName={'visit_log'} tableName={'visit_log'}
scroll={{ x: true }}
/> />
</> </>
) )

View File

@ -21,9 +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 { arrayOrDefault, wrapPrivateComponent } from '@utils' import { arrayOrDefault, coordsFormat, wrapPrivateComponent } from '@utils'
import { coordsFixed } from '../DepositController'
const wellTypes = [ const wellTypes = [
{ value: 1, label: 'Наклонно-направленная' }, { value: 1, label: 'Наклонно-направленная' },
@ -98,8 +96,8 @@ const WellController = memo(() => {
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: coordsFormat }),
makeColumn('Долгота', 'longitude', { width: 150, editable: true, render: coordsFixed }), makeColumn('Долгота', 'longitude', { width: 150, editable: true, render: coordsFormat }),
makeColumn('Телеметрия', 'telemetry', { makeColumn('Телеметрия', 'telemetry', {
editable: true, editable: true,
render: (telemetry) => <TelemetryView telemetry={telemetry} />, render: (telemetry) => <TelemetryView telemetry={telemetry} />,

View File

@ -1,62 +1,57 @@
import { Navigate, Route, Routes } from 'react-router-dom' import { Navigate, Route, Routes } from 'react-router-dom'
import { memo, useMemo } from 'react' import { lazy, memo, useMemo } from 'react'
import { Layout } from 'antd'
import { RootPathContext, useRootPath } from '@asb/context' import { RootPathContext, useLayoutProps, useRootPath } from '@asb/context'
import { AdminLayoutPortal } from '@components/Layout'
import { PrivateMenu } from '@components/Private'
import { NoAccessComponent, wrapPrivateComponent } from '@utils' import { NoAccessComponent, wrapPrivateComponent } from '@utils'
import ClusterController from './ClusterController' import AdminNavigationMenu from './AdminNavigationMenu'
import CompanyController from './CompanyController'
import DepositController from './DepositController' const ClusterController = lazy(() => import('./ClusterController'))
import UserController from './UserController' const CompanyController = lazy(() => import('./CompanyController'))
import WellController from './WellController' const DepositController = lazy(() => import('./DepositController'))
import RoleController from './RoleController' const UserController = lazy(() => import('./UserController'))
import CompanyTypeController from './CompanyTypeController' const WellController = lazy(() => import('./WellController'))
import PermissionController from './PermissionController' const RoleController = lazy(() => import('./RoleController'))
import Telemetry from './Telemetry' const CompanyTypeController = lazy(() => import('./CompanyTypeController'))
import VisitLog from './VisitLog' const PermissionController = lazy(() => import('./PermissionController'))
const Telemetry = lazy(() => import('./Telemetry'))
const TelemetryViewer = lazy(() => import('./Telemetry/TelemetryViewer'))
const TelemetryMerger = lazy(() => import('./Telemetry/TelemetryMerger'))
const VisitLog = lazy(() => import('./VisitLog'))
const layoutProps = {
sider: <AdminNavigationMenu />,
title: 'Администраторская панель',
isAdmin: true,
}
const AdminPanel = memo(() => { const AdminPanel = memo(() => {
const root = useRootPath() const root = useRootPath()
const rootPath = useMemo(() => `${root}/admin`, [root]) const rootPath = useMemo(() => `${root}/admin`, [root])
useLayoutProps(layoutProps)
return ( return (
<RootPathContext.Provider value={rootPath}> <RootPathContext.Provider value={rootPath}>
<AdminLayoutPortal title={'Администраторская панель'}> <Routes>
<PrivateMenu> <Route index element={<Navigate to={'visit_log'} replace />} />
<PrivateMenu.Link content={DepositController} /> <Route path={'*'} element={<NoAccessComponent />} />
<PrivateMenu.Link content={ClusterController} /> <Route path={'deposit'} element={<DepositController />} />
<PrivateMenu.Link content={WellController} /> <Route path={'cluster'} element={<ClusterController />} />
<PrivateMenu.Link content={UserController} /> <Route path={'well'} element={<WellController />} />
<PrivateMenu.Link content={CompanyController} /> <Route path={'user'} element={<UserController />} />
<PrivateMenu.Link content={CompanyTypeController} /> <Route path={'company'} element={<CompanyController />} />
<PrivateMenu.Link content={RoleController} /> <Route path={'company_type'} element={<CompanyTypeController />} />
<PrivateMenu.Link content={PermissionController} /> <Route path={'role'} element={<RoleController />} />
<PrivateMenu.Link content={Telemetry} /> <Route path={'permission'} element={<PermissionController />} />
<PrivateMenu.Link content={VisitLog} /> <Route path={'telemetry'} element={<Telemetry />}>
</PrivateMenu> <Route index element={<Navigate to={'viewer'} replace />} />
<Route path={'*'} element={<NoAccessComponent />} />
<Layout> <Route path={'viewer'} element={<TelemetryViewer />} />
<Layout.Content className={'site-layout-background'}> <Route path={'merger'} element={<TelemetryMerger />} />
<Routes> </Route>
<Route index element={<Navigate to={VisitLog.route} replace />} /> <Route path={'visit_log'} element={<VisitLog />} />
<Route path={'*'} element={<NoAccessComponent />} /> </Routes>
<Route path={DepositController.route} element={<DepositController />} />
<Route path={ClusterController.route} element={<ClusterController />} />
<Route path={WellController.route} element={<WellController />} />
<Route path={UserController.route} element={<UserController />} />
<Route path={CompanyController.route} element={<CompanyController />} />
<Route path={CompanyTypeController.route} element={<CompanyTypeController />} />
<Route path={RoleController.route} element={<RoleController />} />
<Route path={PermissionController.route} element={<PermissionController />} />
<Route path={Telemetry.route} element={<Telemetry />} />
<Route path={VisitLog.route} element={<VisitLog />} />
</Routes>
</Layout.Content>
</Layout>
</AdminLayoutPortal>
</RootPathContext.Provider> </RootPathContext.Provider>
) )
}) })

View File

@ -1,44 +0,0 @@
import { memo, useMemo } from 'react'
import { Navigate, Route, Routes } from 'react-router-dom'
import { Layout } from 'antd'
import { RootPathContext, useRootPath } from '@asb/context'
import { PrivateMenu } from '@components/Private'
import { NoAccessComponent, wrapPrivateComponent } from '@utils'
import Statistics from './Statistics'
import WellCompositeEditor from './WellCompositeEditor'
const Analytics = memo(() => {
const root = useRootPath()
const rootPath = useMemo(() => `${root}/analytics`, [root])
return (
<RootPathContext.Provider value={rootPath}>
<Layout>
<PrivateMenu className={'well_menu'}>
<PrivateMenu.Link content={WellCompositeEditor} />
<PrivateMenu.Link key={'statistics'} title={'Оценка по ЦБ'} content={Statistics} />
</PrivateMenu>
<Layout>
<Layout.Content className={'site-layout-background'}>
<Routes>
<Route index element={<Navigate to={WellCompositeEditor.getKey()} replace />} />
<Route path={'*'} element={<NoAccessComponent />} />
<Route path={WellCompositeEditor.route} element={<WellCompositeEditor />} />
<Route path={Statistics.route} element={<Statistics />} />
</Routes>
</Layout.Content>
</Layout>
</Layout>
</RootPathContext.Provider>
)
})
export default wrapPrivateComponent(Analytics, {
requirements: [],
title: 'Аналитика',
route: 'analytics/*',
key: 'analytics',
})

View File

@ -1,6 +1,6 @@
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import { useState, useEffect, memo, useMemo } from 'react' import { useState, useEffect, memo, useMemo, lazy, Suspense } from 'react'
import { Tag, Button, Modal } from 'antd' import { Button, Modal } from 'antd'
import { LineChartOutlined, ProfileOutlined, TeamOutlined } from '@ant-design/icons' import { LineChartOutlined, ProfileOutlined, TeamOutlined } from '@ant-design/icons'
import { import {
@ -13,9 +13,9 @@ import {
makeNumericRender, makeNumericRender,
makeNumericColumn, makeNumericColumn,
} from '@components/Table' } from '@components/Table'
import { CompanyView } from '@components/views'
import LoaderPortal from '@components/LoaderPortal' import LoaderPortal from '@components/LoaderPortal'
import PointerIcon from '@components/icons/PointerIcon' import PointerIcon from '@components/icons/PointerIcon'
import SuspenseFallback from '@components/SuspenseFallback'
import { invokeWebApiWrapperAsync } from '@components/factory' import { invokeWebApiWrapperAsync } from '@components/factory'
import { import {
getOperations, getOperations,
@ -25,9 +25,10 @@ import {
wrapPrivateComponent wrapPrivateComponent
} from '@utils' } from '@utils'
import Tvd from '@pages/WellOperations/Tvd' const Tvd = lazy(() => import('@pages/Well/WellOperations/Tvd'))
import CompaniesTable from './CompaniesTable'
import WellOperationsTable from './WellOperationsTable' import WellOperationsTable from './WellOperationsTable'
import CompaniesTable from '@pages/Cluster/CompaniesTable'
const filtersMinMax = [ const filtersMinMax = [
{ text: 'min', value: 'min' }, { text: 'min', value: 'min' },
@ -173,23 +174,26 @@ const ClusterWells = memo(({ statsWells }) => {
pagination={false} pagination={false}
rowKey={(record) => record.caption} rowKey={(record) => record.caption}
tableName={'cluster'} tableName={'cluster'}
scroll={{ x: true }}
/> />
<Modal <Modal
title={'TVD'} title={'TVD'}
centered centered
visible={isTVDModalVisible} open={isTVDModalVisible}
onCancel={() => setIsTVDModalVisible(false)} onCancel={() => setIsTVDModalVisible(false)}
width={1500} width={1500}
footer={null} footer={null}
> >
<Tvd style={{ minHeight: '600px' }} well={selectedWell} /> <Suspense fallback={<SuspenseFallback style={{ minHeight: '100%' }} />}>
<Tvd style={{ minHeight: '600px' }} well={selectedWell} />
</Suspense>
</Modal> </Modal>
<Modal <Modal
title={'Операции'} title={'Операции'}
centered centered
visible={isOpsModalVisible} open={isOpsModalVisible}
onCancel={() => setIsOpsModalVisible(false)} onCancel={() => setIsOpsModalVisible(false)}
width={1500} width={1500}
footer={null} footer={null}
@ -202,7 +206,7 @@ const ClusterWells = memo(({ statsWells }) => {
<Modal <Modal
title={'Участники'} title={'Участники'}
centered centered
visible={isCompaniesModalVisible} open={isCompaniesModalVisible}
onCancel={() => setIsCompaniesModalVisible(false)} onCancel={() => setIsCompaniesModalVisible(false)}
width={1500} width={1500}
footer={null} footer={null}

View File

@ -1,18 +1,22 @@
import React, { memo, useMemo } from 'react' import { memo, useMemo } from 'react'
import { BankOutlined } from '@ant-design/icons' import { BankOutlined } from '@ant-design/icons'
import { makeTextColumn, Table } from '@components/Table' import { makeColumn, makeTextColumn, Table } from '@components/Table'
const columns = [ const columns = [
makeTextColumn('', 'logo'), makeColumn('', 'logo'),
makeTextColumn('Название компании', 'caption'), makeTextColumn('Название компании', 'caption'),
makeTextColumn('Тип компании', 'companyTypeCaption'), makeTextColumn('Тип компании', 'companyTypeCaption'),
] ]
const CompaniesTable = memo(({companies}) => { const CompaniesTable = memo(({ companies }) => {
const dataCompanies = useMemo(() => companies?.map((company) => ({ const dataCompanies = useMemo(() => companies?.map((company) => ({
key: company.id, key: company.id,
logo: company?.logo ? <img src={company.logo}/> : <BankOutlined/>, logo: (
<div className={'centered'}>
{company?.logo ? <img src={company.logo}/> : <BankOutlined/>}
</div>
),
caption: company.caption, caption: company.caption,
companyTypeCaption: company.companyTypeCaption, companyTypeCaption: company.companyTypeCaption,
})), [companies]) })), [companies])
@ -30,4 +34,4 @@ const CompaniesTable = memo(({companies}) => {
) )
}) })
export default CompaniesTable export default CompaniesTable

View File

@ -7,8 +7,8 @@ import { getPrecision } from '@utils/functions'
const columns = [ const columns = [
makeTextColumn('Конструкция секции', 'sectionType'), makeTextColumn('Конструкция секции', 'sectionType'),
makeTextColumn('Операция', 'operationName'), makeTextColumn('Операция', 'operationName'),
makeNumericColumnPlanFact('Глубина забоя', 'depth', null, null, getPrecision), makeNumericColumnPlanFact('Глубина забоя', 'depth', null, null, (number) => getPrecision(number)),
makeNumericColumnPlanFact('Часы', 'durationHours', null, null, getPrecision), makeNumericColumnPlanFact('Часы', 'durationHours', null, null, (number) => getPrecision(number)),
makeNumericColumnPlanFact('Комментарий', 'comment', null, null, (text) => text ?? '-') makeNumericColumnPlanFact('Комментарий', 'comment', null, null, (text) => text ?? '-')
] ]

View File

@ -1,19 +1,25 @@
import { useState, useEffect, memo } from 'react' import { useState, useEffect, memo } from 'react'
import { useParams } from 'react-router-dom'
import { LayoutPortal } from '@components/Layout' import { useLayoutProps } from '@asb/context'
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 { arrayOrDefault, wrapPrivateComponent } from '@utils'
import { OperationStatService } from '@api' import { OperationStatService } from '@api'
import ClusterWells from './ClusterWells' import ClusterWells from './ClusterWells'
import { useParams } from 'react-router-dom'
const layoutProps = {
title: 'Анализ скважин куста'
}
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)
useLayoutProps(layoutProps)
useEffect(() => { useEffect(() => {
invokeWebApiWrapperAsync( invokeWebApiWrapperAsync(
async () => { async () => {
@ -27,11 +33,9 @@ const Cluster = memo(() => {
}, [idCluster]) }, [idCluster])
return ( return (
<LayoutPortal title={'Анализ скважин куста'}> <LoaderPortal show={showLoader}>
<LoaderPortal show={showLoader}> <ClusterWells statsWells={data} />
<ClusterWells statsWells={data} /> </LoaderPortal>
</LoaderPortal>
</LayoutPortal>
) )
}) })

View File

@ -1,10 +1,10 @@
import { Map, Overlay } from 'pigeon-maps' import { Map, Overlay } from 'pigeon-maps'
import { useState, useEffect, memo } from 'react' import { useState, useEffect, memo, useMemo } from 'react'
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import { Popover, Badge } from 'antd' import { Popover, Badge } from 'antd'
import { useLayoutProps } from '@asb/context'
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 { arrayOrDefault, limitValue, wrapPrivateComponent } from '@utils'
@ -47,8 +47,26 @@ const Deposit = memo(() => {
const [showLoader, setShowLoader] = useState(false) const [showLoader, setShowLoader] = useState(false)
const [viewParams, setViewParams] = useState(defaultViewParams) const [viewParams, setViewParams] = useState(defaultViewParams)
const setLayoutProps = useLayoutProps()
const location = useLocation() const location = useLocation()
const selectorProps = useMemo(() => {
const hasId = location.pathname.length > '/deposit/'.length
return {
expand: hasId ? [location.pathname] : true,
current: hasId ? location.pathname : undefined,
}
}, [location.pathname])
useEffect(() => setLayoutProps({
sheet: false,
showSelector: true,
selectorProps,
title: 'Месторождение',
}), [setLayoutProps, selectorProps])
useEffect(() => { useEffect(() => {
invokeWebApiWrapperAsync( invokeWebApiWrapperAsync(
async () => { async () => {
@ -63,37 +81,35 @@ const Deposit = memo(() => {
}, []) }, [])
return ( return (
<LayoutPortal noSheet showSelector title={'Месторождение'}> <LoaderPortal show={showLoader}>
<LoaderPortal show={showLoader}> <div className={'h-100vh'} style={{ overflow: 'hidden' }}>
<div className={'h-100vh'}> <Map {...viewParams}>
<Map {...viewParams}> {depositsData.map(deposit => (
{depositsData.map(deposit => ( <Overlay
<Overlay width={32}
width={32} anchor={[deposit.latitude, deposit.longitude]}
anchor={[deposit.latitude, deposit.longitude]} key={`${deposit.latitude} ${deposit.longitude}`}
key={`${deposit.latitude} ${deposit.longitude}`} >
> <Popover content={
<Popover content={ <div>
<div> {deposit.clusters.map(cluster => (
{deposit.clusters.map(cluster => ( <Link key={cluster.id} to={{ pathname: `/cluster/${cluster.id}`, state: { from: location.pathname }}}>
<Link key={cluster.id} to={{ pathname: `/cluster/${cluster.id}`, state: { from: location.pathname }}}> <div>{cluster.caption}</div>
<div>{cluster.caption}</div> </Link>
</Link> ))}
))} </div>
</div> } trigger={['click']} title={deposit.caption}>
} trigger={['click']} title={deposit.caption}> <div style={{cursor: 'pointer'}}>
<div style={{cursor: 'pointer'}}> <Badge count={deposit.clusters.length}>
<Badge count={deposit.clusters.length}> <PointerIcon state={'active'} width={48} height={59} />
<PointerIcon state={'active'} width={48} height={59} /> </Badge>
</Badge> </div>
</div> </Popover>
</Popover> </Overlay>
</Overlay> ))}
))} </Map>
</Map> </div>
</div> </LoaderPortal>
</LoaderPortal>
</LayoutPortal>
) )
}) })

View File

@ -7,8 +7,6 @@ import { downloadFile, invokeWebApiWrapperAsync } from '@components/factory'
import { wrapPrivateComponent } from '@utils' import { wrapPrivateComponent } from '@utils'
import { FileService, WellService } from '@api' import { FileService, WellService } from '@api'
import AccessDenied from './AccessDenied'
const { Paragraph, Text } = Typography const { Paragraph, Text } = Typography
export const getLinkToFile = (fileInfo) => `/file_download/${fileInfo.idWell}/${fileInfo.id}` export const getLinkToFile = (fileInfo) => `/file_download/${fileInfo.idWell}/${fileInfo.id}`
@ -104,4 +102,4 @@ FileDownload.displayName = 'FileDownloadMemo'
export default wrapPrivateComponent(FileDownload, { export default wrapPrivateComponent(FileDownload, {
requirements: ['File.get'], requirements: ['File.get'],
route: 'file_download/:idWell/:idFile/*', route: 'file_download/:idWell/:idFile/*',
}, <AccessDenied />) })

View File

@ -1,50 +0,0 @@
import { memo, Fragment } from 'react'
import { Empty, Form } from 'antd'
import { Grid, GridItem } from '@components/Grid'
import '@styles/index.css'
import '@styles/measure.css'
const colsCount = 3
export const View = memo(({ columns, item }) => !item || !columns?.length ? (
<Empty key={'empty'} image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<Grid>
{columns.map((column, i) => (
<Fragment key={i}>
<GridItem
key={column.dataIndex}
row={Math.floor(i / colsCount) + 1}
col={(i % colsCount) * 2 + 1}
className={'measure-column-header'}
>
{column.title}
</GridItem>
<GridItem
key={column.title}
row={Math.floor(i / colsCount) + 1}
col={(i % colsCount) * 2 + 2}
className={'measure-column-value'}
style={{ padding: 0 }}
>
{column.render ? (
<Form.Item
key={column.dataIndex}
name={column.dataIndex}
style={{ margin: 0 }}
>
{column.render(item[column.dataIndex])}
</Form.Item>
) : (
<p key={column.title} className={'m-5px'}>
{item[column.dataIndex]}
</p>
)}
</GridItem>
</Fragment>
))}
</Grid>
))

View File

@ -1,71 +0,0 @@
import { makeColumn } from '@components/Table'
import { numericColumnOptions, textColumnOptions } from './columnsCommon'
export const columnsDrillingFluid = [
makeColumn('Наименование', 'name', textColumnOptions),
makeColumn('Температура, °C', 'temperature', numericColumnOptions),
makeColumn('Плотность, г/см³', 'density', numericColumnOptions),
makeColumn('Усл. вязкость, сек', 'conditionalViscosity', numericColumnOptions),
makeColumn('R300', 'r300', numericColumnOptions),
makeColumn('R600', 'r600', numericColumnOptions),
makeColumn('R3/R6', 'r3r6', numericColumnOptions),
makeColumn('ДНС, дПа', 'dnsDpa', numericColumnOptions),
makeColumn('Пластич. вязкость, сПз', 'plasticViscocity', numericColumnOptions),
makeColumn('СНС, дПа', 'snsDpa', numericColumnOptions),
makeColumn('R3/R6 49С', 'r3r649С', numericColumnOptions),
makeColumn('ДНС 49С, дПа', 'dns49Cdpa', numericColumnOptions),
makeColumn('Пластич. вязкость 49С, сПз', 'plasticViscocity49c', numericColumnOptions),
makeColumn('СНС 49С, дПа', 'sns49Cdpa', numericColumnOptions),
makeColumn('МВТ, кг/м³', 'mbt', numericColumnOptions),
makeColumn('Песок, %', 'sand', numericColumnOptions),
makeColumn('Фильтрация, см³/30мин', 'filtering', numericColumnOptions),
makeColumn('Корка, мм', 'crust', numericColumnOptions),
makeColumn('KTK', 'ktk', numericColumnOptions),
makeColumn('pH', 'ph', numericColumnOptions),
makeColumn('Жесткость, мг/л', 'hardness', numericColumnOptions),
makeColumn('Хлориды, мг/л', 'chlorides', numericColumnOptions),
makeColumn('PF', 'pf', numericColumnOptions),
makeColumn('Mf', 'mf', numericColumnOptions),
makeColumn('Pm', 'pm', numericColumnOptions),
makeColumn('Твердая фаза раствора, %', 'fluidSolidPhase', numericColumnOptions),
makeColumn('Смазка, %', 'grease', numericColumnOptions),
makeColumn('Карбонат кальция, кг/м³', 'calciumCarbonate', numericColumnOptions),
]
export const drillingFluidDefaultData = {
idWell: 0,
key: 'drillingFluidDefaultData',
idCategory: 0,
isDefaultData: true,
data: {
name: 0,
temperature: 0,
density: 0,
conditionalViscosity: 0,
r300: 0,
r600: 0,
r3r6: 0,
dnsDpa: 0,
plasticViscocity: 0,
snsDpa: 0,
r3r649С: 0,
dns49Cdpa: 0,
plasticViscocity49c: 0,
sns49Cdpa: 0,
mbt: 0,
sand: 0,
filtering: 0,
crust: 0,
ktk: 0,
ph: 0,
hardness: 0,
chlorides: 0,
pf: 0,
mf: 0,
pm: 0,
fluidSolidPhase: 0,
grease: 0,
calciumCarbonate: 0
}
}

View File

@ -1,59 +0,0 @@
import { makeColumn } from '@components/Table'
import { numericColumnOptions, textColumnOptions } from './columnsCommon'
export const columnsMudDiagram = [
makeColumn('N пробы', 'probeNumber', numericColumnOptions),
makeColumn('Глубина отбора пробы', 'probeExtractionDepth', numericColumnOptions),
makeColumn('Песчаник (%)', 'sandstone', numericColumnOptions),
makeColumn('Алевролит (%)', 'siltstone', numericColumnOptions),
makeColumn('Аргиллит (%)', 'argillit', numericColumnOptions),
makeColumn('Аргиллит бит. (%)', 'brokenArgillit', numericColumnOptions),
makeColumn('Уголь (%)', 'coal', numericColumnOptions),
makeColumn('Песок (%)', 'sand', numericColumnOptions),
makeColumn('Глина (%)', 'clay', numericColumnOptions),
makeColumn('Известняк (%)', 'camstone', numericColumnOptions),
makeColumn('Цемент (%)', 'cement', numericColumnOptions),
makeColumn('Краткое описание', 'summary', textColumnOptions),
makeColumn('ЛБА бурового раствора', 'drillingMud', numericColumnOptions),
makeColumn('ЛБА (шлама)', 'sludge', numericColumnOptions),
makeColumn('Сумма УВ мах. (абс%)', 'maxSum', numericColumnOptions),
makeColumn('С1 метан (отн%)', 'methane', numericColumnOptions),
makeColumn('С2 этан (отн%)', 'ethane', numericColumnOptions),
makeColumn('С3 пропан (отн%)', 'propane', numericColumnOptions),
makeColumn('С4 бутан (отн%)', 'butane', numericColumnOptions),
makeColumn('С5 пентан (отн%)', 'pentane', numericColumnOptions),
makeColumn('Мех. скорость', 'mechanicalSpeed', numericColumnOptions),
makeColumn('Предварительное заключение о насыщении по ГК', 'preliminaryConclusion', textColumnOptions),
]
export const mudDiagramDefaultData = {
idWell: 0,
key: 'mudDiagramDefaultData',
idCategory: 0,
isDefaultData: true,
data: {
probeNumber: 0,
probeExtractionDepth: 0,
sandstone: 0,
siltstone: 0,
argillit: 0,
brokenArgillit: 0,
coal: 0,
sand: 0,
clay: 0,
camstone: 0,
cement: 0,
summary: '-',
drillingMud: 0,
sludge: 0,
maxSum: 0,
methane: 0,
ethane: 0,
propane: 0,
butane: 0,
pentane: 0,
mechanicalSpeed: 0,
preliminaryConclusion: '-'
}
}

View File

@ -1,48 +0,0 @@
import { makeColumn } from '@components/Table'
import { numericColumnOptions, textColumnOptions } from './columnsCommon'
export const columnsNnb = [
makeColumn('Глубина по стволу, м', 'depth', textColumnOptions),
makeColumn('Зенитный угол, град', 'zenithAngle', numericColumnOptions),
makeColumn('Азимут магнитный, град', 'magneticAzimuth', numericColumnOptions),
makeColumn('Азимут истинный, град', 'trueAzimuth', numericColumnOptions),
makeColumn('Азимут дирекц., град', 'directAzimuth', numericColumnOptions),
makeColumn('Глубина по вертикали, м', 'verticalDepth', numericColumnOptions),
makeColumn('Абсолютная отметка, м', 'absoluteMark', numericColumnOptions),
makeColumn('Лок. смещение к северу, м', 'localNorthOffset', numericColumnOptions),
makeColumn('Лок. смещение к востоку, м', 'localEastOffset', numericColumnOptions),
makeColumn('Отклонение от устья, м', 'outFallOffset', numericColumnOptions),
makeColumn('Азимут смещения, град', 'offsetAzimuth', numericColumnOptions),
makeColumn('Пространст.\nинтенсивность, град/10 м', 'areaIntensity', numericColumnOptions),
makeColumn('Угол установки отклон., град', 'offsetStopAngle', numericColumnOptions),
makeColumn('Интенсив. по зениту, град/10 м', 'zenithIntensity', numericColumnOptions),
makeColumn('Комментарий', 'comment', numericColumnOptions),
makeColumn('Разница вертикальных глубин\nмежду планом и фактом', 'depthPlanFactDifference', numericColumnOptions),
makeColumn('Расстояние в пространстве\nмежду планом и фактом', 'distancePlanFactDifference', numericColumnOptions),
]
export const nnbDefaultData = {
idWell: 0,
key: 'nnbDefaultData',
idCategory: 0,
isDefaultData: true,
data: {
depth: 0,
zenithAngle: 0,
magneticAzimuth: 0,
trueAzimuth: 0,
directAzimuth: 0,
verticalDepth: 0,
absoluteMark: 0,
localNorthOffset: 0,
localEastOffset: 0,
outFallOffset: 0,
offsetAzimuth: 0,
areaIntensity: '-',
offsetStopAngle: 0,
zenithIntensity: 0,
comment: '-',
depthPlanFactDifference: 0,
distancePlanFactDifference: 0
}
}

View File

@ -1,48 +0,0 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { memo, useMemo } from 'react'
import { FilePdfOutlined } from '@ant-design/icons'
import { Layout } from 'antd'
import { RootPathContext, useRootPath } from '@asb/context'
import { PrivateMenu } from '@components/Private'
import { NoAccessComponent, wrapPrivateComponent } from '@utils'
import DailyReport from './DailyReport'
import DiagramReport from './DiagramReport'
const { Content } = Layout
const Reports = memo(() => {
const root = useRootPath()
const rootPath = useMemo(() => `${root}/reports`, [root])
return (
<RootPathContext.Provider value={rootPath}>
<Layout>
<PrivateMenu className={'well_menu'}>
<PrivateMenu.Link content={DiagramReport} icon={<FilePdfOutlined />} />
<PrivateMenu.Link content={DailyReport} />
</PrivateMenu>
<Layout>
<Content className={'site-layout-background'}>
<Routes>
<Route index element={<Navigate to={'diagram_report'} replace />} />
<Route path={'*'} element={<NoAccessComponent />} />
<Route path={DiagramReport.route} element={<DiagramReport />} />
<Route path={DailyReport.route} element={<DailyReport />} />
</Routes>
</Content>
</Layout>
</Layout>
</RootPathContext.Provider>
)
})
export default wrapPrivateComponent(Reports, {
requirements: [],
title: 'Рапорта',
route: 'reports/*',
key: 'reports',
})

View File

@ -1,60 +0,0 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { memo, useMemo } from 'react'
import { Layout } from 'antd'
import { AlertOutlined, FundViewOutlined, DatabaseOutlined } from '@ant-design/icons'
import { RootPathContext, useRootPath } from '@asb/context'
import { PrivateMenu } from '@components/Private'
import { NoAccessComponent, wrapPrivateComponent } from '@utils'
import Archive from './Archive'
import Messages from './Messages'
import Operations from './Operations'
import DashboardNNB from './DashboardNNB'
import TelemetryView from './TelemetryView'
import '@styles/index.css'
const { Content } = Layout
const Telemetry = memo(() => {
const root = useRootPath()
const rootPath = useMemo(() => `${root}/telemetry`, [root])
return (
<RootPathContext.Provider value={rootPath}>
<Layout>
<PrivateMenu className={'well_menu'}>
<PrivateMenu.Link content={TelemetryView} icon={<FundViewOutlined />} />
<PrivateMenu.Link content={Messages} icon={<AlertOutlined/>} />
<PrivateMenu.Link content={Archive} icon={<DatabaseOutlined />} />
<PrivateMenu.Link content={DashboardNNB} />
<PrivateMenu.Link content={Operations} />
</PrivateMenu>
<Layout>
<Content className={'site-layout-background'}>
<Routes>
<Route index element={<Navigate to={TelemetryView.route} replace />} />
<Route path={'*'} element={<NoAccessComponent />} />
<Route path={TelemetryView.route} element={<TelemetryView />} />
<Route path={Messages.route} element={<Messages />} />
<Route path={Archive.route} element={<Archive />} />
<Route path={DashboardNNB.route} element={<DashboardNNB />} />
<Route path={Operations.route} element={<Operations />} />
</Routes>
</Content>
</Layout>
</Layout>
</RootPathContext.Provider>
)
})
export default wrapPrivateComponent(Telemetry, {
requirements: [],
icon: <FundViewOutlined />,
title: 'Телеметрия',
route: 'telemetry/*',
key: 'telemetry',
})

View File

@ -1,103 +0,0 @@
import {
FolderOutlined,
FilePdfOutlined,
ExperimentOutlined,
DeploymentUnitOutlined,
} from '@ant-design/icons'
import { Layout } from 'antd'
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { Navigate, Route, Routes, useParams } from 'react-router-dom'
import { WellContext, RootPathContext, useRootPath } from '@asb/context'
import { LayoutPortal } from '@components/Layout'
import { PrivateMenu } from '@components/Private'
import { invokeWebApiWrapperAsync } from '@components/factory'
import { NoAccessComponent, wrapPrivateComponent } from '@utils'
import { WellService } from '@api'
import Measure from './Measure'
import Reports from './Reports'
import Analytics from './Analytics'
import Documents from './Documents'
import Telemetry from './Telemetry'
import WellOperations from './WellOperations'
import DrillingProgram from './DrillingProgram'
import '@styles/index.css'
const { Content } = Layout
const Well = memo(() => {
const { idWell } = useParams()
const [well, setWell] = useState({ id: idWell })
const root = useRootPath()
const rootPath = useMemo(() => `${root}/well/${idWell}`, [root, idWell])
useEffect(() => {
invokeWebApiWrapperAsync(
async () => {
const well = await WellService.get(idWell)
setWell(well ?? { id: idWell })
},
undefined,
'Не удалось получить данные по скважине'
)
}, [idWell])
const updateWell = useCallback((data) => invokeWebApiWrapperAsync(
async () => {
const newWell = { ...well, ...data }
await WellService.updateWell(newWell)
setWell(newWell)
},
undefined,
`Не удалось изменить данные скважины`,
{ actionName: 'Изменение данных скважины', well }
), [well])
const wellContext = useMemo(() => [well, updateWell], [well, updateWell])
return (
<LayoutPortal>
<RootPathContext.Provider value={rootPath}>
<PrivateMenu className={'well_menu'}>
<PrivateMenu.Link content={Telemetry} />
<PrivateMenu.Link content={Reports} icon={<FilePdfOutlined />} />
<PrivateMenu.Link content={Analytics} icon={<DeploymentUnitOutlined />} />
<PrivateMenu.Link content={WellOperations} icon={<FolderOutlined />} />
<PrivateMenu.Link content={Documents} icon={<FolderOutlined />} />
<PrivateMenu.Link content={Measure} icon={<ExperimentOutlined />} />
<PrivateMenu.Link content={DrillingProgram} icon={<FolderOutlined />} />
</PrivateMenu>
<WellContext.Provider value={wellContext}>
<Layout>
<Content className={'site-layout-background'}>
<Routes>
<Route index element={<Navigate to={Telemetry.getKey()} replace />} />
<Route path={'*'} element={<NoAccessComponent />} />
<Route path={Telemetry.route} element={<Telemetry />} />
<Route path={Reports.route} element={<Reports />} />
<Route path={Analytics.route} element={<Analytics />} />
<Route path={WellOperations.route} element={<WellOperations />} />
<Route path={Documents.route} element={<Documents />} />
<Route path={Measure.route} element={<Measure />} />
<Route path={DrillingProgram.route} element={<DrillingProgram />} />
</Routes>
</Content>
</Layout>
</WellContext.Provider>
</RootPathContext.Provider>
</LayoutPortal>
)
})
export default wrapPrivateComponent(Well, {
requirements: [],
title: 'Скважина',
route: 'well/:idWell/*',
key: 'well',
})

View File

@ -2,12 +2,75 @@ import { memo, useCallback, useEffect, useState } from 'react'
import { Button, Modal, Popconfirm } from 'antd' import { Button, Modal, Popconfirm } from 'antd'
import { useWell } from '@asb/context' import { useWell } from '@asb/context'
import { Table } from '@components/Table' import { makeColumn, makeGroupColumn, makeNumericRender, makeSelectColumn, Table } from '@components/Table'
import LoaderPortal from '@components/LoaderPortal' import LoaderPortal from '@components/LoaderPortal'
import { invokeWebApiWrapperAsync } from '@components/factory' import { invokeWebApiWrapperAsync } from '@components/factory'
import { DrillParamsService } from '@api' import { DrillParamsService, WellOperationService } from '@api'
import { getColumns } from '@pages/WellOperations/WellDrillParams' const getDeepValue = (data, key) => {
if (!key || key.trim() === '') return null
const keys = key.split('.')
let out = data
while (keys.length > 0) {
if (!(keys[0] in out)) return null
out = out[keys[0]]
keys.splice(0, 1)
}
return out
}
const makeNumericSorter = (keys) => (a, b) => getDeepValue(a, keys) - getDeepValue(b, keys)
const numericRender = makeNumericRender(1)
const makeNumericColumn = (title, dataIndex, render, other) => makeColumn(title, dataIndex, {
sorter: makeNumericSorter(dataIndex),
render: (_, record, index) => {
const func = render ?? ((value) => <>{value}</>)
const item = getDeepValue(record, dataIndex)
return func(item, record, index)
},
align: 'right',
...other,
})
const makeAvgRender = (dataIndex) => (avg, record) => {
const max = record[dataIndex]?.max
const fillW = (max - avg) / max * 100
return (
<div className={'avg-column'}>
<div className={'avg-fill'} style={{ width: `${fillW}%` }} />
<div className={'avg-value'}>
{numericRender(avg)}
</div>
</div>
)
}
const makeNumericAvgRange = (title, dataIndex, defaultRender = false) => makeGroupColumn(title, [
makeNumericColumn('мин', `${dataIndex}.min`),
makeNumericColumn('сред', `${dataIndex}.avg`, defaultRender ? undefined : makeAvgRender(dataIndex)),
makeNumericColumn('макс', `${dataIndex}.max`),
])
export const getColumns = async (idWell) => {
let sectionTypes = await WellOperationService.getSectionTypes(idWell)
sectionTypes = Object.entries(sectionTypes).map(([id, value]) => ({
label: value,
value: id,
}))
return [
makeSelectColumn('Конструкция секции','idWellSectionType', sectionTypes, null, {
width: 160,
sorter: makeNumericSorter('idWellSectionType'),
}),
makeNumericAvgRange('Нагрузка, т', 'axialLoad'),
makeNumericAvgRange('Давление, атм', 'pressure'),
makeNumericAvgRange('Момент на ВСП, кН·м', 'rotorTorque', true),
makeNumericAvgRange('Обороты на ВСП, об/мин', 'rotorSpeed'),
makeNumericAvgRange('Расход, л/с', 'flow'),
]
}
export const NewParamsTable = memo(({ selectedWellsKeys }) => { export const NewParamsTable = memo(({ selectedWellsKeys }) => {
const [params, setParams] = useState([]) const [params, setParams] = useState([])
@ -54,7 +117,7 @@ export const NewParamsTable = memo(({ selectedWellsKeys }) => {
<Modal <Modal
title={'Заполнить режимы текущей скважины'} title={'Заполнить режимы текущей скважины'}
centered centered
visible={isParamsModalVisible} open={isParamsModalVisible}
onCancel={() => setIsParamsModalVisible(false)} onCancel={() => setIsParamsModalVisible(false)}
width={1700} width={1700}
footer={( footer={(

View File

@ -1,7 +1,7 @@
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import { useState, useEffect, memo, useMemo } from 'react' import { useState, useEffect, memo, useMemo, lazy, Suspense } from 'react'
import { LineChartOutlined, ProfileOutlined, TeamOutlined } from '@ant-design/icons' import { LineChartOutlined, ProfileOutlined, TeamOutlined } from '@ant-design/icons'
import { Table, Tag, Button, Badge, Divider, Modal, Row, Col } from 'antd' import { Table, Button, Badge, Divider, Modal, Row, Col } from 'antd'
import { useWell } from '@asb/context' import { useWell } from '@asb/context'
import LoaderPortal from '@components/LoaderPortal' import LoaderPortal from '@components/LoaderPortal'
@ -16,10 +16,12 @@ import {
getOperations getOperations
} from '@utils' } from '@utils'
import Tvd from '@pages/WellOperations/Tvd'
import WellOperationsTable from '@pages/Cluster/WellOperationsTable'
import NewParamsTable from './NewParamsTable' import NewParamsTable from './NewParamsTable'
import CompaniesTable from '@pages/Cluster/CompaniesTable' import SuspenseFallback from '@asb/components/SuspenseFallback'
const Tvd = lazy(() => import('@pages/Well/WellOperations/Tvd'))
const CompaniesTable = lazy(() => import('@pages/Cluster/CompaniesTable'))
const WellOperationsTable = lazy(() => import('@pages/Cluster/WellOperationsTable'))
const filtersMinMax = [ const filtersMinMax = [
{ text: 'min', value: 'min' }, { text: 'min', value: 'min' },
@ -222,38 +224,44 @@ const WellCompositeSections = memo(({ statsWells, selectedSections }) => {
<Modal <Modal
title={'TVD'} title={'TVD'}
centered centered
visible={isTVDModalVisible} open={isTVDModalVisible}
onCancel={() => setIsTVDModalVisible(false)} onCancel={() => setIsTVDModalVisible(false)}
width={1500} width={1500}
footer={null} footer={null}
> >
<Tvd well={selectedWell} style={{ height: '80vh' }} /> <Suspense fallback={<SuspenseFallback style={{ minHeight: '100%' }} />}>
<Tvd well={selectedWell} style={{ height: '80vh' }} />
</Suspense>
</Modal> </Modal>
<Modal <Modal
title={'Операции'} title={'Операции'}
centered centered
visible={isOpsModalVisible} open={isOpsModalVisible}
onCancel={() => setIsOpsModalVisible(false)} onCancel={() => setIsOpsModalVisible(false)}
width={1500} width={1500}
footer={null} footer={null}
> >
<LoaderPortal show={showLoader}> <Suspense fallback={<SuspenseFallback style={{ minHeight: '100%' }} />}>
<WellOperationsTable wellOperations={wellOperations} /> <LoaderPortal show={showLoader}>
</LoaderPortal> <WellOperationsTable wellOperations={wellOperations} />
</LoaderPortal>
</Suspense>
</Modal> </Modal>
<Modal <Modal
title={'Участники'} title={'Участники'}
centered centered
visible={isCompaniesModalVisible} open={isCompaniesModalVisible}
onCancel={() => setIsCompaniesModalVisible(false)} onCancel={() => setIsCompaniesModalVisible(false)}
width={1500} width={1500}
footer={null} footer={null}
> >
<LoaderPortal show={showLoader}> <Suspense fallback={<SuspenseFallback style={{ minHeight: '100%' }} />}>
<CompaniesTable companies={companies} /> <LoaderPortal show={showLoader}>
</LoaderPortal> <CompaniesTable companies={companies} />
</LoaderPortal>
</Suspense>
</Modal> </Modal>
</> </>
) )

View File

@ -1,35 +1,26 @@
import { useState, useEffect, memo, useMemo } from 'react'
import { Navigate, Route, Routes } from 'react-router-dom' import { Navigate, Route, Routes } from 'react-router-dom'
import { Col, Layout, Row } from 'antd' import { useState, useEffect, memo, Suspense, lazy } from 'react'
import { Row } from 'antd'
import { useWell, useRootPath } from '@asb/context' import { useWell } from '@asb/context'
import { PrivateMenu } from '@components/Private'
import LoaderPortal from '@components/LoaderPortal' import LoaderPortal from '@components/LoaderPortal'
import SuspenseFallback from '@components/SuspenseFallback'
import WellSelector from '@components/selectors/WellSelector' import WellSelector from '@components/selectors/WellSelector'
import { invokeWebApiWrapperAsync } from '@components/factory' import { invokeWebApiWrapperAsync } from '@components/factory'
import { arrayOrDefault, NoAccessComponent, wrapPrivateComponent } from '@utils' import { arrayOrDefault, NoAccessComponent, wrapPrivateComponent } from '@utils'
import { OperationStatService, WellCompositeService } from '@api' import { OperationStatService, WellCompositeService } from '@api'
import ClusterWells from '@pages/Cluster/ClusterWells'
import WellCompositeSections from './WellCompositeSections' import WellCompositeSections from './WellCompositeSections'
const { Content } = Layout import '@styles/well_composite.less'
const properties = { const ClusterWells = lazy(() => import('@pages/Cluster/ClusterWells'))
requirements: ['OperationStat.get', 'WellComposite.get'],
title: 'Композитная скважина',
route: 'composite/*',
key: 'composite',
}
const WellCompositeEditor = memo(() => { const WellCompositeEditor = memo(() => {
const [well] = useWell() const [well] = useWell()
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)
const [showTabLoader, setShowTabLoader] = useState(false)
const [selectedIdWells, setSelectedIdWells] = useState([]) const [selectedIdWells, setSelectedIdWells] = useState([])
const [selectedSections, setSelectedSections] = useState([]) const [selectedSections, setSelectedSections] = useState([])
@ -61,7 +52,7 @@ const WellCompositeEditor = memo(() => {
const stats = arrayOrDefault(await OperationStatService.getWellsStat(selectedIdWells)) const stats = arrayOrDefault(await OperationStatService.getWellsStat(selectedIdWells))
setStatsWells(stats) setStatsWells(stats)
}, },
setShowTabLoader, setShowLoader,
'Не удалось загрузить статистику по скважинам/секциям', 'Не удалось загрузить статистику по скважинам/секциям',
{ actionName: 'Получение статистики по скважинам/секциям' } { actionName: 'Получение статистики по скважинам/секциям' }
) )
@ -69,32 +60,20 @@ const WellCompositeEditor = memo(() => {
return ( return (
<LoaderPortal show={showLoader}> <LoaderPortal show={showLoader}>
<Row align={'middle'} justify={'space-between'} wrap={false} style={{ backgroundColor: 'white' }}> <Row align={'middle'} justify={'space-between'} wrap={false} style={{ backgroundColor: 'white', marginBottom: '15px' }}>
<Col span={18}> <WellSelector onChange={setSelectedIdWells} value={selectedIdWells} />
<WellSelector onChange={setSelectedIdWells} value={selectedIdWells} />
</Col>
<Col span={6}>
<PrivateMenu root={rootPath} className={'well_menu'}>
<PrivateMenu.Link content={ClusterWells} />
<PrivateMenu.Link content={WellCompositeSections} />
</PrivateMenu>
</Col>
</Row> </Row>
<Layout> <Suspense fallback={<SuspenseFallback style={{ minHeight: '100%' }} />}>
<Content className={'site-layout-background'}> <Routes>
<LoaderPortal show={showTabLoader}> <Route index element={<Navigate to={'wells'} replace/>} />
<Routes> <Route path={'*'} element={<NoAccessComponent />} />
<Route index element={<Navigate to={ClusterWells.route} replace/>} />
<Route path={'*'} element={<NoAccessComponent />} />
<Route path={ClusterWells.route} element={<ClusterWells statsWells={statsWells} />} /> <Route path={'wells'} element={<ClusterWells statsWells={statsWells} />} />
<Route path={WellCompositeSections.route} element={<WellCompositeSections statsWells={statsWells} selectedSections={selectedSections} />} /> <Route path={'sections'} element={<WellCompositeSections statsWells={statsWells} selectedSections={selectedSections} />} />
</Routes> </Routes>
</LoaderPortal> </Suspense>
</Content>
</Layout>
</LoaderPortal> </LoaderPortal>
) )
}) })
export default wrapPrivateComponent(WellCompositeEditor, properties) export default wrapPrivateComponent(WellCompositeEditor, { requirements: ['OperationStat.get', 'WellComposite.get'] })

View File

@ -0,0 +1,23 @@
import { memo, useMemo } from 'react'
import { Outlet } from 'react-router-dom'
import { RootPathContext, useRootPath } from '@asb/context'
import { wrapPrivateComponent } from '@utils'
const Analytics = memo(() => {
const root = useRootPath()
const rootPath = useMemo(() => `${root}/analytics`, [root])
return (
<RootPathContext.Provider value={rootPath}>
<Outlet />
</RootPathContext.Provider>
)
})
export default wrapPrivateComponent(Analytics, {
requirements: [],
title: 'Аналитика',
route: 'analytics/*',
key: 'analytics',
})

View File

@ -40,7 +40,7 @@ export const DocumentsTemplate = ({ idCategory, well: givenWell, mimeTypes, head
), ),
}, },
makeDateColumn('Дата загрузки', 'uploadDate'), makeDateColumn('Дата загрузки', 'uploadDate'),
makeNumericColumn('Размер', 'size', null, null, formatBytes), makeNumericColumn('Размер', 'size', null, null, (value) => formatBytes(value)),
makeColumn('Автор', 'author', { render: item => <UserView user={item}/> }), makeColumn('Автор', 'author', { render: item => <UserView user={item}/> }),
makeColumn('Компания', 'company', { render: (_, record) => <CompanyView company={record?.author?.company}/> }), makeColumn('Компания', 'company', { render: (_, record) => <CompanyView company={record?.author?.company}/> }),
...(customColumns ?? []) ...(customColumns ?? [])
@ -49,12 +49,8 @@ export const DocumentsTemplate = ({ idCategory, well: givenWell, mimeTypes, head
const filenames = useMemo(() => files.map(file => file.name).filter(Boolean).filter(unique), [files]) const filenames = useMemo(() => files.map(file => file.name).filter(Boolean).filter(unique), [files])
const update = useCallback(() => { const update = useCallback(() => {
let begin = null const begin = filterDataRange?.length > 1 ? filterDataRange[0].toISOString() : null
let end = null const end = filterDataRange?.length > 1 ? filterDataRange[1].toISOString() : null
if (filterDataRange?.length > 1) {
begin = filterDataRange[0].toISOString()
end = filterDataRange[1].toISOString()
}
invokeWebApiWrapperAsync( invokeWebApiWrapperAsync(
async () => { async () => {
@ -65,6 +61,7 @@ export const DocumentsTemplate = ({ idCategory, well: givenWell, mimeTypes, head
filterFileName, filterFileName,
begin, begin,
end, end,
false,
(page - 1) * pageSize, (page - 1) * pageSize,
pageSize, pageSize,
) )
@ -137,6 +134,7 @@ export const DocumentsTemplate = ({ idCategory, well: givenWell, mimeTypes, head
<div> <div>
<span>Загрузка</span> <span>Загрузка</span>
<UploadForm <UploadForm
multiple
url={uploadUrl} url={uploadUrl}
mimeTypes={mimeTypes} mimeTypes={mimeTypes}
onUploadStart={() => setShowLoader(true)} onUploadStart={() => setShowLoader(true)}
@ -160,6 +158,7 @@ export const DocumentsTemplate = ({ idCategory, well: givenWell, mimeTypes, head
onRowDelete={handleFileDelete} onRowDelete={handleFileDelete}
rowKey={(record) => record.id} rowKey={(record) => record.id}
tableName={tableName ?? `file_${idCategory}`} tableName={tableName ?? `file_${idCategory}`}
scroll={{ x: true }}
/> />
</LoaderPortal> </LoaderPortal>
) )

View File

@ -1,16 +1,11 @@
import { Navigate, Route, Routes } from 'react-router-dom' import { Navigate, Route, Routes } from 'react-router-dom'
import { memo, useMemo } from 'react' import { memo, useMemo } from 'react'
import { FolderOutlined } from '@ant-design/icons'
import { Layout } from 'antd'
import { RootPathContext, useRootPath } from '@asb/context' import { RootPathContext, useRootPath } from '@asb/context'
import { PrivateMenu } from '@components/Private' import { wrapPrivateComponent, NoAccessComponent, hasPermission } from '@utils'
import { getTabname, wrapPrivateComponent, NoAccessComponent, hasPermission } from '@utils'
import DocumentsTemplate from './DocumentsTemplate' import DocumentsTemplate from './DocumentsTemplate'
const { Content } = Layout
const makeDocCat = (id, key, title, permissions = ['File.get']) => ({ id, key, title, permissions }) const makeDocCat = (id, key, title, permissions = ['File.get']) => ({ id, key, title, permissions })
export const documentCategories = [ export const documentCategories = [
@ -27,7 +22,6 @@ export const documentCategories = [
] ]
const MenuDocuments = memo(() => { const MenuDocuments = memo(() => {
const category = getTabname()
const root = useRootPath() const root = useRootPath()
const rootPath = useMemo(() => `${root}/document`, [root]) const rootPath = useMemo(() => `${root}/document`, [root])
@ -35,34 +29,21 @@ const MenuDocuments = memo(() => {
return ( return (
<RootPathContext.Provider value={rootPath}> <RootPathContext.Provider value={rootPath}>
<PrivateMenu mode={'horizontal'} selectable={true} className={'well_menu'} selectedKeys={[category]}> <Routes>
{categories.map(category => ( {categories.length > 0 && (
<PrivateMenu.Link <Route index element={<Navigate to={categories[0].key} replace />} />
key={`${category.key}`} )}
icon={<FolderOutlined/>} <Route path={'*'} element={<NoAccessComponent />} />
title={category.title}
/>
))}
</PrivateMenu>
<Layout>
<Content className={'site-layout-background'}>
<Routes>
{categories.length > 0 && (
<Route index element={<Navigate to={categories[0].key} replace />} />
)}
<Route path={'*'} element={<NoAccessComponent />} />
{categories.map(category => ( {categories.map(category => (
<Route key={category.key} path={category.key} element={( <Route key={category.key} path={category.key} element={(
<DocumentsTemplate <DocumentsTemplate
idCategory={category.id} idCategory={category.id}
tableName={`documents_${category.key}`} tableName={`documents_${category.key}`}
/> />
)} /> )} />
))} ))}
</Routes> </Routes>
</Content>
</Layout>
</RootPathContext.Provider> </RootPathContext.Provider>
) )
}) })

View File

@ -159,7 +159,7 @@ export const CategoryEditor = memo(({ visible, category, onClosed }) => {
<Modal <Modal
centered centered
width={1000} width={1000}
visible={visible} open={visible}
footer={null} footer={null}
onCancel={onModalClosed} onCancel={onModalClosed}
title={`Редактирование пользователей категории ${title}`} title={`Редактирование пользователей категории ${title}`}

View File

@ -74,7 +74,7 @@ export const CategoryHistory = ({ idCategory, visible, onClose }) => {
const [begin, end] = range?.length > 1 ? [range[0].toISOString(), range[1].toISOString()] : [null, null] const [begin, end] = range?.length > 1 ? [range[0].toISOString(), range[1].toISOString()] : [null, null]
const skip = (page - 1) * pageSize const skip = (page - 1) * pageSize
const paginatedHistory = await FileService.getFilesInfo(well.caption, idCategory, companyName, fileName, begin, end, skip, pageSize) const paginatedHistory = await FileService.getFilesInfo(well.caption, idCategory, companyName, fileName, begin, end, false, skip, pageSize)
setTotal(paginatedHistory?.count ?? 0) setTotal(paginatedHistory?.count ?? 0)
setData(arrayOrDefault(paginatedHistory?.items)) setData(arrayOrDefault(paginatedHistory?.items))
}, },
@ -94,7 +94,7 @@ export const CategoryHistory = ({ idCategory, visible, onClose }) => {
title={'История категории'} title={'История категории'}
width={1200} width={1200}
centered centered
visible={!!visible} open={!!visible}
onCancel={onClose} onCancel={onClose}
footer={( footer={(
<Button onClick={onClose}>Закрыть</Button> <Button onClick={onClose}>Закрыть</Button>

View File

@ -123,6 +123,7 @@ export const CategoryRender = memo(({ partData, onUpdate, onEdit, onHistory, set
<div className={'file_actions'}> <div className={'file_actions'}>
{permissionToUpload && ( {permissionToUpload && (
<UploadForm <UploadForm
multiple
url={uploadUrl} url={uploadUrl}
mimeTypes={MimeTypes.XLSX} mimeTypes={MimeTypes.XLSX}
style={{ margin: '5px 0 10px 0' }} style={{ margin: '5px 0 10px 0' }}

View File

@ -35,7 +35,7 @@ export const InclinometryTable = memo(({ group, visible, onClose }) => {
<Modal <Modal
title={group?.title} title={group?.title}
centered centered
visible={visible} open={visible}
onCancel={onClose} onCancel={onClose}
width={1900} width={1900}
footer={null} footer={null}

View File

@ -21,7 +21,7 @@ import '@styles/index.css'
import '@styles/measure.css' import '@styles/measure.css'
const createEditingColumns = (cols, renderDelegate) => const createEditingColumns = (cols, renderDelegate) =>
cols.map(col => ({ render: renderDelegate, ...col })) cols.map(col => col.map(col => ({ render: renderDelegate, ...col })))
const disabled = !hasPermission('Measure.edit') const disabled = !hasPermission('Measure.edit')
@ -134,7 +134,7 @@ export const MeasureTable = memo(({ group, updateMeasuresFunc, additionalButtons
</div> </div>
</div> </div>
<div className={'measure-dates mt-20px'}> <div className={'measure-dates mt-20px p-10'}>
<Timeline className={'mt-12px ml-10px'}> <Timeline className={'mt-12px ml-10px'}>
{data.map((item, index) => ( {data.map((item, index) => (
<Timeline.Item <Timeline.Item

View File

@ -0,0 +1,51 @@
import { memo, Fragment } from 'react'
import { Empty, Form } from 'antd'
import { Grid, GridItem } from '@components/Grid'
import '@styles/index.css'
import '@styles/measure.css'
export const View = memo(({ columns, item }) => !item || !columns?.length ? (
<Empty key={'empty'} image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<Grid>
{columns.map((cols, i) => {
const columnPosition = 1 + i * 2
return cols.map((column, j) => (
<Fragment key={column.key}>
<GridItem
key={column.dataIndex}
row={j + 1}
col={columnPosition}
className={'measure-column-header'}
>
{column.title}
</GridItem>
<GridItem
key={column.title}
row={j + 1}
col={columnPosition + 1}
className={'measure-column-value'}
style={{ padding: 0 }}
>
{column.render ? (
<Form.Item
key={column.dataIndex}
name={column.dataIndex}
style={{ margin: 0 }}
>
{column.render(item[column.dataIndex])}
</Form.Item>
) : (
<p key={column.title} className={'m-5px'}>
{item[column.dataIndex]}
</p>
)}
</GridItem>
</Fragment>
))
}).flat()}
</Grid>
))

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