Refactor Dashboard Overview Components for Enhanced Layout and Functionality
- Introduced DashboardOverviewPage and DashboardOverviewFlexColumns components to streamline the layout and management of dashboard sections, improving overall organization and usability. - Replaced individual collapsible sections in Finance, Inventory, Production, and Sales overviews with a unified structure, enhancing maintainability and consistency across the dashboard. - Added ActionsButton component for improved action handling and keyboard shortcuts, enhancing user interaction capabilities. - Updated styles in App.css to support new drag-and-drop functionality and layout features, ensuring a cohesive visual experience. - Refactored existing components to utilize the new structure, improving code clarity and reducing redundancy.
This commit is contained in:
parent
f0e93b0519
commit
044abf32a8
@ -1911,3 +1911,210 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
|
||||
.no-navigation-labels.ant-menu-horizontal .ant-menu-item {
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.overview-drag-handle {
|
||||
cursor: grab;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-text);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.overview-drag-handle svg path {
|
||||
stroke: currentColor;
|
||||
stroke-width: 3px;
|
||||
}
|
||||
|
||||
.overview-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.overview-section-wrapper.overview-dragging,
|
||||
.overview-flex-columns-item.overview-dragging,
|
||||
.overview-stat-wrapper.overview-dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.overview-stat-wrapper.overview-drag-over,
|
||||
.overview-flex-columns-item.overview-drag-over {
|
||||
outline: 2px dashed color-mix(in srgb, var(--color-primary) 50%, transparent);
|
||||
outline-offset: 4px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.overview-stats-display {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.overview-stat-item {
|
||||
flex: 1 1 var(--stat-min-width, 175px);
|
||||
min-width: var(--stat-min-width, 175px);
|
||||
}
|
||||
|
||||
.overview-stat-item .ant-card,
|
||||
.overview-stat-item .ant-tag {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.overview-stat-item .ant-tag {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.overview-section-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.overview-flex-columns {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.overview-flex-columns.is-editing {
|
||||
border: 2px dashed color-mix(in srgb, var(--color-text) 25%, transparent);
|
||||
border-radius: 24px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.overview-flex-columns.is-empty {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.overview-flex-columns-toolbar {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.overview-flex-columns-row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.overview-flex-columns-empty {
|
||||
flex: 1;
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: color-mix(in srgb, var(--color-text) 45%, transparent);
|
||||
}
|
||||
|
||||
.overview-flex-columns-item {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
transition: flex-grow 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.overview-flex-columns.is-resizing,
|
||||
.overview-flex-columns.is-resizing * {
|
||||
cursor: col-resize !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.overview-flex-columns-separator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 100%;
|
||||
margin-left: calc(var(--ant-margin-lg, 24px) / 2);
|
||||
transform: translateX(-50%);
|
||||
width: 16px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.overview-flex-columns-separator-line {
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border-left: 2px dashed color-mix(in srgb, var(--color-text) 25%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.overview-flex-columns-separator:hover .overview-flex-columns-separator-line,
|
||||
.overview-flex-columns-separator.is-active
|
||||
.overview-flex-columns-separator-line {
|
||||
border-left: none;
|
||||
background-color: var(--color-primary);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.overview-flex-columns-separator-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 2px;
|
||||
height: 10px;
|
||||
background-color: var(--color-primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.overview-flex-columns-item > .ant-collapse {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.overview-flex-columns.is-drag-over {
|
||||
outline: 2px dashed color-mix(in srgb, var(--color-primary) 50%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.overview-section-wrapper.overview-insert-before::before,
|
||||
.overview-section-wrapper.overview-insert-after::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 0;
|
||||
border-top: 2px dashed
|
||||
color-mix(in srgb, var(--color-primary) 50%, transparent);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.overview-section-wrapper.overview-insert-before::before {
|
||||
top: calc(var(--ant-margin-lg, 24px) / -2);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.overview-section-wrapper.overview-insert-after::after {
|
||||
bottom: calc(var(--ant-margin-lg, 24px) / -2);
|
||||
transform: translateY(50%);
|
||||
}
|
||||
|
||||
.overview-stat-wrapper {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.overview-stat-wrapper:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.info-collapse-resize-handle {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 12px;
|
||||
margin-top: -2px;
|
||||
cursor: ns-resize;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.info-collapse-resize-handle::after {
|
||||
content: '';
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: color-mix(in srgb, var(--color-text) 50%, transparent);
|
||||
}
|
||||
|
||||
@ -1,72 +1,33 @@
|
||||
import { Flex } from 'antd'
|
||||
import useCollapseState from '../hooks/useCollapseState'
|
||||
import DashboardOverviewPage from '../common/DashboardOverviewPage'
|
||||
import StatsDisplay from '../common/StatsDisplay'
|
||||
import InfoCollapse from '../common/InfoCollapse'
|
||||
import ScrollBox from '../common/ScrollBox'
|
||||
|
||||
const collapseDefaults = {
|
||||
invoiceStats: true,
|
||||
paymentStats: true
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{
|
||||
key: 'invoiceStats',
|
||||
title: 'Invoice Statistics',
|
||||
className: 'no-t-padding-collapse',
|
||||
content: <StatsDisplay objectType='invoice' />
|
||||
},
|
||||
{
|
||||
key: 'paymentStats',
|
||||
title: 'Payment Statistics',
|
||||
className: 'no-t-padding-collapse',
|
||||
content: <StatsDisplay objectType='payment' />
|
||||
}
|
||||
]
|
||||
|
||||
const FinanceOverview = () => {
|
||||
const [collapseState, updateCollapseState] = useCollapseState(
|
||||
'FinanceOverview',
|
||||
{
|
||||
invoiceStats: true,
|
||||
paymentStats: true
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
<ScrollBox>
|
||||
<Flex vertical gap={'large'}>
|
||||
<InfoCollapse
|
||||
title='Invoice Statistics'
|
||||
icon={null}
|
||||
active={collapseState.invoiceStats}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('invoiceStats', isActive)
|
||||
}
|
||||
className='no-t-padding-collapse'
|
||||
collapseKey='invoiceStats'
|
||||
canCollapse={false}
|
||||
>
|
||||
<Flex
|
||||
justify='flex-start'
|
||||
gap='middle'
|
||||
wrap='wrap'
|
||||
align='flex-start'
|
||||
>
|
||||
<StatsDisplay objectType='invoice' />
|
||||
</Flex>
|
||||
</InfoCollapse>
|
||||
<InfoCollapse
|
||||
title='Payment Statistics'
|
||||
icon={null}
|
||||
active={collapseState.paymentStats}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('paymentStats', isActive)
|
||||
}
|
||||
className='no-t-padding-collapse'
|
||||
collapseKey='paymentStats'
|
||||
canCollapse={false}
|
||||
>
|
||||
<Flex
|
||||
justify='flex-start'
|
||||
gap='middle'
|
||||
wrap='wrap'
|
||||
align='flex-start'
|
||||
>
|
||||
<StatsDisplay objectType='payment' />
|
||||
</Flex>
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
</ScrollBox>
|
||||
</Flex>
|
||||
<DashboardOverviewPage
|
||||
pageName='FinanceOverview'
|
||||
collapseDefaults={collapseDefaults}
|
||||
sections={sections}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,292 +1,183 @@
|
||||
import { Flex } from 'antd'
|
||||
import useCollapseState from '../hooks/useCollapseState.jsx'
|
||||
import DashboardOverviewPage from '../common/DashboardOverviewPage'
|
||||
import StatsDisplay from '../common/StatsDisplay'
|
||||
import ModelHistoryDisplay from '../common/ModelHistoryDisplay'
|
||||
import InfoCollapse from '../common/InfoCollapse'
|
||||
import ScrollBox from '../common/ScrollBox'
|
||||
import ObjectTable from '../common/ObjectTable'
|
||||
|
||||
const collapseDefaults = {
|
||||
partStockStats: true,
|
||||
filamentStockStats: true,
|
||||
productStockStats: true,
|
||||
purchaseOrderStats: true,
|
||||
stockEventStats: true,
|
||||
partStockHistory: true,
|
||||
newPartStocks: true,
|
||||
filamentStockHistory: true,
|
||||
unconsumedFilamentStocks: true,
|
||||
productStockHistory: true,
|
||||
draftProductStocks: true,
|
||||
purchaseOrderHistory: true,
|
||||
draftPurchaseOrders: true,
|
||||
stockEventHistory: true
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{
|
||||
key: 'partStockStats',
|
||||
title: 'Part Stock Statistics',
|
||||
className: 'no-t-padding-collapse',
|
||||
content: <StatsDisplay objectType='partStock' />
|
||||
},
|
||||
{
|
||||
key: 'filamentStockStats',
|
||||
title: 'Filament Stock Statistics',
|
||||
className: 'no-t-padding-collapse',
|
||||
content: <StatsDisplay objectType='filamentStock' />
|
||||
},
|
||||
{
|
||||
key: 'productStockStats',
|
||||
title: 'Product Stock Statistics',
|
||||
className: 'no-t-padding-collapse',
|
||||
content: <StatsDisplay objectType='productStock' />
|
||||
},
|
||||
{
|
||||
key: 'purchaseOrderStats',
|
||||
title: 'Purchase Order Statistics',
|
||||
className: 'no-t-padding-collapse',
|
||||
content: <StatsDisplay objectType='purchaseOrder' />
|
||||
},
|
||||
{
|
||||
key: 'stockEventStats',
|
||||
title: 'Stock Event Statistics',
|
||||
className: 'no-t-padding-collapse',
|
||||
content: <StatsDisplay objectType='stockEvent' />
|
||||
},
|
||||
{
|
||||
key: 'partAndFilamentHistoryRow',
|
||||
type: 'row',
|
||||
columns: [
|
||||
[
|
||||
{
|
||||
key: 'partStockHistory',
|
||||
title: 'Part Stock History',
|
||||
content: (
|
||||
<ModelHistoryDisplay objectType='partStock' chartType='line' />
|
||||
)
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'filamentStockHistory',
|
||||
title: 'Filament Stock History',
|
||||
content: (
|
||||
<ModelHistoryDisplay objectType='filamentStock' chartType='line' />
|
||||
)
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'newPartAndUnconsumedFilamentRow',
|
||||
type: 'row',
|
||||
columns: [
|
||||
[
|
||||
{
|
||||
key: 'newPartStocks',
|
||||
title: 'New Part Stocks',
|
||||
content: (
|
||||
<ObjectTable type='partStock' masterFilter={{ state: 'new' }} />
|
||||
)
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'unconsumedFilamentStocks',
|
||||
title: 'Unconsumed Filament Stocks',
|
||||
content: (
|
||||
<ObjectTable
|
||||
type='filamentStock'
|
||||
masterFilter={{ state: 'unconsumed' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'productAndPurchaseOrderHistoryRow',
|
||||
type: 'row',
|
||||
columns: [
|
||||
[
|
||||
{
|
||||
key: 'productStockHistory',
|
||||
title: 'Product Stock History',
|
||||
content: (
|
||||
<ModelHistoryDisplay objectType='productStock' chartType='line' />
|
||||
)
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'purchaseOrderHistory',
|
||||
title: 'Purchase Order History',
|
||||
content: <ModelHistoryDisplay objectType='purchaseOrder' />
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'draftProductAndPurchaseOrderRow',
|
||||
type: 'row',
|
||||
columns: [
|
||||
[
|
||||
{
|
||||
key: 'draftProductStocks',
|
||||
title: 'Draft Product Stocks',
|
||||
content: (
|
||||
<ObjectTable
|
||||
type='productStock'
|
||||
masterFilter={{ state: 'draft' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'draftPurchaseOrders',
|
||||
title: 'Draft Purchase Orders',
|
||||
content: (
|
||||
<ObjectTable
|
||||
type='purchaseOrder'
|
||||
masterFilter={{ state: 'draft' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'stockEventHistoryRow',
|
||||
type: 'row',
|
||||
columns: [
|
||||
[
|
||||
{
|
||||
key: 'stockEventHistory',
|
||||
title: 'Stock Event History',
|
||||
content: (
|
||||
<ModelHistoryDisplay objectType='stockEvent' chartType='line' />
|
||||
)
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const InventoryOverview = () => {
|
||||
const [collapseState, updateCollapseState] = useCollapseState(
|
||||
'InventoryOverview',
|
||||
{
|
||||
partStockStats: true,
|
||||
filamentStockStats: true,
|
||||
productStockStats: true,
|
||||
purchaseOrderStats: true,
|
||||
stockEventStats: true,
|
||||
partStockHistory: true,
|
||||
newPartStocks: true,
|
||||
filamentStockHistory: true,
|
||||
unconsumedFilamentStocks: true,
|
||||
productStockHistory: true,
|
||||
draftProductStocks: true,
|
||||
purchaseOrderHistory: true,
|
||||
draftPurchaseOrders: true,
|
||||
stockEventHistory: true
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
<ScrollBox>
|
||||
<Flex vertical gap={'large'}>
|
||||
<InfoCollapse
|
||||
title='Part Stock Statistics'
|
||||
icon={null}
|
||||
canCollapse={false}
|
||||
active={collapseState.partStockStats}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('partStockStats', isActive)
|
||||
}
|
||||
className='no-t-padding-collapse'
|
||||
collapseKey='partStockStats'
|
||||
>
|
||||
<Flex
|
||||
justify='flex-start'
|
||||
gap='middle'
|
||||
wrap='wrap'
|
||||
align='flex-start'
|
||||
>
|
||||
<StatsDisplay objectType='partStock' />
|
||||
</Flex>
|
||||
</InfoCollapse>
|
||||
|
||||
<InfoCollapse
|
||||
title='Filament Stock Statistics'
|
||||
icon={null}
|
||||
canCollapse={false}
|
||||
active={collapseState.filamentStockStats}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('filamentStockStats', isActive)
|
||||
}
|
||||
className='no-t-padding-collapse'
|
||||
collapseKey='filamentStockStats'
|
||||
>
|
||||
<Flex
|
||||
justify='flex-start'
|
||||
gap='middle'
|
||||
wrap='wrap'
|
||||
align='flex-start'
|
||||
>
|
||||
<StatsDisplay objectType='filamentStock' />
|
||||
</Flex>
|
||||
</InfoCollapse>
|
||||
|
||||
<InfoCollapse
|
||||
title='Product Stock Statistics'
|
||||
icon={null}
|
||||
canCollapse={false}
|
||||
active={collapseState.productStockStats}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('productStockStats', isActive)
|
||||
}
|
||||
className='no-t-padding-collapse'
|
||||
collapseKey='productStockStats'
|
||||
>
|
||||
<Flex
|
||||
justify='flex-start'
|
||||
gap='middle'
|
||||
wrap='wrap'
|
||||
align='flex-start'
|
||||
>
|
||||
<StatsDisplay objectType='productStock' />
|
||||
</Flex>
|
||||
</InfoCollapse>
|
||||
|
||||
<InfoCollapse
|
||||
title='Purchase Order Statistics'
|
||||
icon={null}
|
||||
canCollapse={false}
|
||||
active={collapseState.purchaseOrderStats}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('purchaseOrderStats', isActive)
|
||||
}
|
||||
className='no-t-padding-collapse'
|
||||
collapseKey='purchaseOrderStats'
|
||||
>
|
||||
<Flex
|
||||
justify='flex-start'
|
||||
gap='middle'
|
||||
wrap='wrap'
|
||||
align='flex-start'
|
||||
>
|
||||
<StatsDisplay objectType='purchaseOrder' />
|
||||
</Flex>
|
||||
</InfoCollapse>
|
||||
|
||||
<InfoCollapse
|
||||
title='Stock Event Statistics'
|
||||
icon={null}
|
||||
canCollapse={false}
|
||||
active={collapseState.stockEventStats}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('stockEventStats', isActive)
|
||||
}
|
||||
className='no-t-padding-collapse'
|
||||
collapseKey='stockEventStats'
|
||||
>
|
||||
<Flex
|
||||
justify='flex-start'
|
||||
gap='middle'
|
||||
wrap='wrap'
|
||||
align='flex-start'
|
||||
>
|
||||
<StatsDisplay objectType='stockEvent' />
|
||||
</Flex>
|
||||
</InfoCollapse>
|
||||
|
||||
<Flex gap='large' wrap='wrap'>
|
||||
<Flex flex={1} vertical style={{ minWidth: '300px' }} gap='large'>
|
||||
<InfoCollapse
|
||||
title='Part Stock History'
|
||||
icon={null}
|
||||
active={collapseState.partStockHistory}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('partStockHistory', isActive)
|
||||
}
|
||||
collapseKey='partStockHistory'
|
||||
canCollapse={false}
|
||||
>
|
||||
<ModelHistoryDisplay objectType='partStock' chartType='line' />
|
||||
</InfoCollapse>
|
||||
<InfoCollapse
|
||||
title='New Part Stocks'
|
||||
icon={null}
|
||||
active={collapseState.newPartStocks}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('newPartStocks', isActive)
|
||||
}
|
||||
collapseKey='newPartStocks'
|
||||
canCollapse={false}
|
||||
>
|
||||
<ObjectTable type='partStock' masterFilter={{ state: 'new' }} />
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
<Flex flex={1} vertical style={{ minWidth: '300px' }} gap='large'>
|
||||
<InfoCollapse
|
||||
title='Filament Stock History'
|
||||
icon={null}
|
||||
active={collapseState.filamentStockHistory}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('filamentStockHistory', isActive)
|
||||
}
|
||||
canCollapse={false}
|
||||
collapseKey='filamentStockHistory'
|
||||
>
|
||||
<ModelHistoryDisplay
|
||||
objectType='filamentStock'
|
||||
chartType='line'
|
||||
/>
|
||||
</InfoCollapse>
|
||||
<InfoCollapse
|
||||
title='Unconsumed Filament Stocks'
|
||||
icon={null}
|
||||
active={collapseState.unconsumedFilamentStocks}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('unconsumedFilamentStocks', isActive)
|
||||
}
|
||||
canCollapse={false}
|
||||
collapseKey='unconsumedFilamentStocks'
|
||||
>
|
||||
<ObjectTable
|
||||
type='filamentStock'
|
||||
masterFilter={{ state: 'unconsumed' }}
|
||||
/>
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex gap='large' wrap='wrap'>
|
||||
<Flex flex={1} vertical style={{ minWidth: '300px' }} gap='large'>
|
||||
<InfoCollapse
|
||||
title='Product Stock History'
|
||||
icon={null}
|
||||
active={collapseState.productStockHistory}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('productStockHistory', isActive)
|
||||
}
|
||||
collapseKey='productStockHistory'
|
||||
canCollapse={false}
|
||||
>
|
||||
<ModelHistoryDisplay
|
||||
objectType='productStock'
|
||||
chartType='line'
|
||||
/>
|
||||
</InfoCollapse>
|
||||
<InfoCollapse
|
||||
title='Draft Product Stocks'
|
||||
icon={null}
|
||||
active={collapseState.draftProductStocks}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('draftProductStocks', isActive)
|
||||
}
|
||||
collapseKey='draftProductStocks'
|
||||
canCollapse={false}
|
||||
>
|
||||
<ObjectTable
|
||||
type='productStock'
|
||||
masterFilter={{ state: 'draft' }}
|
||||
/>
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
<Flex flex={1} vertical style={{ minWidth: '300px' }} gap='large'>
|
||||
<InfoCollapse
|
||||
title='Purchase Order History'
|
||||
icon={null}
|
||||
active={collapseState.purchaseOrderHistory}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('purchaseOrderHistory', isActive)
|
||||
}
|
||||
canCollapse={false}
|
||||
collapseKey='purchaseOrderHistory'
|
||||
>
|
||||
<ModelHistoryDisplay objectType='purchaseOrder' />
|
||||
</InfoCollapse>
|
||||
<InfoCollapse
|
||||
title='Draft Purchase Orders'
|
||||
icon={null}
|
||||
active={collapseState.draftPurchaseOrders}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('draftPurchaseOrders', isActive)
|
||||
}
|
||||
canCollapse={false}
|
||||
collapseKey='draftPurchaseOrders'
|
||||
>
|
||||
<ObjectTable
|
||||
type='purchaseOrder'
|
||||
masterFilter={{ state: 'draft' }}
|
||||
/>
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex gap='large' wrap='wrap'>
|
||||
<Flex flex={1} vertical style={{ minWidth: '300px' }}>
|
||||
<InfoCollapse
|
||||
title='Stock Event History'
|
||||
icon={null}
|
||||
active={collapseState.stockEventHistory}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('stockEventHistory', isActive)
|
||||
}
|
||||
canCollapse={false}
|
||||
collapseKey='stockEventHistory'
|
||||
>
|
||||
<ModelHistoryDisplay objectType='stockEvent' chartType='line' />
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ScrollBox>
|
||||
</Flex>
|
||||
<DashboardOverviewPage
|
||||
pageName='InventoryOverview'
|
||||
collapseDefaults={collapseDefaults}
|
||||
sections={sections}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,132 +1,83 @@
|
||||
import { Flex } from 'antd'
|
||||
import useCollapseState from '../hooks/useCollapseState.jsx'
|
||||
import DashboardOverviewPage from '../common/DashboardOverviewPage'
|
||||
import StatsDisplay from '../common/StatsDisplay'
|
||||
import ModelHistoryDisplay from '../common/ModelHistoryDisplay'
|
||||
import InfoCollapse from '../common/InfoCollapse'
|
||||
import ScrollBox from '../common/ScrollBox'
|
||||
import ObjectTable from '../common/ObjectTable'
|
||||
|
||||
const collapseDefaults = {
|
||||
printerStats: true,
|
||||
printerHistory: true,
|
||||
onlinePrinters: true,
|
||||
jobStats: true,
|
||||
productionStats: true,
|
||||
jobStatsDetails: true,
|
||||
jobHistory: true,
|
||||
queuedJobs: true
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{
|
||||
key: 'printerStats',
|
||||
title: 'Printer Statistics',
|
||||
className: 'no-t-padding-collapse',
|
||||
content: <StatsDisplay objectType='printer' />
|
||||
},
|
||||
{
|
||||
key: 'jobStats',
|
||||
title: 'Job Statistics',
|
||||
className: 'no-t-padding-collapse',
|
||||
content: <StatsDisplay objectType='job' />
|
||||
},
|
||||
{
|
||||
key: 'printerAndJobHistoryRow',
|
||||
type: 'row',
|
||||
columns: [
|
||||
[
|
||||
{
|
||||
key: 'printerHistory',
|
||||
title: 'Printer History',
|
||||
content: <ModelHistoryDisplay objectType='printer' />
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'jobHistory',
|
||||
title: 'Job History',
|
||||
content: <ModelHistoryDisplay objectType='job' />
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'onlinePrintersAndQueuedJobsRow',
|
||||
type: 'row',
|
||||
columns: [
|
||||
[
|
||||
{
|
||||
key: 'onlinePrinters',
|
||||
title: 'Online Printers',
|
||||
content: (
|
||||
<ObjectTable type='printer' masterFilter={{ online: true }} />
|
||||
)
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'queuedJobs',
|
||||
title: 'Queued Jobs',
|
||||
content: <ObjectTable type='job' masterFilter={{ state: 'queued' }} />
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const ProductionOverview = () => {
|
||||
const [collapseState, updateCollapseState] = useCollapseState(
|
||||
'ProductionOverview',
|
||||
{
|
||||
printerStats: true,
|
||||
printerHistory: true,
|
||||
onlinePrinters: true,
|
||||
jobStats: true,
|
||||
productionStats: true,
|
||||
jobStatsDetails: true
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
<ScrollBox>
|
||||
<Flex vertical gap={'large'}>
|
||||
<InfoCollapse
|
||||
title='Printer Statistics'
|
||||
icon={null}
|
||||
canCollapse={false}
|
||||
active={collapseState.printerStats}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('printerStats', isActive)
|
||||
}
|
||||
className='no-t-padding-collapse'
|
||||
collapseKey='printerStats'
|
||||
>
|
||||
<Flex
|
||||
justify='flex-start'
|
||||
gap='middle'
|
||||
wrap='wrap'
|
||||
align='flex-start'
|
||||
>
|
||||
<StatsDisplay objectType='printer' />
|
||||
</Flex>
|
||||
</InfoCollapse>
|
||||
|
||||
<InfoCollapse
|
||||
title='Job Statistics'
|
||||
icon={null}
|
||||
canCollapse={false}
|
||||
active={collapseState.jobStats}
|
||||
onToggle={(isActive) => updateCollapseState('jobStats', isActive)}
|
||||
className='no-t-padding-collapse'
|
||||
collapseKey='jobStats'
|
||||
>
|
||||
<Flex
|
||||
justify='flex-start'
|
||||
gap='middle'
|
||||
wrap='wrap'
|
||||
align='flex-start'
|
||||
>
|
||||
<StatsDisplay objectType='job' />
|
||||
</Flex>
|
||||
</InfoCollapse>
|
||||
|
||||
<Flex gap='large' wrap='wrap'>
|
||||
<Flex flex={1} vertical style={{ minWidth: '300px' }} gap='large'>
|
||||
<InfoCollapse
|
||||
title='Printer History'
|
||||
icon={null}
|
||||
active={collapseState.printerHistory}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('printerHistory', isActive)
|
||||
}
|
||||
collapseKey='printerHistory'
|
||||
canCollapse={false}
|
||||
>
|
||||
<ModelHistoryDisplay objectType='printer' />
|
||||
</InfoCollapse>
|
||||
<InfoCollapse
|
||||
title='Online Printers'
|
||||
icon={null}
|
||||
active={collapseState.onlinePrinters}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('onlinePrinters', isActive)
|
||||
}
|
||||
collapseKey='onlinePrinters'
|
||||
canCollapse={false}
|
||||
>
|
||||
<ObjectTable type='printer' masterFilter={{ online: true }} />
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
<Flex flex={1} vertical style={{ minWidth: '300px' }} gap='large'>
|
||||
<InfoCollapse
|
||||
title='Job History'
|
||||
icon={null}
|
||||
active={collapseState.jobHistory}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('jobHistory', isActive)
|
||||
}
|
||||
canCollapse={false}
|
||||
collapseKey='jobHistory'
|
||||
>
|
||||
<ModelHistoryDisplay objectType='job' />
|
||||
</InfoCollapse>
|
||||
<InfoCollapse
|
||||
title='Queued Jobs'
|
||||
icon={null}
|
||||
active={collapseState.queuedJobs}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('queuedJobs', isActive)
|
||||
}
|
||||
canCollapse={false}
|
||||
collapseKey='queuedJobs'
|
||||
>
|
||||
<ObjectTable type='job' masterFilter={{ state: 'queued' }} />
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ScrollBox>
|
||||
</Flex>
|
||||
<DashboardOverviewPage
|
||||
pageName='ProductionOverview'
|
||||
collapseDefaults={collapseDefaults}
|
||||
sections={sections}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,243 +1,146 @@
|
||||
import { Flex } from 'antd'
|
||||
import useCollapseState from '../hooks/useCollapseState.jsx'
|
||||
import DashboardOverviewPage from '../common/DashboardOverviewPage'
|
||||
import StatsDisplay from '../common/StatsDisplay'
|
||||
import ModelHistoryDisplay from '../common/ModelHistoryDisplay'
|
||||
import InfoCollapse from '../common/InfoCollapse'
|
||||
import ScrollBox from '../common/ScrollBox'
|
||||
import ObjectTable from '../common/ObjectTable'
|
||||
|
||||
const collapseDefaults = {
|
||||
salesOrderStats: true,
|
||||
listingStats: true,
|
||||
marketplaceStats: true,
|
||||
clientStats: true,
|
||||
salesOrderHistory: true,
|
||||
confirmedSalesOrders: true,
|
||||
listingHistory: true,
|
||||
draftListings: true,
|
||||
marketplaceHistory: true,
|
||||
readyMarketplaces: true,
|
||||
clientHistory: true,
|
||||
activeClients: true
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{
|
||||
key: 'salesOrderStats',
|
||||
title: 'Sales Order Statistics',
|
||||
className: 'no-t-padding-collapse',
|
||||
content: <StatsDisplay objectType='salesOrder' />
|
||||
},
|
||||
{
|
||||
key: 'listingStats',
|
||||
title: 'Listing Statistics',
|
||||
className: 'no-t-padding-collapse',
|
||||
content: <StatsDisplay objectType='listing' />
|
||||
},
|
||||
{
|
||||
key: 'marketplaceStats',
|
||||
title: 'Marketplace Statistics',
|
||||
className: 'no-t-padding-collapse',
|
||||
content: <StatsDisplay objectType='marketplace' />
|
||||
},
|
||||
{
|
||||
key: 'clientStats',
|
||||
title: 'Client Statistics',
|
||||
className: 'no-t-padding-collapse',
|
||||
content: <StatsDisplay objectType='client' />
|
||||
},
|
||||
{
|
||||
key: 'salesOrderAndListingHistoryRow',
|
||||
type: 'row',
|
||||
columns: [
|
||||
[
|
||||
{
|
||||
key: 'salesOrderHistory',
|
||||
title: 'Sales Order History',
|
||||
content: <ModelHistoryDisplay objectType='salesOrder' />
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'listingHistory',
|
||||
title: 'Listing History',
|
||||
content: <ModelHistoryDisplay objectType='listing' />
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'confirmedSalesOrdersAndDraftListingsRow',
|
||||
type: 'row',
|
||||
columns: [
|
||||
[
|
||||
{
|
||||
key: 'confirmedSalesOrders',
|
||||
title: 'Confirmed Sales Orders',
|
||||
content: (
|
||||
<ObjectTable
|
||||
type='salesOrder'
|
||||
masterFilter={{ state: 'confirmed' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'draftListings',
|
||||
title: 'Draft Listings',
|
||||
content: (
|
||||
<ObjectTable type='listing' masterFilter={{ state: 'draft' }} />
|
||||
)
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'marketplaceAndClientHistoryRow',
|
||||
type: 'row',
|
||||
columns: [
|
||||
[
|
||||
{
|
||||
key: 'marketplaceHistory',
|
||||
title: 'Marketplace History',
|
||||
content: <ModelHistoryDisplay objectType='marketplace' />
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'clientHistory',
|
||||
title: 'Client History',
|
||||
content: <ModelHistoryDisplay objectType='client' />
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'readyMarketplacesAndActiveClientsRow',
|
||||
type: 'row',
|
||||
columns: [
|
||||
[
|
||||
{
|
||||
key: 'readyMarketplaces',
|
||||
title: 'Ready Marketplaces',
|
||||
content: (
|
||||
<ObjectTable type='marketplace' masterFilter={{ state: 'ready' }} />
|
||||
)
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'activeClients',
|
||||
title: 'Active Clients',
|
||||
content: <ObjectTable type='client' masterFilter={{ active: true }} />
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const SalesOverview = () => {
|
||||
const [collapseState, updateCollapseState] = useCollapseState(
|
||||
'SalesOverview',
|
||||
{
|
||||
salesOrderStats: true,
|
||||
listingStats: true,
|
||||
marketplaceStats: true,
|
||||
clientStats: true,
|
||||
salesOrderHistory: true,
|
||||
confirmedSalesOrders: true,
|
||||
listingHistory: true,
|
||||
draftListings: true,
|
||||
marketplaceHistory: true,
|
||||
readyMarketplaces: true,
|
||||
clientHistory: true,
|
||||
activeClients: true
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
<ScrollBox>
|
||||
<Flex vertical gap={'large'}>
|
||||
<InfoCollapse
|
||||
title='Sales Order Statistics'
|
||||
icon={null}
|
||||
canCollapse={false}
|
||||
active={collapseState.salesOrderStats}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('salesOrderStats', isActive)
|
||||
}
|
||||
className='no-t-padding-collapse'
|
||||
collapseKey='salesOrderStats'
|
||||
>
|
||||
<Flex
|
||||
justify='flex-start'
|
||||
gap='middle'
|
||||
wrap='wrap'
|
||||
align='flex-start'
|
||||
>
|
||||
<StatsDisplay objectType='salesOrder' />
|
||||
</Flex>
|
||||
</InfoCollapse>
|
||||
|
||||
<InfoCollapse
|
||||
title='Listing Statistics'
|
||||
icon={null}
|
||||
canCollapse={false}
|
||||
active={collapseState.listingStats}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('listingStats', isActive)
|
||||
}
|
||||
className='no-t-padding-collapse'
|
||||
collapseKey='listingStats'
|
||||
>
|
||||
<Flex
|
||||
justify='flex-start'
|
||||
gap='middle'
|
||||
wrap='wrap'
|
||||
align='flex-start'
|
||||
>
|
||||
<StatsDisplay objectType='listing' />
|
||||
</Flex>
|
||||
</InfoCollapse>
|
||||
|
||||
<InfoCollapse
|
||||
title='Marketplace Statistics'
|
||||
icon={null}
|
||||
canCollapse={false}
|
||||
active={collapseState.marketplaceStats}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('marketplaceStats', isActive)
|
||||
}
|
||||
className='no-t-padding-collapse'
|
||||
collapseKey='marketplaceStats'
|
||||
>
|
||||
<Flex
|
||||
justify='flex-start'
|
||||
gap='middle'
|
||||
wrap='wrap'
|
||||
align='flex-start'
|
||||
>
|
||||
<StatsDisplay objectType='marketplace' />
|
||||
</Flex>
|
||||
</InfoCollapse>
|
||||
|
||||
<InfoCollapse
|
||||
title='Client Statistics'
|
||||
icon={null}
|
||||
canCollapse={false}
|
||||
active={collapseState.clientStats}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('clientStats', isActive)
|
||||
}
|
||||
className='no-t-padding-collapse'
|
||||
collapseKey='clientStats'
|
||||
>
|
||||
<Flex
|
||||
justify='flex-start'
|
||||
gap='middle'
|
||||
wrap='wrap'
|
||||
align='flex-start'
|
||||
>
|
||||
<StatsDisplay objectType='client' />
|
||||
</Flex>
|
||||
</InfoCollapse>
|
||||
|
||||
<Flex gap='large' wrap='wrap'>
|
||||
<Flex flex={1} vertical style={{ minWidth: '300px' }} gap='large'>
|
||||
<InfoCollapse
|
||||
title='Sales Order History'
|
||||
icon={null}
|
||||
active={collapseState.salesOrderHistory}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('salesOrderHistory', isActive)
|
||||
}
|
||||
collapseKey='salesOrderHistory'
|
||||
canCollapse={false}
|
||||
>
|
||||
<ModelHistoryDisplay objectType='salesOrder' />
|
||||
</InfoCollapse>
|
||||
<InfoCollapse
|
||||
title='Confirmed Sales Orders'
|
||||
icon={null}
|
||||
active={collapseState.confirmedSalesOrders}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('confirmedSalesOrders', isActive)
|
||||
}
|
||||
collapseKey='confirmedSalesOrders'
|
||||
canCollapse={false}
|
||||
>
|
||||
<ObjectTable
|
||||
type='salesOrder'
|
||||
masterFilter={{ state: 'confirmed' }}
|
||||
/>
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
<Flex flex={1} vertical style={{ minWidth: '300px' }} gap='large'>
|
||||
<InfoCollapse
|
||||
title='Listing History'
|
||||
icon={null}
|
||||
active={collapseState.listingHistory}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('listingHistory', isActive)
|
||||
}
|
||||
canCollapse={false}
|
||||
collapseKey='listingHistory'
|
||||
>
|
||||
<ModelHistoryDisplay objectType='listing' />
|
||||
</InfoCollapse>
|
||||
<InfoCollapse
|
||||
title='Draft Listings'
|
||||
icon={null}
|
||||
active={collapseState.draftListings}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('draftListings', isActive)
|
||||
}
|
||||
canCollapse={false}
|
||||
collapseKey='draftListings'
|
||||
>
|
||||
<ObjectTable type='listing' masterFilter={{ state: 'draft' }} />
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex gap='large' wrap='wrap'>
|
||||
<Flex flex={1} vertical style={{ minWidth: '300px' }} gap='large'>
|
||||
<InfoCollapse
|
||||
title='Marketplace History'
|
||||
icon={null}
|
||||
active={collapseState.marketplaceHistory}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('marketplaceHistory', isActive)
|
||||
}
|
||||
collapseKey='marketplaceHistory'
|
||||
canCollapse={false}
|
||||
>
|
||||
<ModelHistoryDisplay objectType='marketplace' />
|
||||
</InfoCollapse>
|
||||
<InfoCollapse
|
||||
title='Ready Marketplaces'
|
||||
icon={null}
|
||||
active={collapseState.readyMarketplaces}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('readyMarketplaces', isActive)
|
||||
}
|
||||
collapseKey='readyMarketplaces'
|
||||
canCollapse={false}
|
||||
>
|
||||
<ObjectTable
|
||||
type='marketplace'
|
||||
masterFilter={{ state: 'ready' }}
|
||||
/>
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
<Flex flex={1} vertical style={{ minWidth: '300px' }} gap='large'>
|
||||
<InfoCollapse
|
||||
title='Client History'
|
||||
icon={null}
|
||||
active={collapseState.clientHistory}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('clientHistory', isActive)
|
||||
}
|
||||
canCollapse={false}
|
||||
collapseKey='clientHistory'
|
||||
>
|
||||
<ModelHistoryDisplay objectType='client' />
|
||||
</InfoCollapse>
|
||||
<InfoCollapse
|
||||
title='Active Clients'
|
||||
icon={null}
|
||||
active={collapseState.activeClients}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('activeClients', isActive)
|
||||
}
|
||||
canCollapse={false}
|
||||
collapseKey='activeClients'
|
||||
>
|
||||
<ObjectTable type='client' masterFilter={{ active: true }} />
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ScrollBox>
|
||||
</Flex>
|
||||
<DashboardOverviewPage
|
||||
pageName='SalesOverview'
|
||||
collapseDefaults={collapseDefaults}
|
||||
sections={sections}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
43
src/components/Dashboard/common/ActionsButton.jsx
Normal file
43
src/components/Dashboard/common/ActionsButton.jsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { Dropdown, Button } from 'antd'
|
||||
import PropTypes from 'prop-types'
|
||||
import KeyboardShortcut from './KeyboardShortcut'
|
||||
|
||||
const ActionsButton = ({
|
||||
menu = { items: [] },
|
||||
onOpenModal,
|
||||
disabled = false,
|
||||
buttonProps = {},
|
||||
...dropdownProps
|
||||
}) => {
|
||||
const handleOpenModal = () => {
|
||||
if (disabled || !onOpenModal) return
|
||||
onOpenModal()
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardShortcut shortcut='alt+a' onTrigger={handleOpenModal}>
|
||||
<Dropdown
|
||||
menu={menu}
|
||||
trigger={onOpenModal ? undefined : ['click']}
|
||||
{...dropdownProps}
|
||||
>
|
||||
<Button
|
||||
{...buttonProps}
|
||||
disabled={disabled}
|
||||
onClick={onOpenModal ? handleOpenModal : undefined}
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</KeyboardShortcut>
|
||||
)
|
||||
}
|
||||
|
||||
ActionsButton.propTypes = {
|
||||
menu: PropTypes.object,
|
||||
onOpenModal: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
buttonProps: PropTypes.object
|
||||
}
|
||||
|
||||
export default ActionsButton
|
||||
251
src/components/Dashboard/common/DashboardOverviewFlexColumns.jsx
Normal file
251
src/components/Dashboard/common/DashboardOverviewFlexColumns.jsx
Normal file
@ -0,0 +1,251 @@
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { Button, Flex } from 'antd'
|
||||
import PropTypes from 'prop-types'
|
||||
import XMarkIcon from '../../Icons/XMarkIcon.jsx'
|
||||
import {
|
||||
COLUMN_PROPORTION_STEP,
|
||||
normalizeColumnProportions
|
||||
} from '../hooks/usePageLayout.jsx'
|
||||
|
||||
const ColumnSeparator = ({ active, onPointerDown }) => (
|
||||
<div
|
||||
role='separator'
|
||||
aria-orientation='vertical'
|
||||
className={`overview-flex-columns-separator ${active ? 'is-active' : ''}`}
|
||||
onPointerDown={onPointerDown}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
>
|
||||
<span className='overview-flex-columns-separator-line' />
|
||||
<span className='overview-flex-columns-separator-handle' />
|
||||
</div>
|
||||
)
|
||||
|
||||
ColumnSeparator.propTypes = {
|
||||
active: PropTypes.bool,
|
||||
onPointerDown: PropTypes.func
|
||||
}
|
||||
|
||||
const DashboardOverviewFlexColumns = ({
|
||||
dragHandle = null,
|
||||
onRemove,
|
||||
isLayoutEditing = false,
|
||||
isDragOver = false,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragLeave,
|
||||
proportions,
|
||||
onProportionsChange,
|
||||
children
|
||||
}) => {
|
||||
const isEmpty = Children.count(children) === 0
|
||||
const showToolbar = isLayoutEditing && (dragHandle || onRemove)
|
||||
const childList = useMemo(() => Children.toArray(children), [children])
|
||||
const resolvedProportions = useMemo(
|
||||
() => normalizeColumnProportions(childList.length, proportions),
|
||||
[childList.length, proportions]
|
||||
)
|
||||
const dragRef = useRef(null)
|
||||
const onProportionsChangeRef = useRef(onProportionsChange)
|
||||
const [activeSeparator, setActiveSeparator] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
onProportionsChangeRef.current = onProportionsChange
|
||||
}, [onProportionsChange])
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
const drag = dragRef.current
|
||||
if (drag?.moveListener) {
|
||||
document.removeEventListener('pointermove', drag.moveListener)
|
||||
}
|
||||
if (drag?.upListener) {
|
||||
document.removeEventListener('pointerup', drag.upListener)
|
||||
document.removeEventListener('pointercancel', drag.upListener)
|
||||
}
|
||||
document.body.style.removeProperty('cursor')
|
||||
document.body.style.removeProperty('user-select')
|
||||
dragRef.current = null
|
||||
setActiveSeparator(null)
|
||||
}, [])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
const drag = dragRef.current
|
||||
if (drag?.moveListener) {
|
||||
document.removeEventListener('pointermove', drag.moveListener)
|
||||
}
|
||||
if (drag?.upListener) {
|
||||
document.removeEventListener('pointerup', drag.upListener)
|
||||
document.removeEventListener('pointercancel', drag.upListener)
|
||||
}
|
||||
document.body.style.removeProperty('cursor')
|
||||
document.body.style.removeProperty('user-select')
|
||||
dragRef.current = null
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event, index) => {
|
||||
if (!isLayoutEditing || !onProportionsChange) return
|
||||
if (event.button != null && event.button !== 0) return
|
||||
|
||||
const item = event.currentTarget.closest('.overview-flex-columns-item')
|
||||
const row = item?.parentElement
|
||||
if (!item || !row) return
|
||||
|
||||
const items = [...row.children].filter((el) =>
|
||||
el.classList.contains('overview-flex-columns-item')
|
||||
)
|
||||
const rightItem = items[index + 1]
|
||||
if (!rightItem) return
|
||||
|
||||
const pairWidth =
|
||||
rightItem.getBoundingClientRect().right -
|
||||
item.getBoundingClientRect().left
|
||||
if (pairWidth <= 0) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const startLeft = resolvedProportions[index]
|
||||
const moveListener = (moveEvent) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag) return
|
||||
const deltaPct =
|
||||
((moveEvent.clientX - drag.startX) / drag.pairWidth) * drag.pairTotal
|
||||
let nextLeft =
|
||||
Math.round((drag.startLeft + deltaPct) / COLUMN_PROPORTION_STEP) *
|
||||
COLUMN_PROPORTION_STEP
|
||||
nextLeft = Math.max(
|
||||
COLUMN_PROPORTION_STEP,
|
||||
Math.min(drag.pairTotal - COLUMN_PROPORTION_STEP, nextLeft)
|
||||
)
|
||||
if (nextLeft === drag.lastLeft) return
|
||||
|
||||
drag.lastLeft = nextLeft
|
||||
const next = [...drag.proportions]
|
||||
next[drag.index] = nextLeft
|
||||
next[drag.index + 1] = drag.pairTotal - nextLeft
|
||||
onProportionsChangeRef.current?.(next)
|
||||
}
|
||||
const upListener = (upEvent) => {
|
||||
moveListener(upEvent)
|
||||
endDrag()
|
||||
}
|
||||
|
||||
dragRef.current = {
|
||||
index,
|
||||
startX: event.clientX,
|
||||
startLeft,
|
||||
lastLeft: startLeft,
|
||||
pairTotal: startLeft + resolvedProportions[index + 1],
|
||||
pairWidth,
|
||||
proportions: resolvedProportions,
|
||||
moveListener,
|
||||
upListener
|
||||
}
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
document.addEventListener('pointermove', moveListener)
|
||||
document.addEventListener('pointerup', upListener)
|
||||
document.addEventListener('pointercancel', upListener)
|
||||
setActiveSeparator(index)
|
||||
},
|
||||
[endDrag, isLayoutEditing, onProportionsChange, resolvedProportions]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`overview-flex-columns ${
|
||||
isLayoutEditing ? 'is-editing' : ''
|
||||
} ${isDragOver ? 'is-drag-over' : ''} ${
|
||||
isEmpty && isLayoutEditing ? 'is-empty' : ''
|
||||
} ${activeSeparator != null ? 'is-resizing' : ''}`}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onDragLeave={onDragLeave}
|
||||
>
|
||||
{showToolbar && (
|
||||
<Flex
|
||||
justify='space-between'
|
||||
align='center'
|
||||
className='overview-flex-columns-toolbar'
|
||||
>
|
||||
{dragHandle || <span />}
|
||||
{onRemove ? (
|
||||
<Button
|
||||
type='text'
|
||||
size='small'
|
||||
icon={<XMarkIcon />}
|
||||
onClick={onRemove}
|
||||
aria-label='Remove columns'
|
||||
/>
|
||||
) : null}
|
||||
</Flex>
|
||||
)}
|
||||
<Flex
|
||||
gap='large'
|
||||
align='stretch'
|
||||
className='overview-flex-columns-row'
|
||||
wrap='nowrap'
|
||||
>
|
||||
{isEmpty && isLayoutEditing ? (
|
||||
<div className='overview-flex-columns-empty'>Drop sections here</div>
|
||||
) : (
|
||||
childList.map((child, index) => {
|
||||
if (!isValidElement(child)) return child
|
||||
const proportion = resolvedProportions[index] ?? 0
|
||||
const showSeparator =
|
||||
isLayoutEditing && index < childList.length - 1
|
||||
return cloneElement(child, {
|
||||
style: {
|
||||
...child.props.style,
|
||||
flex: `${proportion} 1 0`,
|
||||
minWidth: 0,
|
||||
position: 'relative'
|
||||
},
|
||||
children: (
|
||||
<>
|
||||
{child.props.children}
|
||||
{showSeparator ? (
|
||||
<ColumnSeparator
|
||||
active={activeSeparator === index}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown(event, index)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
})
|
||||
})
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
DashboardOverviewFlexColumns.propTypes = {
|
||||
dragHandle: PropTypes.node,
|
||||
onRemove: PropTypes.func,
|
||||
isLayoutEditing: PropTypes.bool,
|
||||
isDragOver: PropTypes.bool,
|
||||
onDragOver: PropTypes.func,
|
||||
onDrop: PropTypes.func,
|
||||
onDragLeave: PropTypes.func,
|
||||
proportions: PropTypes.arrayOf(PropTypes.number),
|
||||
onProportionsChange: PropTypes.func,
|
||||
children: PropTypes.node
|
||||
}
|
||||
|
||||
export default DashboardOverviewFlexColumns
|
||||
997
src/components/Dashboard/common/DashboardOverviewPage.jsx
Normal file
997
src/components/Dashboard/common/DashboardOverviewPage.jsx
Normal file
@ -0,0 +1,997 @@
|
||||
import {
|
||||
cloneElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { Flex, Space } from 'antd'
|
||||
import PropTypes from 'prop-types'
|
||||
import { HolderOutlined } from '@ant-design/icons'
|
||||
import ActionsButton from './ActionsButton'
|
||||
import ViewButton from './ViewButton'
|
||||
import EditButtons from './EditButtons'
|
||||
import ScrollBox from './ScrollBox'
|
||||
import InfoCollapse from './InfoCollapse'
|
||||
import StatsDisplay, { StatsViewButton } from './StatsDisplay'
|
||||
import ModelHistoryDisplay from './ModelHistoryDisplay'
|
||||
import ObjectTable from './ObjectTable'
|
||||
import DashboardOverviewFlexColumns from './DashboardOverviewFlexColumns'
|
||||
import useCollapseState from '../hooks/useCollapseState'
|
||||
import usePageLayout, {
|
||||
mergeOrder,
|
||||
normalizeColumnProportions
|
||||
} from '../hooks/usePageLayout'
|
||||
import { OverviewLayoutContext } from '../context/OverviewLayoutContext'
|
||||
|
||||
const flattenOverviewSections = (sectionList = []) => {
|
||||
const leaves = {}
|
||||
const defaultFlexColumns = {}
|
||||
const defaultSectionOrder = []
|
||||
|
||||
for (const section of sectionList) {
|
||||
if (section?.type === 'row') {
|
||||
const childKeys = []
|
||||
for (const column of section.columns || []) {
|
||||
for (const item of column) {
|
||||
if (item?.key) {
|
||||
leaves[item.key] = item
|
||||
childKeys.push(item.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
defaultFlexColumns[section.key] = childKeys
|
||||
defaultSectionOrder.push(section.key)
|
||||
} else if (section?.key) {
|
||||
leaves[section.key] = section
|
||||
defaultSectionOrder.push(section.key)
|
||||
}
|
||||
}
|
||||
|
||||
return { leaves, defaultSectionOrder, defaultFlexColumns }
|
||||
}
|
||||
|
||||
const collectViewItems = (sectionList = []) => {
|
||||
const items = []
|
||||
const visit = (section) => {
|
||||
if (section?.type === 'row') {
|
||||
section.columns?.forEach((column) => column.forEach(visit))
|
||||
return
|
||||
}
|
||||
if (section?.key && section?.title) {
|
||||
items.push({ key: section.key, label: section.title })
|
||||
}
|
||||
}
|
||||
sectionList.forEach(visit)
|
||||
return items
|
||||
}
|
||||
|
||||
const copyLayout = (layout) => ({
|
||||
sectionOrder: [...(layout?.sectionOrder || [])],
|
||||
flexColumns: Object.fromEntries(
|
||||
Object.entries(layout?.flexColumns || {}).map(([key, value]) => [
|
||||
key,
|
||||
Array.isArray(value) ? [...value] : []
|
||||
])
|
||||
),
|
||||
statsOrder: Object.fromEntries(
|
||||
Object.entries(layout?.statsOrder || {}).map(([key, value]) => [
|
||||
key,
|
||||
Array.isArray(value) ? [...value] : value
|
||||
])
|
||||
),
|
||||
statsVisibility: Object.fromEntries(
|
||||
Object.entries(layout?.statsVisibility || {}).map(([key, value]) => [
|
||||
key,
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? { ...value }
|
||||
: {}
|
||||
])
|
||||
),
|
||||
sectionHeights: { ...(layout?.sectionHeights || {}) },
|
||||
flexColumnProportions: Object.fromEntries(
|
||||
Object.entries(layout?.flexColumnProportions || {}).map(([key, value]) => [
|
||||
key,
|
||||
Array.isArray(value) ? [...value] : []
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
const createFlexColumnsId = () =>
|
||||
`flexColumns-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
const DEFAULT_HISTORY_HEIGHT = 400
|
||||
const DEFAULT_TABLE_HEIGHT = 360
|
||||
const EMPTY_ORDER = []
|
||||
const EMPTY_COLUMNS = {}
|
||||
|
||||
const getVerticalEdgeInsert = (element, clientY) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const edge = Math.min(48, rect.height / 3)
|
||||
const y = clientY - rect.top
|
||||
if (y <= edge) return { insertBefore: true }
|
||||
if (y >= rect.height - edge) return { insertBefore: false }
|
||||
return null
|
||||
}
|
||||
|
||||
const DashboardOverviewPage = ({
|
||||
pageName,
|
||||
collapseDefaults = {},
|
||||
viewItems,
|
||||
actionMenu,
|
||||
onOpenActionsModal,
|
||||
sections = []
|
||||
}) => {
|
||||
const [savedCollapseState, updateCollapseState] = useCollapseState(
|
||||
pageName,
|
||||
collapseDefaults
|
||||
)
|
||||
const collapseState = useMemo(
|
||||
() => ({ ...collapseDefaults, ...savedCollapseState }),
|
||||
[collapseDefaults, savedCollapseState]
|
||||
)
|
||||
|
||||
const { leaves, defaultSectionOrder, defaultFlexColumns } = useMemo(
|
||||
() => flattenOverviewSections(sections),
|
||||
[sections]
|
||||
)
|
||||
const [savedLayout, updatePageLayout] = usePageLayout(pageName, {
|
||||
sectionOrder: defaultSectionOrder,
|
||||
flexColumns: defaultFlexColumns,
|
||||
statsOrder: {},
|
||||
statsVisibility: {}
|
||||
})
|
||||
|
||||
const [isLayoutEditing, setIsLayoutEditing] = useState(false)
|
||||
const [draftLayout, setDraftLayout] = useState(null)
|
||||
const [draggedItem, setDraggedItem] = useState(null)
|
||||
const [dropTarget, setDropTarget] = useState(null)
|
||||
const [liveHeights, setLiveHeights] = useState({})
|
||||
const scrollRef = useRef(null)
|
||||
const dragYRef = useRef(null)
|
||||
const scrollRafRef = useRef(0)
|
||||
|
||||
const displayLayout =
|
||||
isLayoutEditing && draftLayout ? draftLayout : savedLayout
|
||||
const resolvedViewItems = viewItems || collectViewItems(sections)
|
||||
const sectionsByKey = leaves
|
||||
const orderedSectionKeys = useMemo(
|
||||
() => displayLayout.sectionOrder || EMPTY_ORDER,
|
||||
[displayLayout.sectionOrder]
|
||||
)
|
||||
const flexColumns = useMemo(
|
||||
() => displayLayout.flexColumns || EMPTY_COLUMNS,
|
||||
[displayLayout.flexColumns]
|
||||
)
|
||||
const isGroupKey = (key) =>
|
||||
Object.prototype.hasOwnProperty.call(flexColumns, key)
|
||||
|
||||
const startEditing = useCallback(() => {
|
||||
setDraftLayout(copyLayout(savedLayout))
|
||||
setIsLayoutEditing(true)
|
||||
}, [savedLayout])
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
setDraggedItem(null)
|
||||
setDropTarget(null)
|
||||
}, [])
|
||||
|
||||
const getSectionHeight = useCallback(
|
||||
(key, fallback) => {
|
||||
if (liveHeights[key] != null) return liveHeights[key]
|
||||
const saved = savedLayout.sectionHeights?.[key]
|
||||
return typeof saved === 'number' ? saved : fallback
|
||||
},
|
||||
[liveHeights, savedLayout.sectionHeights]
|
||||
)
|
||||
|
||||
const handleSectionHeightChange = useCallback(
|
||||
(key, height, persist) => {
|
||||
setLiveHeights((current) => ({ ...current, [key]: height }))
|
||||
if (!persist) return
|
||||
|
||||
const nextHeights = {
|
||||
...(savedLayout.sectionHeights || {}),
|
||||
[key]: height
|
||||
}
|
||||
setDraftLayout((current) => {
|
||||
if (!current) return current
|
||||
return { ...current, sectionHeights: nextHeights }
|
||||
})
|
||||
updatePageLayout({
|
||||
...savedLayout,
|
||||
sectionHeights: nextHeights
|
||||
})
|
||||
},
|
||||
[savedLayout, updatePageLayout]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggedItem) return undefined
|
||||
|
||||
const edgePx = 72
|
||||
const maxSpeed = 28
|
||||
|
||||
const stopScroll = () => {
|
||||
if (scrollRafRef.current) {
|
||||
cancelAnimationFrame(scrollRafRef.current)
|
||||
scrollRafRef.current = 0
|
||||
}
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
scrollRafRef.current = 0
|
||||
const y = dragYRef.current
|
||||
const scrollEl = scrollRef.current
|
||||
if (y == null || !scrollEl) return
|
||||
|
||||
const rect = scrollEl.getBoundingClientRect()
|
||||
let dy = 0
|
||||
if (y >= rect.bottom - edgePx) {
|
||||
dy = Math.min(maxSpeed, Math.ceil((y - (rect.bottom - edgePx)) / 2) + 6)
|
||||
} else if (y <= rect.top + edgePx) {
|
||||
dy = -Math.min(maxSpeed, Math.ceil((rect.top + edgePx - y) / 2) + 6)
|
||||
}
|
||||
|
||||
if (dy === 0) return
|
||||
|
||||
const maxScroll = scrollEl.scrollHeight - scrollEl.clientHeight
|
||||
const next = Math.max(0, Math.min(maxScroll, scrollEl.scrollTop + dy))
|
||||
if (next !== scrollEl.scrollTop) {
|
||||
scrollEl.scrollTop = next
|
||||
scrollRafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
}
|
||||
|
||||
const onDragOver = (event) => {
|
||||
dragYRef.current = event.clientY
|
||||
if (!scrollRafRef.current) {
|
||||
scrollRafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
dragYRef.current = null
|
||||
stopScroll()
|
||||
}
|
||||
|
||||
document.addEventListener('dragover', onDragOver)
|
||||
document.addEventListener('dragend', onDragEnd)
|
||||
document.addEventListener('drop', onDragEnd)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dragover', onDragOver)
|
||||
document.removeEventListener('dragend', onDragEnd)
|
||||
document.removeEventListener('drop', onDragEnd)
|
||||
onDragEnd()
|
||||
}
|
||||
}, [draggedItem])
|
||||
|
||||
const cancelEditing = useCallback(() => {
|
||||
setDraftLayout(null)
|
||||
setIsLayoutEditing(false)
|
||||
clearDragState()
|
||||
}, [clearDragState])
|
||||
|
||||
const handleUpdate = useCallback(() => {
|
||||
if (draftLayout) {
|
||||
updatePageLayout(draftLayout)
|
||||
}
|
||||
setDraftLayout(null)
|
||||
setIsLayoutEditing(false)
|
||||
clearDragState()
|
||||
}, [draftLayout, updatePageLayout, clearDragState])
|
||||
|
||||
const reorderStats = useCallback(
|
||||
(objectType, newOrder) => {
|
||||
if (!isLayoutEditing) return
|
||||
setDraftLayout((current) => {
|
||||
const base = current || copyLayout(savedLayout)
|
||||
return {
|
||||
...base,
|
||||
statsOrder: {
|
||||
...base.statsOrder,
|
||||
[objectType]: newOrder
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
[isLayoutEditing, savedLayout]
|
||||
)
|
||||
|
||||
const updateStatsVisibility = useCallback(
|
||||
(objectType, key, value) => {
|
||||
if (!isLayoutEditing) return
|
||||
setDraftLayout((current) => {
|
||||
const base = current || copyLayout(savedLayout)
|
||||
return {
|
||||
...base,
|
||||
statsVisibility: {
|
||||
...base.statsVisibility,
|
||||
[objectType]: {
|
||||
...(base.statsVisibility?.[objectType] || {}),
|
||||
[key]: value
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
[isLayoutEditing, savedLayout]
|
||||
)
|
||||
|
||||
const layoutContextValue = useMemo(
|
||||
() => ({
|
||||
isLayoutEditing,
|
||||
getStatsOrder: (objectType, defaultOrder = []) =>
|
||||
mergeOrder(displayLayout.statsOrder?.[objectType] || [], defaultOrder),
|
||||
reorderStats,
|
||||
getStatsVisibility: (objectType, defaultNames = []) => {
|
||||
const saved = displayLayout.statsVisibility?.[objectType] || {}
|
||||
return Object.fromEntries(
|
||||
defaultNames.map((name) => [name, saved[name] !== false])
|
||||
)
|
||||
},
|
||||
updateStatsVisibility
|
||||
}),
|
||||
[isLayoutEditing, displayLayout, reorderStats, updateStatsVisibility]
|
||||
)
|
||||
|
||||
const applyMove = useCallback(
|
||||
(dest) => {
|
||||
if (!draggedItem || !dest) return
|
||||
if (draggedItem.kind === 'flexGroup' && dest.parent !== 'root') return
|
||||
|
||||
setDraftLayout((current) => {
|
||||
const base = current || copyLayout(savedLayout)
|
||||
const originalSourceList =
|
||||
draggedItem.sourceParent === 'root'
|
||||
? base.sectionOrder
|
||||
: base.flexColumns?.[draggedItem.sourceParent] || []
|
||||
const sourceIndex = originalSourceList.indexOf(draggedItem.key)
|
||||
if (sourceIndex === -1) return current || base
|
||||
|
||||
const originalDestList =
|
||||
dest.parent === 'root'
|
||||
? base.sectionOrder
|
||||
: base.flexColumns?.[dest.parent] || []
|
||||
|
||||
let destIndex = dest.insertBefore ? dest.index : dest.index + 1
|
||||
if (
|
||||
draggedItem.sourceParent === dest.parent &&
|
||||
sourceIndex < destIndex
|
||||
) {
|
||||
destIndex -= 1
|
||||
}
|
||||
destIndex = Math.max(0, Math.min(destIndex, originalDestList.length))
|
||||
if (
|
||||
draggedItem.sourceParent === dest.parent &&
|
||||
destIndex === sourceIndex
|
||||
) {
|
||||
return current || base
|
||||
}
|
||||
|
||||
const sectionOrder = [...(base.sectionOrder || [])]
|
||||
const nextFlexColumns = Object.fromEntries(
|
||||
Object.entries(base.flexColumns || {}).map(([id, items]) => [
|
||||
id,
|
||||
[...items]
|
||||
])
|
||||
)
|
||||
|
||||
if (draggedItem.sourceParent === 'root') {
|
||||
const from = sectionOrder.indexOf(draggedItem.key)
|
||||
if (from !== -1) sectionOrder.splice(from, 1)
|
||||
} else {
|
||||
nextFlexColumns[draggedItem.sourceParent] = (
|
||||
nextFlexColumns[draggedItem.sourceParent] || []
|
||||
).filter((key) => key !== draggedItem.key)
|
||||
}
|
||||
|
||||
if (dest.parent === 'root') {
|
||||
const insertAt = Math.max(0, Math.min(destIndex, sectionOrder.length))
|
||||
sectionOrder.splice(insertAt, 0, draggedItem.key)
|
||||
} else {
|
||||
const destList = [...(nextFlexColumns[dest.parent] || [])]
|
||||
const insertAt = Math.max(0, Math.min(destIndex, destList.length))
|
||||
destList.splice(insertAt, 0, draggedItem.key)
|
||||
nextFlexColumns[dest.parent] = destList
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
sectionOrder,
|
||||
flexColumns: nextFlexColumns,
|
||||
flexColumnProportions: Object.fromEntries(
|
||||
Object.entries(nextFlexColumns).map(([id, items]) => [
|
||||
id,
|
||||
normalizeColumnProportions(
|
||||
items.length,
|
||||
base.flexColumnProportions?.[id]
|
||||
)
|
||||
])
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
[draggedItem, savedLayout]
|
||||
)
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e, item) => {
|
||||
if (!isLayoutEditing) return
|
||||
e.stopPropagation()
|
||||
const selector =
|
||||
item.sourceParent === 'root'
|
||||
? '.overview-section-wrapper'
|
||||
: '.overview-flex-columns-item'
|
||||
const wrapper = e.currentTarget.closest(selector)
|
||||
if (wrapper) {
|
||||
const rect = wrapper.getBoundingClientRect()
|
||||
e.dataTransfer.setDragImage(
|
||||
wrapper,
|
||||
e.clientX - rect.left,
|
||||
e.clientY - rect.top
|
||||
)
|
||||
}
|
||||
setDraggedItem(item)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/html', '')
|
||||
},
|
||||
[isLayoutEditing]
|
||||
)
|
||||
|
||||
const setRootDropFromGroupEdge = useCallback(
|
||||
(e, groupId) => {
|
||||
const wrapper = e.currentTarget.closest('.overview-section-wrapper')
|
||||
if (!wrapper) return false
|
||||
const edge = getVerticalEdgeInsert(wrapper, e.clientY)
|
||||
if (!edge) return false
|
||||
const rootIndex = orderedSectionKeys.indexOf(groupId)
|
||||
if (rootIndex === -1) return false
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDropTarget({
|
||||
parent: 'root',
|
||||
index: rootIndex,
|
||||
insertBefore: edge.insertBefore
|
||||
})
|
||||
return true
|
||||
},
|
||||
[orderedSectionKeys]
|
||||
)
|
||||
|
||||
const handleRootDragOver = useCallback(
|
||||
(e, index, key) => {
|
||||
if (!isLayoutEditing || !draggedItem) return
|
||||
const isGroup = Object.prototype.hasOwnProperty.call(flexColumns, key)
|
||||
if (draggedItem.kind === 'section' && isGroup) {
|
||||
const edge = getVerticalEdgeInsert(e.currentTarget, e.clientY)
|
||||
if (edge) {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDropTarget({
|
||||
parent: 'root',
|
||||
index,
|
||||
insertBefore: edge.insertBefore
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
setDropTarget({
|
||||
parent: 'root',
|
||||
index,
|
||||
insertBefore: e.clientY < rect.top + rect.height / 2
|
||||
})
|
||||
},
|
||||
[isLayoutEditing, draggedItem, flexColumns]
|
||||
)
|
||||
|
||||
const handleRootListDragOver = useCallback(
|
||||
(e) => {
|
||||
if (!isLayoutEditing || !draggedItem) return
|
||||
if (
|
||||
e.target instanceof Element &&
|
||||
e.target.closest('.overview-section-wrapper')
|
||||
) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
const wrappers = [...e.currentTarget.children].filter((el) =>
|
||||
el.classList.contains('overview-section-wrapper')
|
||||
)
|
||||
if (wrappers.length === 0) {
|
||||
setDropTarget({ parent: 'root', index: 0, insertBefore: true })
|
||||
return
|
||||
}
|
||||
const y = e.clientY
|
||||
for (let i = 0; i < wrappers.length; i++) {
|
||||
const rect = wrappers[i].getBoundingClientRect()
|
||||
if (y < rect.top + rect.height / 2) {
|
||||
const key = wrappers[i].dataset.overviewKey
|
||||
const nextIndex = orderedSectionKeys.indexOf(key)
|
||||
setDropTarget({
|
||||
parent: 'root',
|
||||
index: nextIndex === -1 ? i : nextIndex,
|
||||
insertBefore: true
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
const lastKey = wrappers[wrappers.length - 1].dataset.overviewKey
|
||||
const lastIndex = orderedSectionKeys.indexOf(lastKey)
|
||||
setDropTarget({
|
||||
parent: 'root',
|
||||
index: lastIndex === -1 ? wrappers.length - 1 : lastIndex,
|
||||
insertBefore: false
|
||||
})
|
||||
},
|
||||
[isLayoutEditing, draggedItem, orderedSectionKeys]
|
||||
)
|
||||
|
||||
const handleGroupItemDragOver = useCallback(
|
||||
(e, groupId, index) => {
|
||||
if (!isLayoutEditing || !draggedItem || draggedItem.kind !== 'section') {
|
||||
return
|
||||
}
|
||||
if (setRootDropFromGroupEdge(e, groupId)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDropTarget({
|
||||
parent: groupId,
|
||||
index,
|
||||
insertBefore: true
|
||||
})
|
||||
},
|
||||
[isLayoutEditing, draggedItem, setRootDropFromGroupEdge]
|
||||
)
|
||||
|
||||
const handleGroupContainerDragOver = useCallback(
|
||||
(e, groupId) => {
|
||||
if (!isLayoutEditing || !draggedItem || draggedItem.kind !== 'section') {
|
||||
return
|
||||
}
|
||||
if (setRootDropFromGroupEdge(e, groupId)) return
|
||||
if (
|
||||
e.target instanceof Element &&
|
||||
e.target.closest('.overview-flex-columns-item')
|
||||
) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
const items = flexColumns[groupId] || []
|
||||
setDropTarget({
|
||||
parent: groupId,
|
||||
index: items.length,
|
||||
insertBefore: true
|
||||
})
|
||||
},
|
||||
[isLayoutEditing, draggedItem, flexColumns, setRootDropFromGroupEdge]
|
||||
)
|
||||
|
||||
const handleDragLeave = useCallback((e) => {
|
||||
if (
|
||||
e.relatedTarget instanceof Node &&
|
||||
e.currentTarget.contains(e.relatedTarget)
|
||||
) {
|
||||
return
|
||||
}
|
||||
setDropTarget(null)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e, fallbackParent, fallbackIndex) => {
|
||||
if (!isLayoutEditing || !draggedItem) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const dest = dropTarget || {
|
||||
parent: fallbackParent,
|
||||
index: fallbackIndex,
|
||||
insertBefore: true
|
||||
}
|
||||
applyMove(dest)
|
||||
clearDragState()
|
||||
},
|
||||
[isLayoutEditing, draggedItem, dropTarget, applyMove, clearDragState]
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
clearDragState()
|
||||
}, [clearDragState])
|
||||
|
||||
const addColumns = useCallback(() => {
|
||||
setIsLayoutEditing(true)
|
||||
setDraftLayout((current) => {
|
||||
const base = current || copyLayout(savedLayout)
|
||||
const id = createFlexColumnsId()
|
||||
return {
|
||||
...base,
|
||||
sectionOrder: [...(base.sectionOrder || []), id],
|
||||
flexColumns: {
|
||||
...(base.flexColumns || {}),
|
||||
[id]: []
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [savedLayout])
|
||||
|
||||
const removeGroup = useCallback(
|
||||
(groupId) => {
|
||||
setDraftLayout((current) => {
|
||||
const base = current || copyLayout(savedLayout)
|
||||
const children = base.flexColumns?.[groupId] || []
|
||||
const sectionOrder = [...(base.sectionOrder || [])]
|
||||
const at = sectionOrder.indexOf(groupId)
|
||||
if (at === -1) return current || base
|
||||
sectionOrder.splice(at, 1, ...children)
|
||||
const nextFlexColumns = { ...(base.flexColumns || {}) }
|
||||
delete nextFlexColumns[groupId]
|
||||
const nextFlexColumnProportions = {
|
||||
...(base.flexColumnProportions || {})
|
||||
}
|
||||
delete nextFlexColumnProportions[groupId]
|
||||
return {
|
||||
...base,
|
||||
sectionOrder,
|
||||
flexColumns: nextFlexColumns,
|
||||
flexColumnProportions: nextFlexColumnProportions
|
||||
}
|
||||
})
|
||||
},
|
||||
[savedLayout]
|
||||
)
|
||||
|
||||
const updateFlexColumnProportions = useCallback(
|
||||
(groupId, nextProportions) => {
|
||||
if (!isLayoutEditing) return
|
||||
setDraftLayout((current) => {
|
||||
const base = current || copyLayout(savedLayout)
|
||||
return {
|
||||
...base,
|
||||
flexColumnProportions: {
|
||||
...(base.flexColumnProportions || {}),
|
||||
[groupId]: nextProportions
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
[isLayoutEditing, savedLayout]
|
||||
)
|
||||
|
||||
const renderDragHandle = (item) => (
|
||||
<span
|
||||
className='overview-drag-handle'
|
||||
draggable
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onDragStart={(e) => handleDragStart(e, item)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<HolderOutlined />
|
||||
</span>
|
||||
)
|
||||
|
||||
const isItemVisible = (key) => isLayoutEditing || collapseState[key] !== false
|
||||
|
||||
const wouldMove = (item, dest) => {
|
||||
if (!item || !dest) return false
|
||||
if (item.key === orderedSectionKeys[dest.index] && dest.parent === 'root') {
|
||||
const insertIndex = dest.insertBefore ? dest.index : dest.index + 1
|
||||
let targetIndex = insertIndex
|
||||
if (item.sourceParent === 'root' && item.sourceIndex < targetIndex) {
|
||||
targetIndex -= 1
|
||||
}
|
||||
return targetIndex !== item.sourceIndex
|
||||
}
|
||||
if (item.sourceParent === dest.parent) {
|
||||
const insertIndex = dest.insertBefore ? dest.index : dest.index + 1
|
||||
let targetIndex = insertIndex
|
||||
if (item.sourceIndex < targetIndex) {
|
||||
targetIndex -= 1
|
||||
}
|
||||
return targetIndex !== item.sourceIndex
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const getRootWrapperClass = (index, key) => {
|
||||
const isDragging =
|
||||
draggedItem?.sourceParent === 'root' && draggedItem.key === key
|
||||
let insertClass = ''
|
||||
if (
|
||||
dropTarget?.parent === 'root' &&
|
||||
dropTarget.index === index &&
|
||||
draggedItem &&
|
||||
draggedItem.key !== key &&
|
||||
wouldMove(draggedItem, dropTarget)
|
||||
) {
|
||||
insertClass = dropTarget.insertBefore
|
||||
? 'overview-insert-before'
|
||||
: 'overview-insert-after'
|
||||
}
|
||||
return `overview-section-wrapper ${isDragging ? 'overview-dragging' : ''} ${insertClass}`
|
||||
}
|
||||
|
||||
const getGroupItemClass = (groupId, index, key) => {
|
||||
const isDragging =
|
||||
draggedItem?.sourceParent === groupId && draggedItem.key === key
|
||||
const isDragOver =
|
||||
dropTarget?.parent === groupId &&
|
||||
dropTarget.index === index &&
|
||||
draggedItem?.kind === 'section' &&
|
||||
draggedItem.key !== key
|
||||
return `overview-flex-columns-item ${isDragging ? 'overview-dragging' : ''} ${
|
||||
isDragOver ? 'overview-drag-over' : ''
|
||||
}`
|
||||
}
|
||||
|
||||
const renderCollapse = (section, dragHandle = null) => {
|
||||
const statsObjectType =
|
||||
section.content?.type === StatsDisplay
|
||||
? section.content.props?.objectType
|
||||
: null
|
||||
const isHistory = section.content?.type === ModelHistoryDisplay
|
||||
const isTable = section.content?.type === ObjectTable
|
||||
const resizable = isHistory || isTable
|
||||
const defaultHeight = isHistory
|
||||
? DEFAULT_HISTORY_HEIGHT
|
||||
: DEFAULT_TABLE_HEIGHT
|
||||
const contentHeight = getSectionHeight(section.key, defaultHeight)
|
||||
const extra =
|
||||
isLayoutEditing && statsObjectType ? (
|
||||
<StatsViewButton objectType={statsObjectType} />
|
||||
) : null
|
||||
|
||||
let content = section.content
|
||||
if (resizable && content) {
|
||||
content = isHistory
|
||||
? cloneElement(content, { height: contentHeight })
|
||||
: cloneElement(content, { scrollHeight: `${contentHeight}px` })
|
||||
}
|
||||
|
||||
return (
|
||||
<InfoCollapse
|
||||
title={section.title}
|
||||
icon={section.icon ?? null}
|
||||
canCollapse={false}
|
||||
active={collapseState[section.key] !== false}
|
||||
onToggle={(isActive) => updateCollapseState(section.key, isActive)}
|
||||
className={section.className || ''}
|
||||
collapseKey={section.key}
|
||||
dragHandle={dragHandle}
|
||||
extra={extra}
|
||||
resizable={resizable}
|
||||
contentHeight={contentHeight}
|
||||
onContentHeightChange={
|
||||
resizable
|
||||
? (height, persist) =>
|
||||
handleSectionHeightChange(section.key, height, persist)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</InfoCollapse>
|
||||
)
|
||||
}
|
||||
|
||||
const actionMenuItems = useMemo(() => {
|
||||
const layoutItems = [
|
||||
{
|
||||
key: 'add-columns',
|
||||
label: 'Add columns',
|
||||
onClick: addColumns
|
||||
}
|
||||
]
|
||||
const extraItems = actionMenu?.items || []
|
||||
if (extraItems.length === 0) {
|
||||
return layoutItems
|
||||
}
|
||||
return [...layoutItems, { type: 'divider' }, ...extraItems]
|
||||
}, [actionMenu, addColumns])
|
||||
|
||||
return (
|
||||
<OverviewLayoutContext.Provider value={layoutContextValue}>
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
<Flex justify='space-between'>
|
||||
<Space size='small'>
|
||||
<ActionsButton
|
||||
menu={{ items: actionMenuItems }}
|
||||
onOpenModal={onOpenActionsModal}
|
||||
disabled={false}
|
||||
/>
|
||||
<ViewButton
|
||||
items={resolvedViewItems}
|
||||
visibleState={collapseState}
|
||||
updateVisibleState={updateCollapseState}
|
||||
/>
|
||||
</Space>
|
||||
<Space>
|
||||
<EditButtons
|
||||
isEditing={isLayoutEditing}
|
||||
handleUpdate={handleUpdate}
|
||||
cancelEditing={cancelEditing}
|
||||
startEditing={startEditing}
|
||||
formValid={true}
|
||||
loading={false}
|
||||
requirePermission={false}
|
||||
/>
|
||||
</Space>
|
||||
</Flex>
|
||||
<ScrollBox scrollableNodeProps={{ ref: scrollRef }}>
|
||||
<Flex
|
||||
vertical
|
||||
gap='large'
|
||||
onDragOver={handleRootListDragOver}
|
||||
onDrop={(e) => handleDrop(e, 'root', 0)}
|
||||
>
|
||||
{orderedSectionKeys.map((key, index) => {
|
||||
if (isGroupKey(key)) {
|
||||
const groupItems = flexColumns[key] || []
|
||||
const itemsToRender = isLayoutEditing
|
||||
? groupItems
|
||||
: groupItems.filter((childKey) => isItemVisible(childKey))
|
||||
if (itemsToRender.length === 0 && !isLayoutEditing) {
|
||||
return null
|
||||
}
|
||||
const groupProportions = normalizeColumnProportions(
|
||||
groupItems.length,
|
||||
displayLayout.flexColumnProportions?.[key]
|
||||
)
|
||||
const renderProportions = itemsToRender.map((childKey) => {
|
||||
const itemIndex = groupItems.indexOf(childKey)
|
||||
return itemIndex === -1
|
||||
? undefined
|
||||
: groupProportions[itemIndex]
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
data-overview-key={key}
|
||||
onDragOver={(e) => handleRootDragOver(e, index, key)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, 'root', index)}
|
||||
className={getRootWrapperClass(index, key)}
|
||||
>
|
||||
<DashboardOverviewFlexColumns
|
||||
isLayoutEditing={isLayoutEditing}
|
||||
isDragOver={
|
||||
dropTarget?.parent === key &&
|
||||
draggedItem?.kind === 'section' &&
|
||||
dropTarget.index >= groupItems.length
|
||||
}
|
||||
dragHandle={
|
||||
isLayoutEditing
|
||||
? renderDragHandle({
|
||||
kind: 'flexGroup',
|
||||
key,
|
||||
sourceParent: 'root',
|
||||
sourceIndex: index
|
||||
})
|
||||
: null
|
||||
}
|
||||
onRemove={
|
||||
isLayoutEditing ? () => removeGroup(key) : undefined
|
||||
}
|
||||
onDragOver={(e) => handleGroupContainerDragOver(e, key)}
|
||||
onDrop={(e) => handleDrop(e, key, groupItems.length)}
|
||||
onDragLeave={handleDragLeave}
|
||||
proportions={renderProportions}
|
||||
onProportionsChange={
|
||||
isLayoutEditing
|
||||
? (next) => updateFlexColumnProportions(key, next)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{itemsToRender.map((childKey, childIndex) => {
|
||||
const section = sectionsByKey[childKey]
|
||||
if (!section) return null
|
||||
return (
|
||||
<div
|
||||
key={childKey}
|
||||
onDragOver={(e) =>
|
||||
handleGroupItemDragOver(e, key, childIndex)
|
||||
}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, key, childIndex)}
|
||||
className={getGroupItemClass(
|
||||
key,
|
||||
childIndex,
|
||||
childKey
|
||||
)}
|
||||
>
|
||||
{renderCollapse(
|
||||
section,
|
||||
isLayoutEditing
|
||||
? renderDragHandle({
|
||||
kind: 'section',
|
||||
key: childKey,
|
||||
sourceParent: key,
|
||||
sourceIndex: childIndex
|
||||
})
|
||||
: null
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</DashboardOverviewFlexColumns>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const section = sectionsByKey[key]
|
||||
if (!section) return null
|
||||
if (!isItemVisible(section.key)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
data-overview-key={key}
|
||||
onDragOver={(e) => handleRootDragOver(e, index, key)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, 'root', index)}
|
||||
className={getRootWrapperClass(index, key)}
|
||||
>
|
||||
{renderCollapse(
|
||||
section,
|
||||
isLayoutEditing
|
||||
? renderDragHandle({
|
||||
kind: 'section',
|
||||
key,
|
||||
sourceParent: 'root',
|
||||
sourceIndex: index
|
||||
})
|
||||
: null
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Flex>
|
||||
</ScrollBox>
|
||||
</Flex>
|
||||
</OverviewLayoutContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const sectionShape = PropTypes.shape({
|
||||
key: PropTypes.string.isRequired,
|
||||
title: PropTypes.string,
|
||||
icon: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
content: PropTypes.node,
|
||||
type: PropTypes.oneOf(['row']),
|
||||
columns: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.object))
|
||||
})
|
||||
|
||||
DashboardOverviewPage.propTypes = {
|
||||
pageName: PropTypes.string.isRequired,
|
||||
collapseDefaults: PropTypes.object,
|
||||
viewItems: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
key: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired
|
||||
})
|
||||
),
|
||||
actionMenu: PropTypes.object,
|
||||
onOpenActionsModal: PropTypes.func,
|
||||
sections: PropTypes.arrayOf(sectionShape)
|
||||
}
|
||||
|
||||
export default DashboardOverviewPage
|
||||
@ -18,15 +18,17 @@ const EditButtons = ({
|
||||
formValid,
|
||||
disabled,
|
||||
loading,
|
||||
type
|
||||
type,
|
||||
requirePermission = true
|
||||
}) => {
|
||||
const { userProfile } = useContext(AuthContext)
|
||||
const { currentObjectType } = useActions()
|
||||
const modelType = type || currentObjectType
|
||||
const model = modelType ? getModelByName(modelType) : null
|
||||
const denied = model
|
||||
? !hasActionPermission(userProfile, model, 'edit')
|
||||
: false
|
||||
const denied =
|
||||
requirePermission && model
|
||||
? !hasActionPermission(userProfile, model, 'edit')
|
||||
: false
|
||||
const editDisabled = disabled || denied
|
||||
|
||||
return isEditing ? (
|
||||
@ -92,7 +94,8 @@ EditButtons.propTypes = {
|
||||
formValid: PropTypes.bool.isRequired,
|
||||
disabled: PropTypes.any,
|
||||
loading: PropTypes.bool.isRequired,
|
||||
type: PropTypes.string
|
||||
type: PropTypes.string,
|
||||
requirePermission: PropTypes.bool
|
||||
}
|
||||
|
||||
export default EditButtons
|
||||
|
||||
@ -12,14 +12,19 @@ const InfoCollapse = ({
|
||||
onToggle,
|
||||
className = '',
|
||||
collapseKey = 'default',
|
||||
canCollapse = true
|
||||
canCollapse = true,
|
||||
dragHandle = null,
|
||||
extra = null
|
||||
}) => {
|
||||
return (
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={canCollapse ? (active ? [collapseKey] : []) : [collapseKey]}
|
||||
onChange={(keys) => onToggle(keys.length > 0)}
|
||||
onChange={(keys) => {
|
||||
if (!canCollapse) return
|
||||
onToggle(keys.length > 0)
|
||||
}}
|
||||
expandIcon={({ isActive }) =>
|
||||
canCollapse ? (
|
||||
<CaretLeftOutlined
|
||||
@ -35,10 +40,12 @@ const InfoCollapse = ({
|
||||
children: children,
|
||||
label: (
|
||||
<Flex align='center' gap={'middle'} className='info-collapse-label'>
|
||||
{dragHandle}
|
||||
{icon}
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
{title}
|
||||
</Title>
|
||||
{extra}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@ -55,7 +62,9 @@ InfoCollapse.propTypes = {
|
||||
onToggle: PropTypes.func.isRequired,
|
||||
className: PropTypes.string,
|
||||
collapseKey: PropTypes.string,
|
||||
canCollapse: PropTypes.bool
|
||||
canCollapse: PropTypes.bool,
|
||||
dragHandle: PropTypes.node,
|
||||
extra: PropTypes.node
|
||||
}
|
||||
|
||||
export default InfoCollapse
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import { createElement, useContext, useEffect, useRef } from 'react'
|
||||
import { Dropdown, Button } from 'antd'
|
||||
import { getModelByName } from '../../../database/ObjectModels'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useActionsModal } from '../context/ActionsModalContext'
|
||||
import { useActions } from '../context/ActionsContext'
|
||||
import KeyboardShortcut from './KeyboardShortcut'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import ActionsButton from './ActionsButton'
|
||||
import {
|
||||
actionVisibleOnPage,
|
||||
buildActionUrl,
|
||||
@ -125,8 +124,7 @@ function mapActionsToMenuItems(
|
||||
if (action.disabled) {
|
||||
if (typeof action.disabled === 'function') {
|
||||
disabled =
|
||||
denied ||
|
||||
action.disabled({ ...objectData, _user: userProfile })
|
||||
denied || action.disabled({ ...objectData, _user: userProfile })
|
||||
} else {
|
||||
disabled = denied || action.disabled
|
||||
}
|
||||
@ -267,17 +265,13 @@ const ObjectActions = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardShortcut shortcut='alt+a' onTrigger={openActionsModal}>
|
||||
<Dropdown menu={menu} {...dropdownProps}>
|
||||
<Button
|
||||
{...buttonProps}
|
||||
disabled={disabled || filteredActions.length === 0}
|
||||
onClick={openActionsModal}
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</KeyboardShortcut>
|
||||
<ActionsButton
|
||||
menu={menu}
|
||||
onOpenModal={openActionsModal}
|
||||
disabled={disabled || filteredActions.length === 0}
|
||||
buttonProps={buttonProps}
|
||||
{...dropdownProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { useEffect, useState, useContext, useRef } from 'react'
|
||||
import { useEffect, useState, useContext, useRef, useCallback } from 'react'
|
||||
import { Flex, Tag, Card, Typography, Skeleton, Badge } from 'antd'
|
||||
import PropTypes from 'prop-types'
|
||||
import { getModelByName } from '../../../database/ObjectModels'
|
||||
import { ApiServerContext } from '../context/ApiServerContext'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import { useOverviewLayout } from '../context/OverviewLayoutContext'
|
||||
import { round } from '../utils/Utils'
|
||||
import ViewButton from './ViewButton'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@ -159,14 +161,58 @@ const extractBaseStatName = (statName) => {
|
||||
return baseName
|
||||
}
|
||||
|
||||
export const StatsViewButton = ({ objectType }) => {
|
||||
const { isLayoutEditing, getStatsVisibility, updateStatsVisibility } =
|
||||
useOverviewLayout()
|
||||
const model = objectType ? getModelByName(objectType) : null
|
||||
const modelStats = model?.stats || []
|
||||
|
||||
if (!isLayoutEditing || !objectType || modelStats.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const items = modelStats.map((statDef) => ({
|
||||
key: statDef.name,
|
||||
label: statDef.label || statDef.name
|
||||
}))
|
||||
const visibleState = getStatsVisibility(
|
||||
objectType,
|
||||
modelStats.map((statDef) => statDef.name)
|
||||
)
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ViewButton
|
||||
size='small'
|
||||
items={items}
|
||||
visibleState={visibleState}
|
||||
updateVisibleState={(key, value) =>
|
||||
updateStatsVisibility(objectType, key, value)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
StatsViewButton.propTypes = {
|
||||
objectType: PropTypes.string
|
||||
}
|
||||
|
||||
const StatsDisplay = ({ objectType, stats: statsProp }) => {
|
||||
const { getModelStats, connected, subscribeToModelStats } =
|
||||
useContext(ApiServerContext)
|
||||
const { token } = useContext(AuthContext)
|
||||
const { isLayoutEditing, getStatsOrder, reorderStats, getStatsVisibility } =
|
||||
useOverviewLayout()
|
||||
const [stats, setStats] = useState(statsProp || null)
|
||||
const [loading, setLoading] = useState(!statsProp)
|
||||
const [error, setError] = useState(null)
|
||||
const initializedRef = useRef(false)
|
||||
const [draggedIndex, setDraggedIndex] = useState(null)
|
||||
const [dragOverIndex, setDragOverIndex] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
// If stats are provided as prop, use them and don't fetch
|
||||
@ -229,30 +275,94 @@ const StatsDisplay = ({ objectType, stats: statsProp }) => {
|
||||
}
|
||||
}, [objectType, connected, subscribeToModelStats, statsProp])
|
||||
|
||||
if (!objectType) {
|
||||
return null
|
||||
}
|
||||
const model = objectType ? getModelByName(objectType) : null
|
||||
const Icon = model?.icon
|
||||
const modelStats = model?.stats || []
|
||||
const defaultOrder = modelStats.map((statDef) => statDef.name)
|
||||
const statsVisibility = getStatsVisibility(objectType, defaultOrder)
|
||||
const orderedStats = getStatsOrder(objectType, defaultOrder)
|
||||
.map((name) => modelStats.find((statDef) => statDef.name === name))
|
||||
.filter(Boolean)
|
||||
const visibleStats = orderedStats.filter(
|
||||
(statDef) => statsVisibility[statDef.name] !== false
|
||||
)
|
||||
|
||||
const model = getModelByName(objectType)
|
||||
const handleDragStart = useCallback(
|
||||
(e, index) => {
|
||||
if (!isLayoutEditing) return
|
||||
e.stopPropagation()
|
||||
setDraggedIndex(index)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/html', '')
|
||||
},
|
||||
[isLayoutEditing]
|
||||
)
|
||||
|
||||
if (!model) {
|
||||
return null
|
||||
}
|
||||
const handleDragOver = useCallback(
|
||||
(e, index) => {
|
||||
if (!isLayoutEditing || draggedIndex === null) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDragOverIndex(index)
|
||||
},
|
||||
[isLayoutEditing, draggedIndex]
|
||||
)
|
||||
|
||||
const Icon = model.icon
|
||||
const modelStats = model.stats || []
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragOverIndex(null)
|
||||
}, [])
|
||||
|
||||
if (modelStats.length === 0) {
|
||||
return null
|
||||
}
|
||||
const handleDrop = useCallback(
|
||||
(e, dropIndex) => {
|
||||
if (!isLayoutEditing || draggedIndex === null) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (error) {
|
||||
if (draggedIndex === dropIndex) {
|
||||
setDraggedIndex(null)
|
||||
setDragOverIndex(null)
|
||||
return
|
||||
}
|
||||
|
||||
const newOrder = [...visibleStats.map((statDef) => statDef.name)]
|
||||
const [draggedName] = newOrder.splice(draggedIndex, 1)
|
||||
newOrder.splice(dropIndex, 0, draggedName)
|
||||
const hiddenNames = orderedStats
|
||||
.map((statDef) => statDef.name)
|
||||
.filter((name) => !newOrder.includes(name))
|
||||
reorderStats(objectType, [...newOrder, ...hiddenNames])
|
||||
setDraggedIndex(null)
|
||||
setDragOverIndex(null)
|
||||
},
|
||||
[
|
||||
isLayoutEditing,
|
||||
draggedIndex,
|
||||
visibleStats,
|
||||
orderedStats,
|
||||
objectType,
|
||||
reorderStats
|
||||
]
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggedIndex(null)
|
||||
setDragOverIndex(null)
|
||||
}, [])
|
||||
|
||||
if (!objectType || !model || modelStats.length === 0 || error) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex justify='flex-start' gap='middle' wrap='wrap' align='flex-start'>
|
||||
{modelStats.map((statDef) => {
|
||||
<Flex
|
||||
justify='flex-start'
|
||||
gap='middle'
|
||||
wrap='wrap'
|
||||
align='stretch'
|
||||
className='overview-stats-display'
|
||||
>
|
||||
{visibleStats.map((statDef, index) => {
|
||||
const baseStatName = extractBaseStatName(statDef.name)
|
||||
var statValue = getStatValue(stats, baseStatName, statDef)
|
||||
const tagColor = statDef.color || getTagColor(statDef.name)
|
||||
@ -309,30 +419,59 @@ const StatsDisplay = ({ objectType, stats: statsProp }) => {
|
||||
</Flex>
|
||||
)
|
||||
|
||||
if (tagColor === 'default') {
|
||||
return (
|
||||
const minWidth = statDef?.cardWidth || 175
|
||||
const statStyle = {
|
||||
width: '100%',
|
||||
minWidth,
|
||||
boxSizing: 'border-box'
|
||||
}
|
||||
|
||||
const statCard =
|
||||
tagColor === 'default' ? (
|
||||
<Card
|
||||
key={statDef.name}
|
||||
style={{ minWidth: statDef?.cardWidth || 175 }}
|
||||
style={statStyle}
|
||||
styles={{ body: { padding: '16px 24px' } }}
|
||||
>
|
||||
{content}
|
||||
</Card>
|
||||
) : (
|
||||
<Tag
|
||||
color={tagColor}
|
||||
style={{
|
||||
...statStyle,
|
||||
padding: '16px 24px',
|
||||
margin: 0
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
const isDragging = draggedIndex === index
|
||||
const isDragOver = dragOverIndex === index
|
||||
|
||||
return (
|
||||
<Tag
|
||||
<div
|
||||
key={statDef.name}
|
||||
color={tagColor}
|
||||
style={{
|
||||
minWidth: statDef?.cardWidth || 175,
|
||||
padding: '16px 24px',
|
||||
margin: 0
|
||||
}}
|
||||
draggable={isLayoutEditing}
|
||||
onDragStart={
|
||||
isLayoutEditing ? (e) => handleDragStart(e, index) : undefined
|
||||
}
|
||||
onDragOver={
|
||||
isLayoutEditing ? (e) => handleDragOver(e, index) : undefined
|
||||
}
|
||||
onDragLeave={isLayoutEditing ? handleDragLeave : undefined}
|
||||
onDrop={isLayoutEditing ? (e) => handleDrop(e, index) : undefined}
|
||||
onDragEnd={isLayoutEditing ? handleDragEnd : undefined}
|
||||
className={`overview-stat-item ${
|
||||
isLayoutEditing ? 'overview-stat-wrapper' : ''
|
||||
} ${isDragging ? 'overview-dragging' : ''} ${
|
||||
isDragOver ? 'overview-drag-over' : ''
|
||||
}`}
|
||||
style={{ '--stat-min-width': `${minWidth}px` }}
|
||||
>
|
||||
{content}
|
||||
</Tag>
|
||||
{statCard}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Flex>
|
||||
|
||||
@ -37,7 +37,8 @@ const createEmptyUserSettings = () => ({
|
||||
filterSidebarVisibility: {},
|
||||
sortSidebarVisibility: {},
|
||||
columnVisibility: {},
|
||||
collapseState: {}
|
||||
collapseState: {},
|
||||
pageLayout: {}
|
||||
})
|
||||
|
||||
const normalizeUserSettingsCategory = (category) =>
|
||||
@ -74,7 +75,8 @@ const normalizeUserSettings = (settings = {}) => ({
|
||||
settings?.sortSidebarVisibility
|
||||
),
|
||||
columnVisibility: normalizeUserSettingsCategory(settings?.columnVisibility),
|
||||
collapseState: normalizeUserSettingsCategory(settings?.collapseState)
|
||||
collapseState: normalizeUserSettingsCategory(settings?.collapseState),
|
||||
pageLayout: normalizeUserSettingsCategory(settings?.pageLayout)
|
||||
})
|
||||
|
||||
const emitWithAcknowledgement = (socket, eventName, data) =>
|
||||
|
||||
14
src/components/Dashboard/context/OverviewLayoutContext.jsx
Normal file
14
src/components/Dashboard/context/OverviewLayoutContext.jsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
const defaultOverviewLayout = {
|
||||
isLayoutEditing: false,
|
||||
getStatsOrder: (_objectType, defaultOrder = []) => defaultOrder,
|
||||
reorderStats: () => {},
|
||||
getStatsVisibility: (_objectType, defaultNames = []) =>
|
||||
Object.fromEntries(defaultNames.map((name) => [name, true])),
|
||||
updateStatsVisibility: () => {}
|
||||
}
|
||||
|
||||
export const OverviewLayoutContext = createContext(defaultOverviewLayout)
|
||||
|
||||
export const useOverviewLayout = () => useContext(OverviewLayoutContext)
|
||||
213
src/components/Dashboard/hooks/usePageLayout.jsx
Normal file
213
src/components/Dashboard/hooks/usePageLayout.jsx
Normal file
@ -0,0 +1,213 @@
|
||||
import { useCallback, useContext } from 'react'
|
||||
import { ApiServerContext } from '../context/ApiServerContext'
|
||||
|
||||
export const mergeOrder = (saved = [], defaults = []) => {
|
||||
const defaultSet = new Set(defaults)
|
||||
const kept = saved.filter((key) => defaultSet.has(key))
|
||||
const keptSet = new Set(kept)
|
||||
const appended = defaults.filter((key) => !keptSet.has(key))
|
||||
return [...kept, ...appended]
|
||||
}
|
||||
|
||||
export const COLUMN_PROPORTION_STEP = 5
|
||||
const MIN_COLUMN_PROPORTION = 5
|
||||
|
||||
export const normalizeColumnProportions = (count, saved) => {
|
||||
if (count <= 0) return []
|
||||
if (count === 1) return [100]
|
||||
|
||||
if (Array.isArray(saved) && saved.length === count) {
|
||||
return saved.map((value) => {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return MIN_COLUMN_PROPORTION
|
||||
return Math.max(
|
||||
MIN_COLUMN_PROPORTION,
|
||||
Math.round(numeric / COLUMN_PROPORTION_STEP) * COLUMN_PROPORTION_STEP
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const base =
|
||||
Math.floor(100 / count / COLUMN_PROPORTION_STEP) * COLUMN_PROPORTION_STEP
|
||||
const result = Array(count).fill(Math.max(MIN_COLUMN_PROPORTION, base))
|
||||
let remainder = 100 - result.reduce((sum, value) => sum + value, 0)
|
||||
let i = 0
|
||||
while (remainder >= COLUMN_PROPORTION_STEP) {
|
||||
result[i % count] += COLUMN_PROPORTION_STEP
|
||||
remainder -= COLUMN_PROPORTION_STEP
|
||||
i += 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const asObject = (value) =>
|
||||
value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||
|
||||
const asStringArray = (value) =>
|
||||
Array.isArray(value) ? value.filter((key) => typeof key === 'string') : []
|
||||
|
||||
const collectKnownLeaves = (defaultOrder, defaultFlexColumns) => {
|
||||
const leaves = new Set()
|
||||
for (const key of defaultOrder) {
|
||||
if (defaultFlexColumns[key]) {
|
||||
for (const child of defaultFlexColumns[key]) {
|
||||
leaves.add(child)
|
||||
}
|
||||
} else {
|
||||
leaves.add(key)
|
||||
}
|
||||
}
|
||||
return leaves
|
||||
}
|
||||
|
||||
export const mergeLayout = (saved, defaultLayout) => {
|
||||
const defaultOrder = asStringArray(defaultLayout?.sectionOrder)
|
||||
const defaultFlexColumns = Object.fromEntries(
|
||||
Object.entries(asObject(defaultLayout?.flexColumns)).map(([id, items]) => [
|
||||
id,
|
||||
asStringArray(items)
|
||||
])
|
||||
)
|
||||
const savedOrder = asStringArray(saved?.sectionOrder)
|
||||
const savedFlexColumns = Object.fromEntries(
|
||||
Object.entries(asObject(saved?.flexColumns)).map(([id, items]) => [
|
||||
id,
|
||||
asStringArray(items)
|
||||
])
|
||||
)
|
||||
const savedStats =
|
||||
saved?.statsOrder &&
|
||||
typeof saved.statsOrder === 'object' &&
|
||||
!Array.isArray(saved.statsOrder)
|
||||
? saved.statsOrder
|
||||
: {}
|
||||
const savedVisibility =
|
||||
saved?.statsVisibility &&
|
||||
typeof saved.statsVisibility === 'object' &&
|
||||
!Array.isArray(saved.statsVisibility)
|
||||
? saved.statsVisibility
|
||||
: {}
|
||||
|
||||
const knownLeaves = collectKnownLeaves(defaultOrder, defaultFlexColumns)
|
||||
const defaultGroupIds = new Set(Object.keys(defaultFlexColumns))
|
||||
const userGroupIds = Object.keys(savedFlexColumns).filter(
|
||||
(id) => !defaultGroupIds.has(id) && !knownLeaves.has(id)
|
||||
)
|
||||
const userGroupIdSet = new Set(userGroupIds)
|
||||
const isGroupId = (key) => defaultGroupIds.has(key) || userGroupIdSet.has(key)
|
||||
|
||||
const placedLeaves = new Set()
|
||||
const takeLeaves = (keys) => {
|
||||
const result = []
|
||||
for (const key of keys || []) {
|
||||
if (knownLeaves.has(key) && !placedLeaves.has(key)) {
|
||||
placedLeaves.add(key)
|
||||
result.push(key)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const sectionOrder = []
|
||||
const flexColumns = {}
|
||||
|
||||
const addGroup = (id, childKeys) => {
|
||||
flexColumns[id] = childKeys
|
||||
if (!sectionOrder.includes(id)) {
|
||||
sectionOrder.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
const hasSavedStructure =
|
||||
savedOrder.length > 0 || Object.keys(savedFlexColumns).length > 0
|
||||
|
||||
if (hasSavedStructure) {
|
||||
for (const key of savedOrder) {
|
||||
if (knownLeaves.has(key) && !placedLeaves.has(key)) {
|
||||
placedLeaves.add(key)
|
||||
sectionOrder.push(key)
|
||||
} else if (isGroupId(key)) {
|
||||
addGroup(
|
||||
key,
|
||||
takeLeaves(savedFlexColumns[key] ?? defaultFlexColumns[key] ?? [])
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of defaultOrder) {
|
||||
if (knownLeaves.has(key) && !placedLeaves.has(key)) {
|
||||
placedLeaves.add(key)
|
||||
sectionOrder.push(key)
|
||||
} else if (defaultGroupIds.has(key)) {
|
||||
const remaining = takeLeaves(
|
||||
mergeOrder(savedFlexColumns[key] || [], defaultFlexColumns[key])
|
||||
)
|
||||
if (sectionOrder.includes(key)) {
|
||||
flexColumns[key] = [...(flexColumns[key] || []), ...remaining]
|
||||
} else if (remaining.length > 0 || !hasSavedStructure) {
|
||||
addGroup(key, remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of userGroupIds) {
|
||||
if (!sectionOrder.includes(id)) {
|
||||
addGroup(id, takeLeaves(savedFlexColumns[id]))
|
||||
}
|
||||
}
|
||||
|
||||
for (const leaf of knownLeaves) {
|
||||
if (!placedLeaves.has(leaf)) {
|
||||
placedLeaves.add(leaf)
|
||||
sectionOrder.push(leaf)
|
||||
}
|
||||
}
|
||||
|
||||
const flexColumnProportions = Object.fromEntries(
|
||||
Object.entries(asObject(saved?.flexColumnProportions)).map(
|
||||
([id, value]) => [
|
||||
id,
|
||||
Array.isArray(value)
|
||||
? value.filter(
|
||||
(item) => typeof item === 'number' && Number.isFinite(item)
|
||||
)
|
||||
: []
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
sectionOrder,
|
||||
flexColumns,
|
||||
statsOrder: savedStats,
|
||||
statsVisibility: savedVisibility,
|
||||
sectionHeights: asObject(saved?.sectionHeights),
|
||||
flexColumnProportions
|
||||
}
|
||||
}
|
||||
|
||||
const usePageLayout = (
|
||||
pageName,
|
||||
defaultLayout = {
|
||||
sectionOrder: [],
|
||||
flexColumns: {},
|
||||
statsOrder: {},
|
||||
statsVisibility: {}
|
||||
}
|
||||
) => {
|
||||
const { userSettings, updateUserSettings } = useContext(ApiServerContext)
|
||||
const saved = userSettings.pageLayout?.[pageName]
|
||||
const layout = mergeLayout(saved, defaultLayout)
|
||||
|
||||
const updatePageLayout = useCallback(
|
||||
(value) => {
|
||||
updateUserSettings('pageLayout', pageName, value)
|
||||
},
|
||||
[pageName, updateUserSettings]
|
||||
)
|
||||
|
||||
return [layout, updatePageLayout]
|
||||
}
|
||||
|
||||
export default usePageLayout
|
||||
Loading…
x
Reference in New Issue
Block a user