forked from ddrilling/asb_cloud_front
Добавлена view для разрешений
К отображению телеметрии добавлено id Актуализирован редактор ролей Добавлена кнопка смены пароля Окно смены пароля вынесено в компонент
This commit is contained in:
parent
1b17ee2cfd
commit
e084727c72
63
src/components/ChangePassword.tsx
Normal file
63
src/components/ChangePassword.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { memo, useState } from 'react'
|
||||||
|
import { useForm } from 'antd/lib/form/Form'
|
||||||
|
import { Form, Input, Modal, FormProps } from 'antd'
|
||||||
|
|
||||||
|
import { AuthService } from '../services/api'
|
||||||
|
import { passwordRules } from '../utils/validationRules'
|
||||||
|
|
||||||
|
import LoaderPortal from './LoaderPortal'
|
||||||
|
import { invokeWebApiWrapperAsync } from './factory'
|
||||||
|
|
||||||
|
const formLayout: FormProps = { labelCol: { span: 11 }, wrapperCol: { span: 16 } }
|
||||||
|
|
||||||
|
export type ChangePasswordProps = {
|
||||||
|
userId?: number
|
||||||
|
visible?: boolean
|
||||||
|
onCancel?: () => void
|
||||||
|
onOk?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const ChangePassword = memo<ChangePasswordProps>(({ userId, visible, onCancel, onOk }) => {
|
||||||
|
const [showLoader, setShowLoader] = useState<boolean>(false)
|
||||||
|
const [password, setPassword] = useState<string>('')
|
||||||
|
|
||||||
|
const [form] = useForm()
|
||||||
|
|
||||||
|
const onModalCancel = () => {
|
||||||
|
form.resetFields()
|
||||||
|
onCancel?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFormFinish = () => invokeWebApiWrapperAsync(
|
||||||
|
async() => {
|
||||||
|
await AuthService.changePassword(userId ?? localStorage['userId'], `"${password}"`)
|
||||||
|
onOk?.()
|
||||||
|
},
|
||||||
|
setShowLoader,
|
||||||
|
`Не удалось сменить пароль пользователя ${localStorage['login']}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
centered
|
||||||
|
title={'Сменить пароль'}
|
||||||
|
visible={visible}
|
||||||
|
onCancel={onModalCancel}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
>
|
||||||
|
<LoaderPortal show={showLoader}>
|
||||||
|
<Form
|
||||||
|
{...formLayout}
|
||||||
|
form={form}
|
||||||
|
name={'change-password'}
|
||||||
|
onFinish={onFormFinish}
|
||||||
|
>
|
||||||
|
<Form.Item label={'Новый пароль'} name={'new-password'} rules={passwordRules}>
|
||||||
|
<Input.Password onChange={(e) => setPassword(e.target.value)} value={password} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</LoaderPortal>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
})
|
@ -49,6 +49,8 @@ export const EditableTable = ({
|
|||||||
onRowAdd, // Метод вызывается с новой добавленной записью. Если метод не определен, то кнопка добавления строки не показывается
|
onRowAdd, // Метод вызывается с новой добавленной записью. Если метод не определен, то кнопка добавления строки не показывается
|
||||||
onRowEdit, // Метод вызывается с новой отредактированной записью. Если метод не поределен, то кнопка редактирования строки не показывается
|
onRowEdit, // Метод вызывается с новой отредактированной записью. Если метод не поределен, то кнопка редактирования строки не показывается
|
||||||
onRowDelete, // Метод вызывается с удаленной записью. Если метод не поределен, то кнопка удаления строки не показывается
|
onRowDelete, // Метод вызывается с удаленной записью. Если метод не поределен, то кнопка удаления строки не показывается
|
||||||
|
additionalButtons,
|
||||||
|
buttonsWidth,
|
||||||
...otherTableProps
|
...otherTableProps
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
@ -144,7 +146,7 @@ export const EditableTable = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const operationColumn = {
|
const operationColumn = {
|
||||||
width: 82,
|
width: buttonsWidth ?? 82,
|
||||||
title: !!onRowAdd && (
|
title: !!onRowAdd && (
|
||||||
<Button
|
<Button
|
||||||
onClick={addNewRow}
|
onClick={addNewRow}
|
||||||
@ -157,6 +159,7 @@ export const EditableTable = ({
|
|||||||
<span>
|
<span>
|
||||||
<Button onClick={() => save(record)} icon={<SaveOutlined/>}/>
|
<Button onClick={() => save(record)} icon={<SaveOutlined/>}/>
|
||||||
<Button onClick={cancel} icon={<CloseCircleOutlined/>}/>
|
<Button onClick={cancel} icon={<CloseCircleOutlined/>}/>
|
||||||
|
{additionalButtons?.(record, editingKey)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span>
|
||||||
@ -172,6 +175,7 @@ export const EditableTable = ({
|
|||||||
<Button icon={<DeleteOutlined/>}/>
|
<Button icon={<DeleteOutlined/>}/>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
)}
|
)}
|
||||||
|
{additionalButtons?.(record, editingKey)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { ReactNode } from 'react'
|
import { memo, useEffect, useState, ReactNode } from 'react'
|
||||||
import { InputNumber, Select, Table as RawTable } from 'antd'
|
import { InputNumber, Select, Table as RawTable, Tag, SelectProps } from 'antd'
|
||||||
import { OptionsType } from 'rc-select/lib/interface'
|
import { OptionsType } from 'rc-select/lib/interface'
|
||||||
import { tryAddKeys } from './EditableTable'
|
import { tryAddKeys } from './EditableTable'
|
||||||
import { makeNumericSorter, makeStringSorter } from './sorters'
|
import { makeNumericSorter, makeStringSorter } from './sorters'
|
||||||
import { Rule } from 'rc-field-form/lib/interface'
|
import { Rule } from 'rc-field-form/lib/interface'
|
||||||
|
import { SelectValue } from 'antd/lib/select'
|
||||||
export { makeDateSorter, makeNumericSorter, makeStringSorter } from './sorters'
|
export { makeDateSorter, makeNumericSorter, makeStringSorter } from './sorters'
|
||||||
export { EditableTable, makeActionHandler } from './EditableTable'
|
export { EditableTable, makeActionHandler } from './EditableTable'
|
||||||
export { DatePickerWrapper } from './DatePickerWrapper'
|
export { DatePickerWrapper } from './DatePickerWrapper'
|
||||||
@ -205,13 +206,71 @@ export const makeSelectColumn = <T extends unknown = string>(
|
|||||||
dataIndex: string,
|
dataIndex: string,
|
||||||
options: OptionsType,
|
options: OptionsType,
|
||||||
defaultValue?: T,
|
defaultValue?: T,
|
||||||
other?: columnPropsOther
|
other?: columnPropsOther,
|
||||||
|
selectOther?: SelectProps<SelectValue>
|
||||||
) => makeColumn(title, dataIndex, {
|
) => makeColumn(title, dataIndex, {
|
||||||
input: <Select options={options}/>,
|
input: <Select options={options} {...selectOther}/>,
|
||||||
render: (value) => options?.find(option => option?.value === value)?.label ?? defaultValue ?? value ?? '--',
|
render: (value) => options?.find(option => option?.value === value)?.label ?? defaultValue ?? value ?? '--',
|
||||||
...other
|
...other
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const makeTagInput = <T extends Record<string, any>>(id_key: string, value_key: string) => memo<{
|
||||||
|
options: T[],
|
||||||
|
value?: T[],
|
||||||
|
onChange?: (values: T[]) => void
|
||||||
|
}>(({ options, value, onChange }) => {
|
||||||
|
const [selectOptions, setSelectOptions] = useState<OptionsType>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectOptions(options.map((elm) => ({
|
||||||
|
value: elm[id_key],
|
||||||
|
label: elm[value_key]
|
||||||
|
})))
|
||||||
|
}, [options])
|
||||||
|
|
||||||
|
const onSelectChange = (rawValues?: SelectValue) => {
|
||||||
|
let values: string[] = []
|
||||||
|
if (typeof rawValues === 'string')
|
||||||
|
values = rawValues.split(',')
|
||||||
|
else if (typeof rawValues === 'number')
|
||||||
|
values = [`${rawValues}`]
|
||||||
|
|
||||||
|
const objectValues: T[] = values.reduce((out: T[], id: string) => {
|
||||||
|
const res = options.find((option) => `${option[id_key]}` === id)
|
||||||
|
if (res) out.push(res)
|
||||||
|
return out
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
onChange?.(objectValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
mode={'tags'}
|
||||||
|
options={selectOptions}
|
||||||
|
value={value?.join(',')}
|
||||||
|
onChange={onSelectChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const makeTagColumn = <T extends Record<string, any>>(
|
||||||
|
title: string,
|
||||||
|
dataIndex: string,
|
||||||
|
options: T[],
|
||||||
|
value_key: string,
|
||||||
|
label_key: string,
|
||||||
|
other?: columnPropsOther
|
||||||
|
) => {
|
||||||
|
const InputComponent = makeTagInput<T>(value_key, label_key)
|
||||||
|
|
||||||
|
return makeColumn(title, dataIndex, {
|
||||||
|
...other,
|
||||||
|
render: (item?: T[]) => item?.map((elm: T) => <Tag key={elm[label_key]} color='blue'>{elm[label_key]}</Tag>) ?? '-',
|
||||||
|
input: <InputComponent options={options} />,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type PaginationContainer = {
|
type PaginationContainer = {
|
||||||
skip?: number
|
skip?: number
|
||||||
take?: number
|
take?: number
|
||||||
|
@ -1,52 +1,35 @@
|
|||||||
import { MouseEventHandler, useState } from 'react'
|
import { MouseEventHandler, useState } from 'react'
|
||||||
import { Link, useHistory } from 'react-router-dom'
|
import { Link, useHistory } from 'react-router-dom'
|
||||||
import { Button, Dropdown, Menu, Modal, Form, Input, FormProps } from 'antd'
|
import { Button, Dropdown, Menu } from 'antd'
|
||||||
import { useForm } from 'antd/lib/form/Form'
|
|
||||||
import { UserOutlined } from '@ant-design/icons'
|
import { UserOutlined } from '@ant-design/icons'
|
||||||
import { AuthService } from '../services/api'
|
|
||||||
import { passwordRules } from '../utils/validationRules'
|
|
||||||
import { invokeWebApiWrapperAsync } from './factory'
|
|
||||||
import { PrivateMenuItem } from './Private'
|
import { PrivateMenuItem } from './Private'
|
||||||
import LoaderPortal from './LoaderPortal'
|
import { ChangePassword } from './ChangePassword'
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem('login')
|
localStorage.removeItem('login')
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
}
|
}
|
||||||
|
|
||||||
const formLayout: FormProps = { labelCol: { span: 11 }, wrapperCol: { span: 16 } }
|
|
||||||
|
|
||||||
type UserMenuProps = {
|
type UserMenuProps = {
|
||||||
isAdmin?: boolean
|
isAdmin?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserMenu: React.FC<UserMenuProps> = ({ isAdmin }) => {
|
export const UserMenu: React.FC<UserMenuProps> = ({ isAdmin }) => {
|
||||||
const [form] = useForm()
|
|
||||||
const [isModalVisible, setIsModalVisible] = useState<boolean>(false)
|
const [isModalVisible, setIsModalVisible] = useState<boolean>(false)
|
||||||
const [showLoader, setShowLoader] = useState<boolean>(false)
|
|
||||||
const [password, setPassword] = useState<string>('')
|
|
||||||
|
|
||||||
const history = useHistory()
|
const history = useHistory()
|
||||||
|
|
||||||
const changePassword = () => invokeWebApiWrapperAsync(
|
|
||||||
async() => {
|
|
||||||
await AuthService.changePassword(localStorage['userId'], `"${password}"`)
|
|
||||||
history.push('/login')
|
|
||||||
},
|
|
||||||
setShowLoader,
|
|
||||||
`Не удалось сменить пароль пользователя ${localStorage['login']}`
|
|
||||||
)
|
|
||||||
|
|
||||||
const onFormCancel = () => {
|
|
||||||
form.resetFields()
|
|
||||||
setIsModalVisible(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onChangePasswordClick: MouseEventHandler = (e) => {
|
const onChangePasswordClick: MouseEventHandler = (e) => {
|
||||||
setIsModalVisible(true)
|
setIsModalVisible(true)
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onChangePasswordOk = () => {
|
||||||
|
setIsModalVisible(false)
|
||||||
|
history.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
@ -71,26 +54,11 @@ export const UserMenu: React.FC<UserMenuProps> = ({ isAdmin }) => {
|
|||||||
>
|
>
|
||||||
<Button icon={<UserOutlined/>}>{localStorage['login']}</Button>
|
<Button icon={<UserOutlined/>}>{localStorage['login']}</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<Modal
|
<ChangePassword
|
||||||
title={'Сменить пароль'}
|
|
||||||
centered
|
|
||||||
visible={isModalVisible}
|
visible={isModalVisible}
|
||||||
onCancel={onFormCancel}
|
onOk={onChangePasswordOk}
|
||||||
onOk={() => form.submit()}
|
onCancel={() => setIsModalVisible(false)}
|
||||||
>
|
/>
|
||||||
<LoaderPortal show={showLoader}>
|
|
||||||
<Form
|
|
||||||
{...formLayout}
|
|
||||||
form={form}
|
|
||||||
name={'change-password'}
|
|
||||||
onFinish={changePassword}
|
|
||||||
>
|
|
||||||
<Form.Item label={'Новый пароль'} name={'new-password'} rules={passwordRules}>
|
|
||||||
<Input.Password onChange={(e) => setPassword(e.target.value)} value={password} />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</LoaderPortal>
|
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
27
src/components/Views/PermissionView.tsx
Normal file
27
src/components/Views/PermissionView.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { Tooltip } from 'antd'
|
||||||
|
import { memo } from 'react'
|
||||||
|
import { PermissionDto } from '../../services/api'
|
||||||
|
import { Grid, GridItem } from '../Grid'
|
||||||
|
|
||||||
|
export type PermissionViewProps = {
|
||||||
|
info?: PermissionDto
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PermissionView = memo<PermissionViewProps>(({ info }) => info ? (
|
||||||
|
<Tooltip overlayInnerStyle={{ width: '400px' }} title={
|
||||||
|
<Grid>
|
||||||
|
<GridItem row={1} col={1}>Название:</GridItem>
|
||||||
|
<GridItem row={1} col={2}>{info.name}</GridItem>
|
||||||
|
|
||||||
|
<GridItem row={2} col={1}>Описание:</GridItem>
|
||||||
|
<GridItem row={2} col={2}>{info.description}</GridItem>
|
||||||
|
</Grid>
|
||||||
|
}>
|
||||||
|
{info.name}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Tooltip title={'нет данных'}>-</Tooltip>
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
export default PermissionView
|
@ -1,6 +1,6 @@
|
|||||||
import { memo } from 'react'
|
import { Fragment, memo } from 'react'
|
||||||
import { Tooltip } from 'antd'
|
import { Tooltip } from 'antd'
|
||||||
import { TelemetryInfoDto } from '../../services/api'
|
import { TelemetryDto, TelemetryInfoDto } from '../../services/api'
|
||||||
import { Grid, GridItem } from '../Grid'
|
import { Grid, GridItem } from '../Grid'
|
||||||
|
|
||||||
const lables: { [labelKey: string]: string } = {
|
const lables: { [labelKey: string]: string } = {
|
||||||
@ -17,25 +17,29 @@ const lables: { [labelKey: string]: string } = {
|
|||||||
spinPlcVersion: 'Версия Спин Мастер',
|
spinPlcVersion: 'Версия Спин Мастер',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getTelemetryLabel = (info?: TelemetryInfoDto) => info ? `${info.deposit} / ${info.cluster} / ${info.well}` : '---'
|
export const getTelemetryLabel = (telemetry?: TelemetryDto) =>
|
||||||
|
`${telemetry?.id ?? '-'} / ${telemetry?.info?.deposit ?? '-'} / ${telemetry?.info?.cluster ?? '-'} / ${telemetry?.info?.well ?? '-'}`
|
||||||
|
|
||||||
export type TelemetryViewProps = {
|
export type TelemetryViewProps = {
|
||||||
info?: TelemetryInfoDto
|
telemetry?: TelemetryDto
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TelemetryView = memo<TelemetryViewProps>(({ info }) => info ? (
|
export const TelemetryView = memo<TelemetryViewProps>(({ telemetry }) => telemetry?.info ? (
|
||||||
<Tooltip overlayInnerStyle={{ width: '400px' }} title={
|
<Tooltip
|
||||||
<Grid>
|
overlayInnerStyle={{ width: '400px' }}
|
||||||
{(Object.keys(info) as Array<keyof TelemetryInfoDto>).map((key, i) => (
|
title={
|
||||||
<>
|
<Grid>
|
||||||
<GridItem row={i+1} col={1}>{lables[key] ?? key}:</GridItem>
|
{(Object.keys(telemetry.info) as Array<keyof TelemetryInfoDto>).map((key, i) => (
|
||||||
<GridItem row={i+1} col={2}>{info[key]}</GridItem>
|
<Fragment key={i}>
|
||||||
</>
|
<GridItem row={i+1} col={1}>{lables[key] ?? key}:</GridItem>
|
||||||
))}
|
<GridItem row={i+1} col={2}>{telemetry.info?.[key]}</GridItem>
|
||||||
</Grid>
|
</Fragment>
|
||||||
}>
|
))}
|
||||||
{getTelemetryLabel(info)}
|
</Grid>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{getTelemetryLabel(telemetry)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<Tooltip title={'нет данных'}>-</Tooltip>
|
<Tooltip title={'нет данных'}>{getTelemetryLabel()}</Tooltip>
|
||||||
))
|
))
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
export type { CompanyViewProps } from './CompanyView'
|
export type { CompanyViewProps } from './CompanyView'
|
||||||
export type { MarkViewProps } from './MarkView'
|
export type { MarkViewProps } from './MarkView'
|
||||||
|
export type { PermissionViewProps } from './PermissionView'
|
||||||
export type { TelemetryViewProps } from './TelemetryView'
|
export type { TelemetryViewProps } from './TelemetryView'
|
||||||
export type { UserViewProps } from './UserView'
|
export type { UserViewProps } from './UserView'
|
||||||
|
|
||||||
export { CompanyView } from './CompanyView'
|
export { CompanyView } from './CompanyView'
|
||||||
export { MarkView } from './MarkView'
|
export { MarkView } from './MarkView'
|
||||||
export { TelemetryView } from './TelemetryView'
|
export { PermissionView } from './PermissionView'
|
||||||
|
export { TelemetryView, getTelemetryLabel } from './TelemetryView'
|
||||||
export { UserView } from './UserView'
|
export { UserView } from './UserView'
|
||||||
|
@ -1,101 +1,50 @@
|
|||||||
import { Button, Modal } from 'antd'
|
import { Select, Tag } from 'antd'
|
||||||
import { useEffect, useState } from 'react'
|
import { memo, useEffect, useState } from 'react'
|
||||||
import { AdminUserRoleService } from '../../services/api'
|
|
||||||
import LoaderPortal from '../../components/LoaderPortal'
|
import LoaderPortal from '../../components/LoaderPortal'
|
||||||
|
import { PermissionView } from '../../components/Views'
|
||||||
import { invokeWebApiWrapperAsync } from '../../components/factory'
|
import { invokeWebApiWrapperAsync } from '../../components/factory'
|
||||||
import { EditableTable, makeActionHandler, makeColumn, makeSelectColumn } from '../../components/Table'
|
import { EditableTable, makeActionHandler, makeColumn, makeSelectColumn } from '../../components/Table'
|
||||||
|
import { AdminPermissionService, AdminUserRoleService } from '../../services/api'
|
||||||
|
import { arrayOrDefault } from '../../utils'
|
||||||
|
|
||||||
export const toHexString = (num, size) => '0x' + ('0'.repeat(size) + num.toString(16).toUpperCase()).slice(-size)
|
const PermissionTag = memo(({ permissions, value, onChange }) => {
|
||||||
|
const [options, setOptions] = useState([])
|
||||||
|
|
||||||
const columns = [
|
useEffect(() => {
|
||||||
makeColumn('Имена прав', 'name', {
|
setOptions(permissions.map((elm) => ({ key: Date.now(), value: `${elm.id}`, label: elm.name })))
|
||||||
width: 400,
|
}, [permissions])
|
||||||
editable: true,
|
|
||||||
formItemRules: [{
|
|
||||||
required: true,
|
|
||||||
message: 'Пожалуйста, введите имя права'
|
|
||||||
}],
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
|
|
||||||
export const RolePermissions = ({ value, onChange }) => {
|
console.log({ permissions, value })
|
||||||
const [isModalVisible, setIsModalVisible] = useState(false)
|
|
||||||
const [list, setList] = useState([])
|
|
||||||
|
|
||||||
const save = () => {
|
const onSelectChange = (values) => {
|
||||||
const newValue = list.map((value) => value.name)
|
const arr = values.map((id) => permissions.find((elm) => `${elm.id}` === id))
|
||||||
if(!onChange(newValue))
|
onChange?.(arr)
|
||||||
setIsModalVisible(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const add = (permission) => {
|
const selectValue = value?.map((val) => `${val.id}`)
|
||||||
permission.key = Date.now()
|
|
||||||
setList((prevList) => {
|
|
||||||
if (!prevList) prevList = []
|
|
||||||
prevList.push(permission)
|
|
||||||
return prevList
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const edit = (permission) => {
|
|
||||||
if (!permission.key) return
|
|
||||||
const idx = list.findIndex(v => v.key === permission.key)
|
|
||||||
if (idx < 0) return
|
|
||||||
setList((prevList) => {
|
|
||||||
prevList[idx] = permission
|
|
||||||
return prevList
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const remove = (permission) => {
|
|
||||||
if (!permission.key) return
|
|
||||||
const idx = list.findIndex(v => v.key === permission.key)
|
|
||||||
if (idx < 0) return
|
|
||||||
setList((prevList) => {
|
|
||||||
prevList.splice(idx, 1)
|
|
||||||
return prevList
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Select
|
||||||
<Button type={'link'} onClick={() => setIsModalVisible(true)}>Редактировать</Button>
|
showSearch
|
||||||
<Modal
|
mode={'tags'}
|
||||||
title={'Права доступа'}
|
options={options}
|
||||||
centered
|
value={selectValue}
|
||||||
visible={isModalVisible}
|
onChange={onSelectChange}
|
||||||
width={750}
|
/>
|
||||||
onCancel={() => setIsModalVisible(false)}
|
|
||||||
onOk={save}
|
|
||||||
okText={'Сохранить'}
|
|
||||||
>
|
|
||||||
<EditableTable
|
|
||||||
size={'small'}
|
|
||||||
bordered
|
|
||||||
columns={columns}
|
|
||||||
dataSource={list}
|
|
||||||
onRowAdd={add}
|
|
||||||
onRowEdit={edit}
|
|
||||||
onRowDelete={remove}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
export const RoleController = () => {
|
export const RoleController = () => {
|
||||||
|
const [permissions, setPermissions] = useState([])
|
||||||
const [roles, setRoles] = useState([])
|
const [roles, setRoles] = useState([])
|
||||||
const [showLoader, setShowLoader] = useState(false)
|
const [showLoader, setShowLoader] = useState(false)
|
||||||
const [columns, setColumns] = useState([])
|
const [columns, setColumns] = useState([])
|
||||||
|
|
||||||
const updateTable = () => invokeWebApiWrapperAsync(
|
const loadRoles = async () => {
|
||||||
async () => {
|
const roles = await AdminUserRoleService.getAll()
|
||||||
const roles = await AdminUserRoleService.getAll()
|
setRoles(Array.isArray(roles) ? roles : [])
|
||||||
setRoles(roles)
|
}
|
||||||
},
|
|
||||||
setShowLoader,
|
|
||||||
`Не удалось загрузить список прав`
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const options = roles?.map((r) => ({ value: r.id, label: r.caption })) ?? []
|
const options = roles?.map((r) => ({ value: r.id, label: r.caption })) ?? []
|
||||||
@ -104,23 +53,39 @@ export const RoleController = () => {
|
|||||||
makeSelectColumn('Роль-родитель', 'idParent', options, options[0], {
|
makeSelectColumn('Роль-родитель', 'idParent', options, options[0], {
|
||||||
width: 200,
|
width: 200,
|
||||||
editable: true
|
editable: true
|
||||||
}),
|
}, { allowClear: true }),
|
||||||
makeColumn('Права доступа', 'permissions', {
|
makeColumn('Права доступа', 'permissions', {
|
||||||
width: 200,
|
width: 200,
|
||||||
editable: true,
|
editable: true,
|
||||||
input: <RolePermissions />,
|
input: <PermissionTag permissions={permissions} />,
|
||||||
render: (permissions) => permissions?.join(', ') ?? '',
|
render: (item) => item?.map((elm) => (
|
||||||
})
|
<Tag key={elm.name} color={'blue'}>
|
||||||
|
<PermissionView info={elm} />
|
||||||
|
</Tag>
|
||||||
|
)) ?? '-',
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
}, [roles])
|
}, [roles, permissions])
|
||||||
|
|
||||||
useEffect(updateTable, [])
|
useEffect(() => invokeWebApiWrapperAsync(
|
||||||
|
async () => {
|
||||||
|
const permissions = await AdminPermissionService.getAll()
|
||||||
|
setPermissions(arrayOrDefault(permissions))
|
||||||
|
await loadRoles()
|
||||||
|
},
|
||||||
|
setShowLoader,
|
||||||
|
`Не удалось загрузить список прав`
|
||||||
|
), [])
|
||||||
|
|
||||||
const handlerProps = {
|
const handlerProps = {
|
||||||
service: AdminUserRoleService,
|
service: AdminUserRoleService,
|
||||||
setLoader: setShowLoader,
|
setLoader: setShowLoader,
|
||||||
errorMsg: `Не удалось выполнить операцию`,
|
errorMsg: `Не удалось выполнить операцию`,
|
||||||
onComplete: updateTable
|
onComplete: async () => invokeWebApiWrapperAsync(
|
||||||
|
loadRoles,
|
||||||
|
setShowLoader,
|
||||||
|
`Не удалось загрузить список прав`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,66 +1,28 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { UserSwitchOutlined } from '@ant-design/icons'
|
||||||
import { invokeWebApiWrapperAsync } from '../../components/factory'
|
import { invokeWebApiWrapperAsync } from '../../components/factory'
|
||||||
import LoaderPortal from '../../components/LoaderPortal'
|
import LoaderPortal from '../../components/LoaderPortal'
|
||||||
import { EditableTable, makeColumn, makeSelectColumn, makeActionHandler, makeStringSorter, makeNumericSorter } from '../../components/Table'
|
import { EditableTable, makeColumn, makeSelectColumn, makeActionHandler, makeStringSorter, makeNumericSorter } from '../../components/Table'
|
||||||
import { AdminCompanyService, AdminUserService } from '../../services/api'
|
import { AdminCompanyService, AdminUserService } from '../../services/api'
|
||||||
import { createLoginRules, nameRules, phoneRules, emailRules } from '../../utils/validationRules'
|
import { createLoginRules, nameRules, phoneRules, emailRules } from '../../utils/validationRules'
|
||||||
import { arrayOrDefault } from '../../utils'
|
import { arrayOrDefault } from '../../utils'
|
||||||
|
import { Button } from 'antd'
|
||||||
|
import { ChangePassword } from '../../components/ChangePassword'
|
||||||
|
|
||||||
export default function UserController() {
|
export default function UserController() {
|
||||||
const [companies, setCompanies] = useState([])
|
|
||||||
const [users, setUsers] = useState([])
|
const [users, setUsers] = useState([])
|
||||||
const [showLoader, setShowLoader] = useState(false)
|
const [showLoader, setShowLoader] = useState(false)
|
||||||
|
const [columns, setColumns] = useState([])
|
||||||
|
const [selectedId, setSelectedId] = useState(null)
|
||||||
|
|
||||||
const userColumns = [
|
const additionalButtons = (record, editingKey) => (
|
||||||
makeColumn('Логин', 'login', {
|
<Button
|
||||||
editable: true,
|
icon={<UserSwitchOutlined />}
|
||||||
formItemRules: [
|
onClick={() => setSelectedId(record.id)}
|
||||||
{ required: true },
|
title={'Сменить пароль'}
|
||||||
...createLoginRules,
|
disabled={editingKey !== ''}
|
||||||
() => ({
|
/>
|
||||||
validator(_, value) {
|
)
|
||||||
if (!value || users.findIndex((user) => user.login === value) < 0)
|
|
||||||
return Promise.resolve()
|
|
||||||
return Promise.reject(new Error('Логин уже занят!'))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
],
|
|
||||||
sorter: makeStringSorter('login'),
|
|
||||||
}),
|
|
||||||
makeColumn('Фамилия', 'surname', {
|
|
||||||
editable: true,
|
|
||||||
formItemRules: [{ required: true }, ...nameRules],
|
|
||||||
sorter: makeStringSorter('surname'),
|
|
||||||
}),
|
|
||||||
makeColumn('Имя', 'name', {
|
|
||||||
editable: true,
|
|
||||||
formItemRules: nameRules,
|
|
||||||
sorter: makeStringSorter('name'),
|
|
||||||
}),
|
|
||||||
makeColumn('Отчество', 'patronymic', {
|
|
||||||
editable: true,
|
|
||||||
formItemRules: nameRules,
|
|
||||||
sorter: makeStringSorter('patronymic'),
|
|
||||||
}),
|
|
||||||
makeColumn('E-mail', 'email', {
|
|
||||||
editable: true,
|
|
||||||
formItemRules: [{ required: true }, ...emailRules],
|
|
||||||
sorter: makeStringSorter('email'),
|
|
||||||
}),
|
|
||||||
makeColumn('Номер телефона', 'phone', {
|
|
||||||
editable: true,
|
|
||||||
formItemRules: phoneRules,
|
|
||||||
sorter: makeStringSorter('phone'),
|
|
||||||
}),
|
|
||||||
makeColumn('Должность', 'position', {
|
|
||||||
editable: true,
|
|
||||||
sorter: makeStringSorter('position'),
|
|
||||||
}),
|
|
||||||
makeSelectColumn('Компания', 'idCompany', companies, '--', {
|
|
||||||
editable: true,
|
|
||||||
sorter: makeNumericSorter('idCompany'),
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
|
|
||||||
const updateTable = () => invokeWebApiWrapperAsync(
|
const updateTable = () => invokeWebApiWrapperAsync(
|
||||||
async() => {
|
async() => {
|
||||||
@ -75,14 +37,65 @@ export default function UserController() {
|
|||||||
async () => {
|
async () => {
|
||||||
let companies = arrayOrDefault(await AdminCompanyService.getAll())
|
let companies = arrayOrDefault(await AdminCompanyService.getAll())
|
||||||
companies = companies?.map((company) => ({ value: company.id, label: company.caption }))
|
companies = companies?.map((company) => ({ value: company.id, label: company.caption }))
|
||||||
setCompanies(companies)
|
|
||||||
|
const users = arrayOrDefault(await AdminUserService.getAll())
|
||||||
|
setUsers(users)
|
||||||
|
|
||||||
|
setColumns([
|
||||||
|
makeColumn('Логин', 'login', {
|
||||||
|
editable: true,
|
||||||
|
formItemRules: [
|
||||||
|
{ required: true },
|
||||||
|
...createLoginRules,
|
||||||
|
() => ({
|
||||||
|
validator(_, value) {
|
||||||
|
if (!value || users.findIndex((user) => user.login === value) < 0)
|
||||||
|
return Promise.resolve()
|
||||||
|
return Promise.reject(new Error('Логин уже занят!'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
sorter: makeStringSorter('login'),
|
||||||
|
}),
|
||||||
|
makeColumn('Фамилия', 'surname', {
|
||||||
|
editable: true,
|
||||||
|
formItemRules: [{ required: true }, ...nameRules],
|
||||||
|
sorter: makeStringSorter('surname'),
|
||||||
|
}),
|
||||||
|
makeColumn('Имя', 'name', {
|
||||||
|
editable: true,
|
||||||
|
formItemRules: nameRules,
|
||||||
|
sorter: makeStringSorter('name'),
|
||||||
|
}),
|
||||||
|
makeColumn('Отчество', 'patronymic', {
|
||||||
|
editable: true,
|
||||||
|
formItemRules: nameRules,
|
||||||
|
sorter: makeStringSorter('patronymic'),
|
||||||
|
}),
|
||||||
|
makeColumn('E-mail', 'email', {
|
||||||
|
editable: true,
|
||||||
|
formItemRules: [{ required: true }, ...emailRules],
|
||||||
|
sorter: makeStringSorter('email'),
|
||||||
|
}),
|
||||||
|
makeColumn('Номер телефона', 'phone', {
|
||||||
|
editable: true,
|
||||||
|
formItemRules: phoneRules,
|
||||||
|
sorter: makeStringSorter('phone'),
|
||||||
|
}),
|
||||||
|
makeColumn('Должность', 'position', {
|
||||||
|
editable: true,
|
||||||
|
sorter: makeStringSorter('position'),
|
||||||
|
}),
|
||||||
|
makeSelectColumn('Компания', 'idCompany', companies, '--', {
|
||||||
|
editable: true,
|
||||||
|
sorter: makeNumericSorter('idCompany'),
|
||||||
|
}),
|
||||||
|
])
|
||||||
},
|
},
|
||||||
setShowLoader,
|
setShowLoader,
|
||||||
`Не удалось загрузить список компаний`
|
`Не удалось загрузить список компаний`
|
||||||
), [])
|
), [])
|
||||||
|
|
||||||
useEffect(updateTable, [])
|
|
||||||
|
|
||||||
const handlerProps = {
|
const handlerProps = {
|
||||||
service: AdminUserService,
|
service: AdminUserService,
|
||||||
setLoader: setShowLoader,
|
setLoader: setShowLoader,
|
||||||
@ -91,17 +104,27 @@ export default function UserController() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoaderPortal show={showLoader}>
|
<>
|
||||||
<EditableTable
|
<LoaderPortal show={showLoader}>
|
||||||
size={'small'}
|
<EditableTable
|
||||||
bordered
|
size={'small'}
|
||||||
columns={userColumns}
|
bordered
|
||||||
dataSource={users}
|
columns={columns}
|
||||||
onRowAdd={makeActionHandler('insert', handlerProps)}
|
dataSource={users}
|
||||||
onRowEdit={makeActionHandler('update', handlerProps)}
|
onRowAdd={makeActionHandler('insert', handlerProps)}
|
||||||
onRowDelete={makeActionHandler('delete', handlerProps)}
|
onRowEdit={makeActionHandler('update', handlerProps)}
|
||||||
pagination={{ defaultPageSize: 14 }}
|
onRowDelete={makeActionHandler('delete', handlerProps)}
|
||||||
|
additionalButtons={additionalButtons}
|
||||||
|
buttonsWidth={120}
|
||||||
|
pagination={{ defaultPageSize: 14 }}
|
||||||
|
/>
|
||||||
|
</LoaderPortal>
|
||||||
|
<ChangePassword
|
||||||
|
userId={selectedId}
|
||||||
|
visible={selectedId > 0}
|
||||||
|
onCancel={() => setSelectedId(null)}
|
||||||
|
onOk={() => setSelectedId(null)}
|
||||||
/>
|
/>
|
||||||
</LoaderPortal>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,22 +1,25 @@
|
|||||||
import { Select } from 'antd'
|
import { Select } from 'antd'
|
||||||
import { memo, useEffect, useState } from 'react'
|
import { memo, useEffect, useState } from 'react'
|
||||||
import { TelemetryView } from '../../components/Views'
|
|
||||||
import LoaderPortal from '../../components/LoaderPortal'
|
import LoaderPortal from '../../components/LoaderPortal'
|
||||||
import { invokeWebApiWrapperAsync } from '../../components/factory'
|
import { invokeWebApiWrapperAsync } from '../../components/factory'
|
||||||
import { getTelemetryLabel } from '../../components/Views/TelemetryView'
|
import { TelemetryView, getTelemetryLabel } from '../../components/Views'
|
||||||
import {
|
import {
|
||||||
EditableTable,
|
EditableTable,
|
||||||
makeColumn,
|
makeColumn,
|
||||||
makeSelectColumn,
|
makeSelectColumn,
|
||||||
makeActionHandler,
|
makeActionHandler,
|
||||||
makeStringSorter,
|
makeStringSorter,
|
||||||
makeNumericSorter
|
makeNumericSorter,
|
||||||
|
makeTagColumn
|
||||||
} from '../../components/Table'
|
} from '../../components/Table'
|
||||||
import {
|
import {
|
||||||
AdminClusterService,
|
AdminClusterService,
|
||||||
|
AdminCompanyService,
|
||||||
AdminTelemetryService,
|
AdminTelemetryService,
|
||||||
AdminWellService
|
AdminWellService
|
||||||
} from '../../services/api'
|
} from '../../services/api'
|
||||||
|
import { arrayOrDefault } from '../../utils'
|
||||||
|
|
||||||
const wellTypes = [
|
const wellTypes = [
|
||||||
{ value: 1, label: 'Наклонно-направленная' },
|
{ value: 1, label: 'Наклонно-направленная' },
|
||||||
@ -29,55 +32,27 @@ const TelemetrySelect = memo(({ telemetry, value, onChange }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const options = telemetry.map((row) => ({
|
const options = telemetry.map((row) => ({
|
||||||
value: row.id,
|
value: row.id,
|
||||||
label: getTelemetryLabel(row.info)
|
label: getTelemetryLabel(row)
|
||||||
}))
|
}))
|
||||||
setOptions(options)
|
setOptions(options)
|
||||||
}, [telemetry])
|
}, [telemetry])
|
||||||
|
|
||||||
const onSelectChange = (id) => {
|
const onSelectChange = (id) => {
|
||||||
const value = telemetry.find((row) => row.id === id)
|
onChange?.(telemetry.find((row) => row.id === id))
|
||||||
onChange?.(value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Select options={options} value={value?.id} onChange={onSelectChange}/>
|
return <Select options={options} value={value?.id} onChange={onSelectChange}/>
|
||||||
})
|
})
|
||||||
|
|
||||||
export default function WellController() {
|
export default function WellController() {
|
||||||
const [clusters, setClusters] = useState([])
|
const [columns, setColumns] = useState([])
|
||||||
const [wells, setWells] = useState([])
|
const [wells, setWells] = useState([])
|
||||||
const [showLoader, setShowLoader] = useState(false)
|
const [showLoader, setShowLoader] = useState(false)
|
||||||
const [telemetry, setTelemetry] = useState([])
|
|
||||||
|
|
||||||
const wellColumns = [
|
const updateTable = async () => invokeWebApiWrapperAsync(
|
||||||
makeSelectColumn('Куст', 'idCluster', clusters, '--', {
|
|
||||||
width: 200,
|
|
||||||
editable: true,
|
|
||||||
sorter: makeNumericSorter('idCluster'),
|
|
||||||
}),
|
|
||||||
makeColumn('Название', 'caption', {
|
|
||||||
width: 200,
|
|
||||||
editable: true,
|
|
||||||
sorter: makeStringSorter('caption'),
|
|
||||||
}),
|
|
||||||
makeSelectColumn('Тип', 'idWellType', wellTypes, '--', {
|
|
||||||
width: 150,
|
|
||||||
editable: true,
|
|
||||||
sorter: makeNumericSorter('idWellType'),
|
|
||||||
}),
|
|
||||||
makeColumn('Широта', 'latitude', { width: 150, editable: true }),
|
|
||||||
makeColumn('Долгота', 'longitude', { width: 150, editable: true }),
|
|
||||||
makeColumn('Телеметрия', 'telemetry', {
|
|
||||||
width: 150,
|
|
||||||
editable: true,
|
|
||||||
render: (telemetry) => <TelemetryView info={telemetry?.info} />,
|
|
||||||
input: <TelemetrySelect telemetry={telemetry}/>,
|
|
||||||
})
|
|
||||||
]
|
|
||||||
|
|
||||||
const updateTable = () => invokeWebApiWrapperAsync(
|
|
||||||
async () => {
|
async () => {
|
||||||
const wells = await AdminWellService.getAll()
|
const wells = await AdminWellService.getAll()
|
||||||
setWells(wells)
|
setWells(arrayOrDefault(wells))
|
||||||
},
|
},
|
||||||
setShowLoader,
|
setShowLoader,
|
||||||
`Не удалось загрузить список скважин`
|
`Не удалось загрузить список скважин`
|
||||||
@ -85,13 +60,41 @@ export default function WellController() {
|
|||||||
|
|
||||||
useEffect(() => invokeWebApiWrapperAsync(
|
useEffect(() => invokeWebApiWrapperAsync(
|
||||||
async () => {
|
async () => {
|
||||||
const telemetry = await AdminTelemetryService.getAll()
|
const companies = arrayOrDefault(await AdminCompanyService.getAll())
|
||||||
setTelemetry(telemetry)
|
const telemetry = arrayOrDefault(await AdminTelemetryService.getAll())
|
||||||
|
let clusters = arrayOrDefault(await AdminClusterService.getAll())
|
||||||
|
clusters = clusters.map((cluster) => ({ value: cluster.id, label: cluster.caption }))
|
||||||
|
|
||||||
|
setColumns([
|
||||||
|
makeSelectColumn('Куст', 'idCluster', clusters, '--', {
|
||||||
|
width: 200,
|
||||||
|
editable: true,
|
||||||
|
sorter: makeNumericSorter('idCluster'),
|
||||||
|
}),
|
||||||
|
makeColumn('Название', 'caption', {
|
||||||
|
width: 200,
|
||||||
|
editable: true,
|
||||||
|
sorter: makeStringSorter('caption'),
|
||||||
|
}),
|
||||||
|
makeSelectColumn('Тип', 'idWellType', wellTypes, '--', {
|
||||||
|
width: 150,
|
||||||
|
editable: true,
|
||||||
|
sorter: makeNumericSorter('idWellType'),
|
||||||
|
}),
|
||||||
|
makeColumn('Широта', 'latitude', { width: 150, editable: true }),
|
||||||
|
makeColumn('Долгота', 'longitude', { width: 150, editable: true }),
|
||||||
|
makeColumn('Телеметрия', 'telemetry', {
|
||||||
|
width: 150,
|
||||||
|
editable: true,
|
||||||
|
render: (telemetry) => <TelemetryView telemetry={telemetry} />,
|
||||||
|
input: <TelemetrySelect telemetry={telemetry}/>,
|
||||||
|
}),
|
||||||
|
makeTagColumn('Компании', 'companies', companies, 'id', 'caption', {
|
||||||
|
width: 400
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
await updateTable()
|
await updateTable()
|
||||||
let clusters = await AdminClusterService.getAll()
|
|
||||||
clusters = clusters?.map((cluster) => ({ value: cluster.id, label: cluster.caption }))
|
|
||||||
setClusters(clusters ?? [])
|
|
||||||
},
|
},
|
||||||
setShowLoader,
|
setShowLoader,
|
||||||
`Не удалось загрузить список кустов`
|
`Не удалось загрузить список кустов`
|
||||||
@ -109,7 +112,7 @@ export default function WellController() {
|
|||||||
<EditableTable
|
<EditableTable
|
||||||
size={'small'}
|
size={'small'}
|
||||||
bordered
|
bordered
|
||||||
columns={wellColumns}
|
columns={columns}
|
||||||
dataSource={wells}
|
dataSource={wells}
|
||||||
onRowAdd={makeActionHandler('insert', handlerProps)}
|
onRowAdd={makeActionHandler('insert', handlerProps)}
|
||||||
onRowEdit={makeActionHandler('update', handlerProps)}
|
onRowEdit={makeActionHandler('update', handlerProps)}
|
||||||
|
@ -39,7 +39,7 @@ export const AdminPanel = () => {
|
|||||||
<Link to={`${rootPath}/role`}>Роли</Link>
|
<Link to={`${rootPath}/role`}>Роли</Link>
|
||||||
</PrivateMenuItem>
|
</PrivateMenuItem>
|
||||||
<PrivateMenuItem key={'permission'} permission={'permission_editor'}>
|
<PrivateMenuItem key={'permission'} permission={'permission_editor'}>
|
||||||
<Link to={`${rootPath}/permission`}>Права</Link>
|
<Link to={`${rootPath}/permission`}>Разрешения</Link>
|
||||||
</PrivateMenuItem>
|
</PrivateMenuItem>
|
||||||
<PrivateMenuItem key={'visit_log'}>
|
<PrivateMenuItem key={'visit_log'}>
|
||||||
<Link to={`${rootPath}/visit_log`}>Журнал посещений</Link>
|
<Link to={`${rootPath}/visit_log`}>Журнал посещений</Link>
|
||||||
|
Loading…
Reference in New Issue
Block a user