Добавлен дашборд с выводом параметров ННБ

This commit is contained in:
goodmice 2022-04-15 17:17:34 +05:00
parent 0e519ea03f
commit 7c3d46893a
11 changed files with 591 additions and 25 deletions

View File

@ -0,0 +1,67 @@
import { Button } from 'antd'
import { memo, ReactNode, useMemo } from 'react'
import { CloseOutlined, SettingOutlined } from '@ant-design/icons'
import '@styles/widgets/base.less'
export type WidgetSettings<T = any> = {
id?: number,
unit?: string,
label?: string,
formatter?: ((v: T) => ReactNode) | null,
defaultValue?: ReactNode,
labelColor?: string,
valueColor?: string,
backgroundColor?: string,
unitColor?: string,
}
export const defaultSettings: WidgetSettings = {
unit: '----',
label: 'Виджет',
formatter: v => isNaN(v) ? v : parseFloat(v).toFixed(2),
labelColor: '#000000',
valueColor: '#000000',
backgroundColor: '#f6f6f6',
unitColor: '#a0a0a0',
}
export type BaseWidgetProps<T = any> = WidgetSettings<T> & {
value: T,
onRemove: (settings: WidgetSettings<T>) => void,
onEdit: (settings: WidgetSettings<T>) => void,
}
export const BaseWidget = memo<BaseWidgetProps>(({ value, onRemove, onEdit, ...settings }) => {
const sets = useMemo<WidgetSettings>(() => ({ ...defaultSettings, ...settings }), [settings])
return (
<div className={'number_widget'} style={{ background: sets.backgroundColor }}>
<div className={'widget_head'}>
<Button
type={'text'}
onClick={() => onEdit(sets)}
icon={<SettingOutlined />}
style={{ visibility: !!onEdit ? 'visible' : 'hidden' }}
/>
<div className={'widget_label'} style={{ color: sets.labelColor }}>{sets.label}</div>
<div className={'widget_close'}>
<Button
type={'link'}
icon={<CloseOutlined />}
onClick={() => onRemove(settings)}
style={{ visibility: !!onRemove ? 'visible' : 'hidden' }}
/>
</div>
</div>
<div className={'widget_value'} style={{ color: sets.valueColor }}>
{(sets.formatter === null ? value : sets.formatter?.(value)) ?? sets.defaultValue ?? '----'}
</div>
<div className={'widget_units'} style={{ color: sets.unitColor }}>{sets.unit}</div>
</div>
)
})
export default BaseWidget

View File

@ -0,0 +1,65 @@
import { memo, useEffect } from 'react'
import { Form, Input, Modal, ModalProps } from 'antd'
import { WidgetSettings } from './BaseWidget'
export type WidgetSettingsWindowProps<T = any> = ModalProps & {
settings: WidgetSettings<T>
onEdit: (settings: WidgetSettings<T>) => void
}
const { Item } = Form
export const WidgetSettingsWindow = memo<WidgetSettingsWindowProps>(({ settings, onEdit, ...other }) => {
const [form] = Form.useForm()
useEffect(() => {
if (settings) form.setFieldsValue(settings)
}, [form, settings])
return (
<Modal
{...other}
visible={!!settings}
title={(
<>
Настройка виджета {settings?.label ? `"${settings?.label}"` : ''}
<span style={{ color: '#a0a0a0'}}> (id: {settings?.id})</span>
</>
)}
onOk={form.submit}
getContainer={false}
>
<Form form={form} onFinish={onEdit}>
<Item name={'id'} hidden><Input type={'hidden'} /></Item>
<Item
label={'Заголовок поля'}
name={'label'}
rules={[{ required: true, message: 'Пожалуйста, введите заголовок!' }]}
>
<Input />
</Item>
<Item
label={'Единицы измерения'}
name={'unit'}
>
<Input />
</Item>
<Item label={'Цвет заголовка'} name={'labelColor'}>
<Input type={'color'} />
</Item>
<Item label={'Цвет значения'} name={'valueColor'}>
<Input type={'color'} />
</Item>
<Item label={'Цвет фона'} name={'backgroundColor'}>
<Input type={'color'} />
</Item>
<Item label={'Цвет единиц измерения'} name={'unitColor'}>
<Input type={'color'} />
</Item>
</Form>
</Modal>
)
})
export default WidgetSettingsWindow

View File

@ -0,0 +1,5 @@
export { WidgetSettingsWindow } from './WidgetSettingsWindow'
export { BaseWidget } from './BaseWidget'
export type { WidgetSettingsWindowProps } from './WidgetSettingsWindow'
export type { WidgetSettings, BaseWidgetProps } from './BaseWidget'

View File

@ -0,0 +1,18 @@
import { PlusOutlined } from '@ant-design/icons'
import { Form, Input } from 'antd'
import { memo } from 'react'
import Poprompt from '@components/selectors/Poprompt'
const addGroupRules = [{ required: true, message: 'Пожалуйста, введите название группы' }]
const addGroupButtonProps = { type: 'link', className: 'add_group', icon: <PlusOutlined /> }
export const AddGroupWindow = memo(({ addGroup, initialValue = 'Новая группа' }) => (
<Poprompt placement={'right'} text={'Добавить группу'} buttonProps={addGroupButtonProps} onDone={addGroup}>
<Form.Item initialValue={initialValue} rules={addGroupRules} name={'groupName'} label={'Название группы'}>
<Input type={'text'} />
</Form.Item>
</Poprompt>
))
export default AddGroupWindow

View File

@ -0,0 +1,52 @@
import { PlusOutlined } from '@ant-design/icons'
import { Form, Select } from 'antd'
import { memo, useCallback, useMemo } from 'react'
import Poprompt from '@components/selectors/Poprompt'
export const createWidgetId = (witsRecord, dt = false) => `${witsRecord.recordId}_${witsRecord.itemId}` + (dt ? `_${Date.now()}` : '')
export const makeWidgetFromWits = (witsRecord) => ({
id: createWidgetId(witsRecord, true),
witsId: witsRecord.longMnemonic.toLowerCase(),
recordId: witsRecord.recordId,
unit: witsRecord.metricUnits,
label: witsRecord.longMnemonic,
})
const addWidgetRules = [{ required: true, message: 'Пожалуйста, выберите виджет' }]
const addWidgetButtonProps = { type: 'link', className: 'add_group', icon: <PlusOutlined /> }
export const AddWidgetWindow = memo(({ witsInfo, onAdded }) => {
const options = useMemo(() => witsInfo?.map((witsRecord) => ({
label: `Record #${witsRecord.recordId}: ${witsRecord.longMnemonic}`,
value: createWidgetId(witsRecord),
})) ?? [], [witsInfo])
const onFormFinish = useCallback((value) => {
if (!value?.widget) return
const record = witsInfo.find((witsRecord) => createWidgetId(witsRecord) === value.widget)
if (record)
onAdded?.(makeWidgetFromWits(record))
}, [onAdded, witsInfo])
return (
<Poprompt
placement={'right'}
onDone={onFormFinish}
text={'Добавить виджет'}
buttonProps={addWidgetButtonProps}
overlayInnerStyle={{ width: '300px' }}
>
<Form.Item rules={addWidgetRules} name={'widget'} label={'Виджет'}>
<Select
showSearch
options={options}
filterOption={(input, option) => option.label.toLowerCase().includes(input.toLowerCase())}
/>
</Form.Item>
</Poprompt>
)
})
export default AddWidgetWindow

View File

@ -0,0 +1,255 @@
import { memo, useCallback, useEffect, useMemo, useReducer, useState } from 'react'
import { useHistory, useParams } from 'react-router-dom'
import { CloseOutlined } from '@ant-design/icons'
import { Button, Menu, Popconfirm } from 'antd'
import LoaderPortal from '@components/LoaderPortal'
import { invokeWebApiWrapperAsync } from '@components/factory'
import { BaseWidget, WidgetSettingsWindow } from '@components/widgets'
import { getJSON, setJSON } from '@utils/storage'
import { arrayOrDefault } from '@utils'
import Subscribe from '@services/signalr'
import {
WitsInfoService,
WitsRecord1Service,
WitsRecord7Service,
WitsRecord8Service,
WitsRecord50Service,
WitsRecord60Service,
WitsRecord61Service,
} from '@api'
import AddWidgetWindow, { makeWidgetFromWits } from './AddWidgetWindow'
import '@styles/dashboard_nnb.less'
import AddGroupWindow from './AddGroupWindow'
const getWitsInfo = async () => {
// TODO: Добавить expire с принудительным обновлением
if ('witsInfo' in localStorage)
return getJSON('witsInfo')
const info = arrayOrDefault(await WitsInfoService.getItems())
setJSON('witsInfo', info)
return info
}
const STORAGE_NAME = 'nnbWidgets'
const defaultGroups = [
{ id: '1', name: 'General Time-Based', editable: false },
{ id: '7', name: 'Survey/Directional', editable: false },
{ id: '8', name: 'MWD Formation Evaluation', editable: false },
{ id: '50', name: 'Резистивиметр MCR', editable: false },
{ id: '60', name: 'Передача полных', editable: false },
{ id: '61', name: 'Резистивиметр Corvet', editable: false },
]
const makeGroup = (settings) => ({
widgets: [],
editable: true,
id: '' + Date.now(),
name: 'Новая группа',
...settings,
})
const groupsReducer = (groups, action) => {
let newGroups = [ ...groups ]
const { groupId, widgetId, value, type, witsInfo } = action
const groupIdx = newGroups.findIndex(({ id }) => `${id}` === groupId)
const widgetIdx = groupIdx < 0 ? -1 : newGroups[groupIdx].widgets.findIndex(({ id }) => id === widgetId)
switch (type) {
case 'set': return value
case 'clear':
localStorage.removeItem(STORAGE_NAME)
if (!witsInfo) return []
// break намеренно пропущен, далее должен срабатывать init
case 'init': // eslint-disable-line no-fallthrough
if (STORAGE_NAME in localStorage)
return getJSON(STORAGE_NAME)
newGroups = defaultGroups.map((group) => ({
...group,
widgets: witsInfo.filter(({ recordId }) => `${recordId}` === group.id).map(makeWidgetFromWits)
}))
break
case 'add_group':
newGroups.push(makeGroup(value))
break
case 'edit_group':
if (groupIdx >= 0)
newGroups[groupIdx] = { ...newGroups[groupIdx], ...value }
break
case 'remove_group':
if (groupIdx >= 0)
newGroups.splice(groupIdx, 1)
break
case 'add_widget':
if (groupIdx >= 0)
newGroups[groupIdx].widgets.push(value)
break
case 'edit_widget':
if (widgetIdx >= 0)
newGroups[groupIdx].widgets[widgetIdx] = { ...newGroups[groupIdx].widgets[widgetIdx], ...value }
break
case 'remove_widget':
if (widgetIdx >= 0)
newGroups[groupIdx].widgets.splice(widgetIdx, 1)
break
default: return newGroups
}
setJSON('nnbWidgets', newGroups)
return newGroups
}
export const DashboardNNB = memo(({ idWell }) => {
const [groups, dispatchGroups] = useReducer(groupsReducer, [])
const [witsInfo, setWitsInfo] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [selectedSettings, setSelectedSettings] = useState(null)
const [values, setValues] = useState({})
const root = useMemo(() => `/well/${idWell}/telemetry/dashboard_nnb`, [idWell])
const history = useHistory()
const { tab: selectedGroup } = useParams()
if (!selectedGroup && groups?.length > 0)
history.push(`${root}/${groups[0].id}`)
const group = useMemo(() => ({
editable: true,
...groups.find(({ id }) => `${id}` === selectedGroup),
}), [groups, selectedGroup])
useEffect(() => invokeWebApiWrapperAsync(
async () => {
const info = await getWitsInfo()
setWitsInfo(info)
dispatchGroups({ type: 'init', witsInfo: info })
},
setIsLoading,
'Не удалось загрузить информацию о параметрах ННБ',
'Получение информации о параметрах ННБ'
), [])
const handleData = useCallback((data, recordId) => {
const mergedData = data.reduce((out, record) => ({ ...out, ...record }), {})
setValues((pre) => ({
...pre,
[recordId]: { ...pre[recordId], ...mergedData },
}))
}, [])
useEffect(() => {
invokeWebApiWrapperAsync(
async () => {
handleData(await WitsRecord1Service.getLastData(idWell), '1')
handleData(await WitsRecord7Service.getLastData(idWell), '7')
handleData(await WitsRecord8Service.getLastData(idWell), '8')
handleData(await WitsRecord50Service.getLastData(idWell), '50')
handleData(await WitsRecord60Service.getLastData(idWell), '60')
handleData(await WitsRecord61Service.getLastData(idWell), '61')
},
setIsLoading,
'Не удалось загрузить последние данные',
'Получение данных WITS',
)
return Subscribe('hubs/telemetry', `well_${idWell}_wits`,
{ methodName: 'ReceiveWitsRecord1', handler: (data) => handleData(data, '1') },
{ methodName: 'ReceiveWitsRecord7', handler: (data) => handleData(data, '7') },
{ methodName: 'ReceiveWitsRecord8', handler: (data) => handleData(data, '8') },
{ methodName: 'ReceiveWitsRecord50', handler: (data) => handleData(data, '50') },
{ methodName: 'ReceiveWitsRecord60', handler: (data) => handleData(data, '60') },
{ methodName: 'ReceiveWitsRecord61', handler: (data) => handleData(data, '61') },
)
}, [idWell, handleData])
const addGroup = useCallback((values) => dispatchGroups({ type: 'add_group', value: { name: values.groupName } }), [])
const removeGroup = useCallback((id) => {
dispatchGroups({ type: 'remove_group', groupId: `${id}` })
if (id === selectedGroup) history.push(`${root}`)
}, [root, history, selectedGroup])
const addWidget = useCallback((settings) => dispatchGroups({
type: 'add_widget',
groupId: selectedGroup,
value: settings,
}), [selectedGroup])
const onEdit = useCallback((settings) => {
dispatchGroups({
type: 'edit_widget',
groupId: selectedGroup,
widgetId: settings.id,
value: settings,
})
setSelectedSettings(null)
}, [selectedGroup])
const removeWidget = useCallback((settings) => dispatchGroups({
type: 'remove_widget',
groupId: selectedGroup,
widgetId: settings.id,
}), [selectedGroup])
return (
<LoaderPortal show={isLoading}>
<div className={'dashboard_nnb'}>
<Menu
className={'dashboard_menu'}
mode={'vertical'}
selectable={true}
selectedKeys={[selectedGroup]}
>
<Menu.Item key={'add_group'}>
<AddGroupWindow addGroup={addGroup} />
</Menu.Item>
{group?.editable && (
<Menu.Item key={'add_widget'}>
<AddWidgetWindow witsInfo={witsInfo} onAdded={addWidget} />
</Menu.Item>
)}
{groups.map(({ id, name, editable }) => (
<Menu.Item key={id}>
{editable && (
<Popconfirm
title={'Вы уверены, что хотите удалить группу, это действие невозможно отменить?'}
onConfirm={() => removeGroup(id)}
okText={'Удалить'}
cancelText={'Отмена'}
>
<Button type={'link'} icon={<CloseOutlined />} />
</Popconfirm>
)}
<Button type={'text'} style={{ paddingLeft: 0 }} onClick={() => history.push(`${root}/${id}`)}>{name}</Button>
</Menu.Item>
))}
</Menu>
<div className={'widgets'}>
{group.widgets?.map((widget) => (
<BaseWidget
key={widget.id}
// onEdit={group.editable && setSelectedSettings} // TODO: Доделать редактирование
onRemove={group.editable && removeWidget}
{...widget}
value={values[widget.recordId]?.[widget.witsId]}
/>
))}
<WidgetSettingsWindow
visible={!!selectedSettings}
onCancel={() => setSelectedSettings(null)}
settings={selectedSettings}
onEdit={onEdit}
/>
</div>
</div>
</LoaderPortal>
)
})
export default DashboardNNB

View File

@ -7,6 +7,7 @@ import { PrivateRoute, PrivateDefaultRoute, PrivateMenuItem } from '@components/
import Archive from './Archive'
import Messages from './Messages'
import DashboardNNB from './DashboardNNB'
import TelemetryView from './TelemetryView'
import '@styles/index.css'
@ -23,6 +24,7 @@ export const Telemetry = memo(({ idWell }) => {
<PrivateMenuItem.Link root={rootPath} key={'monitoring'} path={'monitoring'} icon={<FundViewOutlined />} title={'Мониторинг'}/>
<PrivateMenuItem.Link root={rootPath} key={'messages'} path={'messages'} icon={<AlertOutlined/>} title={'Сообщения'} />
<PrivateMenuItem.Link root={rootPath} key={'archive'} path={'archive'} icon={<DatabaseOutlined />} title={'Архив'} />
<PrivateMenuItem.Link root={rootPath} key={'dashboard_nnb'} path={'dashboard_nnb'} title={'ННБ'} />
</Menu>
<Layout>
@ -37,10 +39,14 @@ export const Telemetry = memo(({ idWell }) => {
<PrivateRoute path={`${rootPath}/archive`}>
<Archive idWell={idWell} />
</PrivateRoute>
<PrivateRoute path={`${rootPath}/dashboard_nnb/:tab?`}>
<DashboardNNB idWell={idWell} />
</PrivateRoute>
<PrivateDefaultRoute urls={[
`${rootPath}/monitoring`,
`${rootPath}/messages`,
`${rootPath}/archive`,
`${rootPath}/dashboard_nnb`,
]}/>
</Switch>
</Content>

View File

@ -0,0 +1,29 @@
.dashboard_nnb {
display: flex;
flex-direction: row;
max-height: 80vh;
& > .dashboard_menu {
flex: 1;
max-height: 100%;
overflow-y: auto;
& .add_group {
//
}
}
& .widgets {
flex: 5;
display: flex;
flex-wrap: wrap;
overflow-y: auto;
& .add_widget {
border-radius: 5px;
margin: 10px;
width: 13vh;
height: 13vh;
}
}
}

View File

@ -0,0 +1,56 @@
@size: 9.9vh;
.number_widget {
display: flex;
flex-direction: column;
justify-content: center;
align-items: stretch;
margin: 10px;
padding: 5px 10px;
border-radius: 5px;
background: #00000009;
height: @size;
min-width: @size * 1.75;
& > .widget_head {
display: grid;
grid-gap: 10px;
& > .widget_settings {
grid-row: 1;
grid-column: 1;
}
& > .widget_label {
grid-row: 1;
grid-column: 2;
color: black;
font-size: @size * 0.175;
//line-height: @size * 0.175;
text-align: center;
}
& > .widget_close {
grid-row: 1;
grid-column: 3;
display: flex;
flex-direction: row-reverse;
}
}
& > .widget_value {
flex: 2;
color: black;
font-size: @size * 0.45;
line-height: @size * 0.45;
text-align: center;
}
& > .widget_units {
flex: 1;
text-align: right;
font-size: @size * 0.15;
line-height: @size * 0.15;
color: #A0A0A0;
}
}

View File

@ -10,9 +10,14 @@ export const mainFrameSize = () => ({
export const arrayOrDefault = <T extends unknown>(arr?: unknown, def: T[] = []): T[] => Array.isArray(arr) ? arr : def
export const deepCopy = <T extends any>(data: T): T => JSON.parse(JSON.stringify(data ?? null))
export const wrapValues = <T, R>(data: Record<string, T>, handler: (data: T, key: string, object: Record<string, T>) => R): Record<string, R> =>
Object.fromEntries(Object.entries(data).map(([key, value]) => [key, handler(value, key, data)]))
/**
* Объединить типы, исключив совпадающие поля справа
* @param T Тип, передаваемый полностью
* @param R Аддитивный тип
*/
export type OmitExtends<T, R> = T & Omit<R, keyof T>
export type OmitExtends<T, R> = T & Omit<R, keyof T>

View File

@ -1,4 +1,5 @@
import { OpenAPI, UserTokenDto } from '@api'
import { wrapValues } from '.'
import { Role, Permission } from './permissions'
import { normalizeColumn, optimizeColumn, TableSettings, TableSettingsStore } from './table_settings'
@ -9,6 +10,8 @@ export enum StorageNames {
permissions = 'permissions',
roles = 'roles',
tableSettings = 'tableSettings',
dashboardNNB = 'dashboardNNB',
witsInfo = 'witsInfo'
}
export const getArrayFromLocalStorage = <T extends string = string>(name: string, sep: string | RegExp = ','): T[] | null => {
@ -17,6 +20,23 @@ export const getArrayFromLocalStorage = <T extends string = string>(name: string
return raw.split(sep).map<T>(elm => elm as T)
}
export const getJSON = <T extends any>(name: StorageNames): T | null => {
const raw = localStorage.getItem(name)
if (!raw) return null
try {
return JSON.parse(raw)
} catch {}
return null
}
export const setJSON = <T extends any>(name: StorageNames, data: T | null): boolean => {
try {
localStorage.setItem(name, JSON.stringify(data))
return true
} catch {}
return false
}
export const getUserRoles = (): Role[] => getArrayFromLocalStorage<Role>(StorageNames.roles) ?? []
export const getUserPermissions = (): Permission[] => getArrayFromLocalStorage<Permission>(StorageNames.permissions) ?? []
export const getUserId = () => Number(localStorage.getItem(StorageNames.userId)) || null
@ -43,31 +63,19 @@ export const removeUser = () => {
}
export const getTableSettings = (tableName: string): TableSettings => {
const tablesJSON = localStorage.getItem(StorageNames.tableSettings)
if (!tablesJSON) return {}
try {
const tables: TableSettingsStore = JSON.parse(tablesJSON)
if (tableName in tables) {
const columns = tables[tableName] ?? {}
for (const [name, column] of Object.entries(columns))
columns[name] = normalizeColumn(column, name)
return columns
}
} catch {}
return {}
const tables = getJSON<TableSettingsStore>(StorageNames.tableSettings) ?? {}
if (!(tableName in tables)) return {}
return wrapValues(tables[tableName] ?? {}, normalizeColumn)
}
export const setTableSettings = (tableName: string, settings?: TableSettings | null): boolean => {
const currentJSON = localStorage.getItem(StorageNames.tableSettings)
try {
const currentStore: TableSettingsStore = currentJSON ? JSON.parse(currentJSON) : {}
const newSettings = settings ?? null
if (newSettings)
for (const [name, column] of Object.entries(newSettings))
newSettings[name] = optimizeColumn(column)
currentStore[tableName] = newSettings
localStorage.setItem(StorageNames.tableSettings, JSON.stringify(currentStore))
return true
} catch {}
return false
const currentStore = getJSON<TableSettingsStore>(StorageNames.tableSettings) ?? {}
currentStore[tableName] = wrapValues(settings ?? {}, optimizeColumn)
return setJSON(StorageNames.tableSettings, currentStore)
}
export type DataDashboardNNB = {
}
export const getDashboardNNB = () => getJSON<DataDashboardNNB>(StorageNames.dashboardNNB)