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
/coverage
# production
# build directories
/build
/dev_build
/prod_build
# misc
.DS_Store

View File

@ -38,10 +38,10 @@ npx openapi -i http://{IP_ADDRESS}:{PORT}/swagger/v1/swagger.json -o src/service
| IP-адрес | Описание |
|:-|:-|
| 127.0.0.1:5000 | Локальный адрес вашей машины (привязан к `update_openapi`) |
| 192.168.1.70:5000 | Локальный адрес development-сервера (привязан к `update_openapi_server`) |
| 46.146.209.148:89 | Внешний адрес development-сервера |
| 46.146.209.148 | Внешний адрес production-сервера |
| 127.0.0.1:5000 | Локальный адрес вашей машины (привязан к `update_openapi`) |
| 192.168.1.113:5000 | Локальный адрес development-сервера (привязан к `update_openapi_server`) |
| 46.146.209.148:89 | Внешний адрес development-сервера |
| cloud.digitaldrilling.ru | Внешний адрес 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"
},
"scripts": {
"start": "webpack-dev-server --mode=development --open --hot",
"build": "webpack --mode=production",
"test": "jest",
"build": "webpack --env=\"ENV=prod\"",
"dev_build": "webpack --env=\"ENV=dev\"",
"prod_build": "webpack --env=\"ENV=prod\"",
"start": "webpack-dev-server --env=\"ENV=dev\" --open --hot",
"prod": "webpack-dev-server --env=\"ENV=prod\" --open --hot",
"dev": "webpack-dev-server --env=\"ENV=dev\" --open --hot",
"oul": "npx openapi -i http://127.0.0.1:5000/swagger/v1/swagger.json -o src/services/api",
"oud": "npx openapi -i http://192.168.1.70:5000/swagger/v1/swagger.json -o src/services/api",
"oug": "npx openapi -i http://46.146.209.148/swagger/v1/swagger.json -o src/services/api",
"oud": "npx openapi -i http://192.168.1.113:5000/swagger/v1/swagger.json -o src/services/api",
"oug": "npx openapi -i https://cloud.digitaldrilling.ru/swagger/v1/swagger.json -o src/services/api",
"oug_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",
@ -83,24 +89,30 @@
"@types/react-router-dom": "^5.3.3",
"babel-jest": "^28.1.0",
"babel-loader": "^8.2.5",
"colors": "^1.4.0",
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^4.2.0",
"extract-loader": "^5.1.0",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"interpolate-html-plugin": "^4.0.0",
"jest": "^28.1.0",
"less": "^4.1.3",
"less-loader": "^11.0.0",
"mini-css-extract-plugin": "^2.6.1",
"openapi-typescript": "^5.4.0",
"openapi-typescript-codegen": "^0.23.0",
"path-browserify": "^1.0.1",
"react-test-renderer": "^18.1.0",
"source-map-loader": "^3.0.1",
"style-loader": "^3.3.1",
"terser-webpack-plugin": "^5.3.6",
"ts-loader": "^9.3.0",
"typescript": "^4.7.4",
"url-loader": "^4.1.1",
"webpack": "^5.73.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.9.1"
"webpack-dev-server": "^4.9.1",
"webpack-merge": "^5.8.0"
}
}

View File

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

View File

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

View File

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

View File

@ -106,7 +106,7 @@ export const ColorPicker = memo<ColorPickerProps>(({ value = '#AA33BB', onChange
return (
<Popover
trigger={'click'}
onVisibleChange={onClose}
onOpenChange={onClose}
content={(
<div className={'asb-color-picker-content'}>
<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 { Layout } from 'antd'
import { Link, useLocation } from 'react-router-dom'
import { Link } from 'react-router-dom'
import { BasicProps } from 'antd/lib/layout/layout'
import { headerHeight } from '@utils'
import { UserMenu } from './UserMenu'
import Logo from '@images/Logo'
import '@styles/layout.less'
export type PageHeaderProps = BasicProps & {
title?: string
isAdmin?: boolean
children?: React.ReactNode
}
export const PageHeader: React.FC<PageHeaderProps> = memo(({ title = 'Мониторинг', isAdmin, children, ...other }) => {
const location = useLocation()
return (
<Layout>
<Layout.Header className={'header'} {...other}>
<Link to={'/'} style={{ height: headerHeight }}>
<Logo />
</Link>
<h1 className={'title'}>{title}</h1>
{children}
<UserMenu isAdmin={isAdmin} />
</Layout.Header>
</Layout>
)
})
export const PageHeader: React.FC<PageHeaderProps> = memo(({ title, children, ...other }) => (
<Layout.Header className={'header'} {...other}>
<Link to={'/'} style={{ height: headerHeight }}>
<Logo />
</Link>
<h1 className={'title'}>{title}</h1>
{children}
</Layout.Header>
))
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,
makeNumericStartEnd,
makeNumericMinMax,
makeNumericAvgRange
} from './numeric'
export { makeColumnsPlanFact } from './plan_fact'
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 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
// react компонента редактора

View File

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

View File

@ -1,6 +1,7 @@
import { ColumnFilterItem } from 'antd/lib/table/interface'
import { ReactNode } from 'react'
import { columnPropsOther, DataType, RenderMethod, SorterMethod } from '.'
import makeColumn, { columnPropsOther, DataType, RenderMethod, SorterMethod } from '.'
import { makeStringSorter } from '../sorters'
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>(
title: ReactNode,
dataIndex: string,
filters: object[],
key: string,
filters?: ColumnFilterItem[],
sorter?: SorterMethod<T>,
render?: RenderMethod<T>,
other?: columnPropsOther
) => ({
title: title,
dataIndex: dataIndex,
key: dataIndex,
filters: filters,
onFilter: filters ? makeFilterTextMatch(dataIndex) : null,
sorter: sorter ?? makeStringSorter(dataIndex),
) => makeColumn(title, key, {
filters,
onFilter: filters ? makeFilterTextMatch(key) : undefined,
sorter: sorter ?? makeStringSorter(key),
render: render,
...other
})

View File

@ -11,6 +11,7 @@ const { RangePicker } = DatePicker
export type DateRangeWrapperProps = RangePickerSharedProps<Moment> & {
value?: RangeValue<Moment>,
isUTC?: boolean
allowClear?: boolean
}
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
showTime
allowClear={false}
allowClear={allowClear}
format={defaultFormat}
defaultValue={[
moment().subtract(1, 'days').startOf('day'),

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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 { getChartIcon, isDev, usePartialProps } from '@utils'
import { BaseDataType } from '../types'
import { ChartGroup, ChartSizes } from './D3MonitoringCharts'
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
height?: number
render?: D3GroupRenderFunction<DataType>
@ -23,11 +24,12 @@ export type D3HorizontalCursorSettings<DataType> = {
lineStyle?: SVGProps<SVGLineElement>
}
export type D3HorizontalCursorProps<DataType> = D3HorizontalCursorSettings<DataType> & {
export type D3HorizontalCursorProps<DataType extends BaseDataType> = D3HorizontalCursorSettings<DataType> & {
groups: ChartGroup<DataType>[]
data: DataType[]
sizes: ChartSizes
yAxis?: d3.ScaleTime<number, number>
spaceBetweenGroups?: number
}
const defaultLineStyle: SVGProps<SVGLineElement> = {
@ -36,7 +38,7 @@ const defaultLineStyle: SVGProps<SVGLineElement> = {
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) => {
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,>({
width = 220,
const _D3HorizontalCursor = <DataType extends BaseDataType>({
spaceBetweenGroups = 30,
height = 200,
render = makeDefaultRender<DataType>(),
position: _position = 'bottom',
@ -139,7 +141,7 @@ const _D3HorizontalCursor = <DataType,>({
if (!mouseState.visible || fixed) return
let top = mouseState.y + offsetY
if (top + height >= sizes.chartsHeight) {
if (mouseState.y >= sizes.chartsHeight / 2) {
setPosition('bottom')
top = mouseState.y - offsetY - height
} else {
@ -178,17 +180,21 @@ const _D3HorizontalCursor = <DataType,>({
{groups.map((_, i) => (
<foreignObject
key={`${i}`}
width={width}
width={sizes.groupWidth + spaceBetweenGroups}
height={height}
x={sizes.groupLeft(i) + (sizes.groupWidth - width) / 2}
x={sizes.groupLeft(i) - spaceBetweenGroups / 2}
y={tooltipY}
opacity={fixed || mouseState.visible ? 1 : 0}
pointerEvents={fixed ? 'all' : 'none'}
style={{ transition: 'opacity .1s ease-out', userSelect: fixed ? 'auto' : 'none' }}
>
<div className={`tooltip ${position} ${className}`}>
<div className={'tooltip-content'}>
{tooltipBodies[i]}
<div className={'tooltip-wrapper'}>
<div className={`adaptive-tooltip tooltip ${position} ${className}`}
style={{height: 'auto', bottom: `${position === 'bottom' ? '0' : ''}`}}
>
<div className={'tooltip-content'}>
{tooltipBodies[i]}
</div>
</div>
</div>
</foreignObject>

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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'
@ -12,7 +12,7 @@ const defaultConfig: Required<Omit<PointChartDataset, 'type'>> = {
fillOpacity: 1,
}
const getPointsRoot = <DataType,>(chart: ChartRegistry<DataType>, embeded?: boolean): d3.Selection<SVGGElement, any, any, any> => {
const getPointsRoot = <DataType extends BaseDataType>(chart: ChartRegistry<DataType>, embeded?: boolean): d3.Selection<SVGGElement, any, any, any> => {
const root = chart()
if (!embeded) return root
if (root.select('.points').empty())

View File

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

View File

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

View File

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

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'}
{...other}
visible={visible}
onVisibleChange={(visible) => setVisible(visible)}
open={visible}
onOpenChange={(visible) => setVisible(visible)}
>
<Button {...buttonProps}>{text}</Button>
</Popover>

View File

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

View File

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

View File

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

View File

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

View File

@ -20,7 +20,7 @@ export const WidgetSettingsWindow = memo<WidgetSettingsWindowProps>(({ settings,
return (
<Modal
{...other}
visible={!!settings}
open={!!settings}
title={(
<>
Настройка виджета {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 RootPathContext = createContext<string>('')
export const RootPathContext = createContext<string>('/')
/** Контекст текущего пользователя */
export const UserContext = createContext<UserTokenDto>({})
/** Контекст метода редактирования параметров заголовка и меню */
export const LayoutPropsContext = createContext<(props: LayoutPortalProps) => void>(() => {})
/**
* Получение текущей скважины
* Получить текущую скважину
*
* @returns Текущая скважина, либо `null`
*/
export const useWell = () => useContext(WellContext)
/**
* Получает текущий корневой путь
* Получить текущий корневой путь
*
* @returns Текущий корневой путь
*/
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'
import { invokeWebApiWrapperAsync } from '@components/factory'
import { AdminClusterService, AdminDepositService } from '@api'
import { arrayOrDefault, wrapPrivateComponent } from '@utils'
import { arrayOrDefault, coordsFormat, wrapPrivateComponent } from '@utils'
import { min1 } from '@utils/validationRules'
import { coordsFixed } from './DepositController'
const ClusterController = memo(() => {
const [deposits, setDeposits] = useState([])
const [clusters, setClusters] = useState([])
@ -41,8 +39,8 @@ const ClusterController = memo(() => {
sorter: makeStringSorter('caption'),
formItemRules: min1,
}),
makeColumn('Широта', 'latitude', { width: 150, editable: true, render: coordsFixed }),
makeColumn('Долгота', 'longitude', { width: 150, editable: true, render: coordsFixed }),
makeColumn('Широта', 'latitude', { width: 150, editable: true, render: coordsFormat }),
makeColumn('Долгота', 'longitude', { width: 150, editable: true, render: coordsFormat }),
makeTimezoneColumn('Зона', 'timezone', null, true, { width: 150 }),
], [deposits])
@ -108,6 +106,7 @@ const ClusterController = memo(() => {
onRowEdit={tableHandlers.edit}
onRowDelete={tableHandlers.delete}
tableName={'admin_cluster_controller'}
scroll={{ x: true }}
/>
</>
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,9 +21,7 @@ import {
import { invokeWebApiWrapperAsync } from '@components/factory'
import { TelemetryView, CompanyView } from '@components/views'
import TelemetrySelect from '@components/selectors/TelemetrySelect'
import { arrayOrDefault, wrapPrivateComponent } from '@utils'
import { coordsFixed } from '../DepositController'
import { arrayOrDefault, coordsFormat, wrapPrivateComponent } from '@utils'
const wellTypes = [
{ value: 1, label: 'Наклонно-направленная' },
@ -98,8 +96,8 @@ const WellController = memo(() => {
editable: true,
sorter: makeNumericSorter('idWellType'),
}),
makeColumn('Широта', 'latitude', { width: 150, editable: true, render: coordsFixed }),
makeColumn('Долгота', 'longitude', { width: 150, editable: true, render: coordsFixed }),
makeColumn('Широта', 'latitude', { width: 150, editable: true, render: coordsFormat }),
makeColumn('Долгота', 'longitude', { width: 150, editable: true, render: coordsFormat }),
makeColumn('Телеметрия', 'telemetry', {
editable: true,
render: (telemetry) => <TelemetryView telemetry={telemetry} />,

View File

@ -1,62 +1,57 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { memo, useMemo } from 'react'
import { Layout } from 'antd'
import { lazy, memo, useMemo } from 'react'
import { RootPathContext, useRootPath } from '@asb/context'
import { AdminLayoutPortal } from '@components/Layout'
import { PrivateMenu } from '@components/Private'
import { RootPathContext, useLayoutProps, useRootPath } from '@asb/context'
import { NoAccessComponent, wrapPrivateComponent } from '@utils'
import ClusterController from './ClusterController'
import CompanyController from './CompanyController'
import DepositController from './DepositController'
import UserController from './UserController'
import WellController from './WellController'
import RoleController from './RoleController'
import CompanyTypeController from './CompanyTypeController'
import PermissionController from './PermissionController'
import Telemetry from './Telemetry'
import VisitLog from './VisitLog'
import AdminNavigationMenu from './AdminNavigationMenu'
const ClusterController = lazy(() => import('./ClusterController'))
const CompanyController = lazy(() => import('./CompanyController'))
const DepositController = lazy(() => import('./DepositController'))
const UserController = lazy(() => import('./UserController'))
const WellController = lazy(() => import('./WellController'))
const RoleController = lazy(() => import('./RoleController'))
const CompanyTypeController = lazy(() => import('./CompanyTypeController'))
const PermissionController = lazy(() => import('./PermissionController'))
const 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 root = useRootPath()
const rootPath = useMemo(() => `${root}/admin`, [root])
useLayoutProps(layoutProps)
return (
<RootPathContext.Provider value={rootPath}>
<AdminLayoutPortal title={'Администраторская панель'}>
<PrivateMenu>
<PrivateMenu.Link content={DepositController} />
<PrivateMenu.Link content={ClusterController} />
<PrivateMenu.Link content={WellController} />
<PrivateMenu.Link content={UserController} />
<PrivateMenu.Link content={CompanyController} />
<PrivateMenu.Link content={CompanyTypeController} />
<PrivateMenu.Link content={RoleController} />
<PrivateMenu.Link content={PermissionController} />
<PrivateMenu.Link content={Telemetry} />
<PrivateMenu.Link content={VisitLog} />
</PrivateMenu>
<Layout>
<Layout.Content className={'site-layout-background'}>
<Routes>
<Route index element={<Navigate to={VisitLog.route} replace />} />
<Route path={'*'} element={<NoAccessComponent />} />
<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>
<Routes>
<Route index element={<Navigate to={'visit_log'} replace />} />
<Route path={'*'} element={<NoAccessComponent />} />
<Route path={'deposit'} element={<DepositController />} />
<Route path={'cluster'} element={<ClusterController />} />
<Route path={'well'} element={<WellController />} />
<Route path={'user'} element={<UserController />} />
<Route path={'company'} element={<CompanyController />} />
<Route path={'company_type'} element={<CompanyTypeController />} />
<Route path={'role'} element={<RoleController />} />
<Route path={'permission'} element={<PermissionController />} />
<Route path={'telemetry'} element={<Telemetry />}>
<Route index element={<Navigate to={'viewer'} replace />} />
<Route path={'*'} element={<NoAccessComponent />} />
<Route path={'viewer'} element={<TelemetryViewer />} />
<Route path={'merger'} element={<TelemetryMerger />} />
</Route>
<Route path={'visit_log'} element={<VisitLog />} />
</Routes>
</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 { useState, useEffect, memo, useMemo } from 'react'
import { Tag, Button, Modal } from 'antd'
import { useState, useEffect, memo, useMemo, lazy, Suspense } from 'react'
import { Button, Modal } from 'antd'
import { LineChartOutlined, ProfileOutlined, TeamOutlined } from '@ant-design/icons'
import {
@ -13,9 +13,9 @@ import {
makeNumericRender,
makeNumericColumn,
} from '@components/Table'
import { CompanyView } from '@components/views'
import LoaderPortal from '@components/LoaderPortal'
import PointerIcon from '@components/icons/PointerIcon'
import SuspenseFallback from '@components/SuspenseFallback'
import { invokeWebApiWrapperAsync } from '@components/factory'
import {
getOperations,
@ -25,9 +25,10 @@ import {
wrapPrivateComponent
} 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 CompaniesTable from '@pages/Cluster/CompaniesTable'
const filtersMinMax = [
{ text: 'min', value: 'min' },
@ -173,23 +174,26 @@ const ClusterWells = memo(({ statsWells }) => {
pagination={false}
rowKey={(record) => record.caption}
tableName={'cluster'}
scroll={{ x: true }}
/>
<Modal
title={'TVD'}
centered
visible={isTVDModalVisible}
open={isTVDModalVisible}
onCancel={() => setIsTVDModalVisible(false)}
width={1500}
footer={null}
>
<Tvd style={{ minHeight: '600px' }} well={selectedWell} />
<Suspense fallback={<SuspenseFallback style={{ minHeight: '100%' }} />}>
<Tvd style={{ minHeight: '600px' }} well={selectedWell} />
</Suspense>
</Modal>
<Modal
title={'Операции'}
centered
visible={isOpsModalVisible}
open={isOpsModalVisible}
onCancel={() => setIsOpsModalVisible(false)}
width={1500}
footer={null}
@ -202,7 +206,7 @@ const ClusterWells = memo(({ statsWells }) => {
<Modal
title={'Участники'}
centered
visible={isCompaniesModalVisible}
open={isCompaniesModalVisible}
onCancel={() => setIsCompaniesModalVisible(false)}
width={1500}
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 { makeTextColumn, Table } from '@components/Table'
import { makeColumn, makeTextColumn, Table } from '@components/Table'
const columns = [
makeTextColumn('', 'logo'),
makeColumn('', 'logo'),
makeTextColumn('Название компании', 'caption'),
makeTextColumn('Тип компании', 'companyTypeCaption'),
]
const CompaniesTable = memo(({companies}) => {
const CompaniesTable = memo(({ companies }) => {
const dataCompanies = useMemo(() => companies?.map((company) => ({
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,
companyTypeCaption: company.companyTypeCaption,
})), [companies])

View File

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

View File

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

View File

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

View File

@ -7,8 +7,6 @@ import { downloadFile, invokeWebApiWrapperAsync } from '@components/factory'
import { wrapPrivateComponent } from '@utils'
import { FileService, WellService } from '@api'
import AccessDenied from './AccessDenied'
const { Paragraph, Text } = Typography
export const getLinkToFile = (fileInfo) => `/file_download/${fileInfo.idWell}/${fileInfo.id}`
@ -104,4 +102,4 @@ FileDownload.displayName = 'FileDownloadMemo'
export default wrapPrivateComponent(FileDownload, {
requirements: ['File.get'],
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 { useWell } from '@asb/context'
import { Table } from '@components/Table'
import { makeColumn, makeGroupColumn, makeNumericRender, makeSelectColumn, Table } from '@components/Table'
import LoaderPortal from '@components/LoaderPortal'
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 }) => {
const [params, setParams] = useState([])
@ -54,7 +117,7 @@ export const NewParamsTable = memo(({ selectedWellsKeys }) => {
<Modal
title={'Заполнить режимы текущей скважины'}
centered
visible={isParamsModalVisible}
open={isParamsModalVisible}
onCancel={() => setIsParamsModalVisible(false)}
width={1700}
footer={(

View File

@ -1,7 +1,7 @@
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 { 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 LoaderPortal from '@components/LoaderPortal'
@ -16,10 +16,12 @@ import {
getOperations
} from '@utils'
import Tvd from '@pages/WellOperations/Tvd'
import WellOperationsTable from '@pages/Cluster/WellOperationsTable'
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 = [
{ text: 'min', value: 'min' },
@ -222,38 +224,44 @@ const WellCompositeSections = memo(({ statsWells, selectedSections }) => {
<Modal
title={'TVD'}
centered
visible={isTVDModalVisible}
open={isTVDModalVisible}
onCancel={() => setIsTVDModalVisible(false)}
width={1500}
footer={null}
>
<Tvd well={selectedWell} style={{ height: '80vh' }} />
<Suspense fallback={<SuspenseFallback style={{ minHeight: '100%' }} />}>
<Tvd well={selectedWell} style={{ height: '80vh' }} />
</Suspense>
</Modal>
<Modal
title={'Операции'}
centered
visible={isOpsModalVisible}
open={isOpsModalVisible}
onCancel={() => setIsOpsModalVisible(false)}
width={1500}
footer={null}
>
<LoaderPortal show={showLoader}>
<WellOperationsTable wellOperations={wellOperations} />
</LoaderPortal>
<Suspense fallback={<SuspenseFallback style={{ minHeight: '100%' }} />}>
<LoaderPortal show={showLoader}>
<WellOperationsTable wellOperations={wellOperations} />
</LoaderPortal>
</Suspense>
</Modal>
<Modal
title={'Участники'}
centered
visible={isCompaniesModalVisible}
open={isCompaniesModalVisible}
onCancel={() => setIsCompaniesModalVisible(false)}
width={1500}
footer={null}
>
<LoaderPortal show={showLoader}>
<CompaniesTable companies={companies} />
</LoaderPortal>
<Suspense fallback={<SuspenseFallback style={{ minHeight: '100%' }} />}>
<LoaderPortal show={showLoader}>
<CompaniesTable companies={companies} />
</LoaderPortal>
</Suspense>
</Modal>
</>
)

View File

@ -1,35 +1,26 @@
import { useState, useEffect, memo, useMemo } from 'react'
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 { PrivateMenu } from '@components/Private'
import { useWell } from '@asb/context'
import LoaderPortal from '@components/LoaderPortal'
import SuspenseFallback from '@components/SuspenseFallback'
import WellSelector from '@components/selectors/WellSelector'
import { invokeWebApiWrapperAsync } from '@components/factory'
import { arrayOrDefault, NoAccessComponent, wrapPrivateComponent } from '@utils'
import { OperationStatService, WellCompositeService } from '@api'
import ClusterWells from '@pages/Cluster/ClusterWells'
import WellCompositeSections from './WellCompositeSections'
const { Content } = Layout
import '@styles/well_composite.less'
const properties = {
requirements: ['OperationStat.get', 'WellComposite.get'],
title: 'Композитная скважина',
route: 'composite/*',
key: 'composite',
}
const ClusterWells = lazy(() => import('@pages/Cluster/ClusterWells'))
const WellCompositeEditor = memo(() => {
const [well] = useWell()
const root = useRootPath()
const rootPath = useMemo(() => `${root}/${properties.key}`, [root])
const [statsWells, setStatsWells] = useState([])
const [showLoader, setShowLoader] = useState(false)
const [showTabLoader, setShowTabLoader] = useState(false)
const [selectedIdWells, setSelectedIdWells] = useState([])
const [selectedSections, setSelectedSections] = useState([])
@ -61,7 +52,7 @@ const WellCompositeEditor = memo(() => {
const stats = arrayOrDefault(await OperationStatService.getWellsStat(selectedIdWells))
setStatsWells(stats)
},
setShowTabLoader,
setShowLoader,
'Не удалось загрузить статистику по скважинам/секциям',
{ actionName: 'Получение статистики по скважинам/секциям' }
)
@ -69,32 +60,20 @@ const WellCompositeEditor = memo(() => {
return (
<LoaderPortal show={showLoader}>
<Row align={'middle'} justify={'space-between'} wrap={false} style={{ backgroundColor: 'white' }}>
<Col span={18}>
<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 align={'middle'} justify={'space-between'} wrap={false} style={{ backgroundColor: 'white', marginBottom: '15px' }}>
<WellSelector onChange={setSelectedIdWells} value={selectedIdWells} />
</Row>
<Layout>
<Content className={'site-layout-background'}>
<LoaderPortal show={showTabLoader}>
<Routes>
<Route index element={<Navigate to={ClusterWells.route} replace/>} />
<Route path={'*'} element={<NoAccessComponent />} />
<Suspense fallback={<SuspenseFallback style={{ minHeight: '100%' }} />}>
<Routes>
<Route index element={<Navigate to={'wells'} replace/>} />
<Route path={'*'} element={<NoAccessComponent />} />
<Route path={ClusterWells.route} element={<ClusterWells statsWells={statsWells} />} />
<Route path={WellCompositeSections.route} element={<WellCompositeSections statsWells={statsWells} selectedSections={selectedSections} />} />
</Routes>
</LoaderPortal>
</Content>
</Layout>
<Route path={'wells'} element={<ClusterWells statsWells={statsWells} />} />
<Route path={'sections'} element={<WellCompositeSections statsWells={statsWells} selectedSections={selectedSections} />} />
</Routes>
</Suspense>
</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'),
makeNumericColumn('Размер', 'size', null, null, formatBytes),
makeNumericColumn('Размер', 'size', null, null, (value) => formatBytes(value)),
makeColumn('Автор', 'author', { render: item => <UserView user={item}/> }),
makeColumn('Компания', 'company', { render: (_, record) => <CompanyView company={record?.author?.company}/> }),
...(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 update = useCallback(() => {
let begin = null
let end = null
if (filterDataRange?.length > 1) {
begin = filterDataRange[0].toISOString()
end = filterDataRange[1].toISOString()
}
const begin = filterDataRange?.length > 1 ? filterDataRange[0].toISOString() : null
const end = filterDataRange?.length > 1 ? filterDataRange[1].toISOString() : null
invokeWebApiWrapperAsync(
async () => {
@ -65,6 +61,7 @@ export const DocumentsTemplate = ({ idCategory, well: givenWell, mimeTypes, head
filterFileName,
begin,
end,
false,
(page - 1) * pageSize,
pageSize,
)
@ -137,6 +134,7 @@ export const DocumentsTemplate = ({ idCategory, well: givenWell, mimeTypes, head
<div>
<span>Загрузка</span>
<UploadForm
multiple
url={uploadUrl}
mimeTypes={mimeTypes}
onUploadStart={() => setShowLoader(true)}
@ -160,6 +158,7 @@ export const DocumentsTemplate = ({ idCategory, well: givenWell, mimeTypes, head
onRowDelete={handleFileDelete}
rowKey={(record) => record.id}
tableName={tableName ?? `file_${idCategory}`}
scroll={{ x: true }}
/>
</LoaderPortal>
)

View File

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

View File

@ -159,7 +159,7 @@ export const CategoryEditor = memo(({ visible, category, onClosed }) => {
<Modal
centered
width={1000}
visible={visible}
open={visible}
footer={null}
onCancel={onModalClosed}
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 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)
setData(arrayOrDefault(paginatedHistory?.items))
},
@ -94,7 +94,7 @@ export const CategoryHistory = ({ idCategory, visible, onClose }) => {
title={'История категории'}
width={1200}
centered
visible={!!visible}
open={!!visible}
onCancel={onClose}
footer={(
<Button onClick={onClose}>Закрыть</Button>

View File

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

View File

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

View File

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