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 {
|
.no-navigation-labels.ant-menu-horizontal .ant-menu-item {
|
||||||
padding: 0 5px;
|
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 DashboardOverviewPage from '../common/DashboardOverviewPage'
|
||||||
import useCollapseState from '../hooks/useCollapseState'
|
|
||||||
import StatsDisplay from '../common/StatsDisplay'
|
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 FinanceOverview = () => {
|
||||||
const [collapseState, updateCollapseState] = useCollapseState(
|
|
||||||
'FinanceOverview',
|
|
||||||
{
|
|
||||||
invoiceStats: true,
|
|
||||||
paymentStats: true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<DashboardOverviewPage
|
||||||
gap='large'
|
pageName='FinanceOverview'
|
||||||
vertical='true'
|
collapseDefaults={collapseDefaults}
|
||||||
style={{
|
sections={sections}
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,292 +1,183 @@
|
|||||||
import { Flex } from 'antd'
|
import DashboardOverviewPage from '../common/DashboardOverviewPage'
|
||||||
import useCollapseState from '../hooks/useCollapseState.jsx'
|
|
||||||
import StatsDisplay from '../common/StatsDisplay'
|
import StatsDisplay from '../common/StatsDisplay'
|
||||||
import ModelHistoryDisplay from '../common/ModelHistoryDisplay'
|
import ModelHistoryDisplay from '../common/ModelHistoryDisplay'
|
||||||
import InfoCollapse from '../common/InfoCollapse'
|
|
||||||
import ScrollBox from '../common/ScrollBox'
|
|
||||||
import ObjectTable from '../common/ObjectTable'
|
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 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 (
|
return (
|
||||||
<Flex
|
<DashboardOverviewPage
|
||||||
gap='large'
|
pageName='InventoryOverview'
|
||||||
vertical='true'
|
collapseDefaults={collapseDefaults}
|
||||||
style={{
|
sections={sections}
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,132 +1,83 @@
|
|||||||
import { Flex } from 'antd'
|
import DashboardOverviewPage from '../common/DashboardOverviewPage'
|
||||||
import useCollapseState from '../hooks/useCollapseState.jsx'
|
|
||||||
import StatsDisplay from '../common/StatsDisplay'
|
import StatsDisplay from '../common/StatsDisplay'
|
||||||
import ModelHistoryDisplay from '../common/ModelHistoryDisplay'
|
import ModelHistoryDisplay from '../common/ModelHistoryDisplay'
|
||||||
import InfoCollapse from '../common/InfoCollapse'
|
|
||||||
import ScrollBox from '../common/ScrollBox'
|
|
||||||
import ObjectTable from '../common/ObjectTable'
|
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 ProductionOverview = () => {
|
||||||
const [collapseState, updateCollapseState] = useCollapseState(
|
|
||||||
'ProductionOverview',
|
|
||||||
{
|
|
||||||
printerStats: true,
|
|
||||||
printerHistory: true,
|
|
||||||
onlinePrinters: true,
|
|
||||||
jobStats: true,
|
|
||||||
productionStats: true,
|
|
||||||
jobStatsDetails: true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<DashboardOverviewPage
|
||||||
gap='large'
|
pageName='ProductionOverview'
|
||||||
vertical='true'
|
collapseDefaults={collapseDefaults}
|
||||||
style={{
|
sections={sections}
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,243 +1,146 @@
|
|||||||
import { Flex } from 'antd'
|
import DashboardOverviewPage from '../common/DashboardOverviewPage'
|
||||||
import useCollapseState from '../hooks/useCollapseState.jsx'
|
|
||||||
import StatsDisplay from '../common/StatsDisplay'
|
import StatsDisplay from '../common/StatsDisplay'
|
||||||
import ModelHistoryDisplay from '../common/ModelHistoryDisplay'
|
import ModelHistoryDisplay from '../common/ModelHistoryDisplay'
|
||||||
import InfoCollapse from '../common/InfoCollapse'
|
|
||||||
import ScrollBox from '../common/ScrollBox'
|
|
||||||
import ObjectTable from '../common/ObjectTable'
|
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 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 (
|
return (
|
||||||
<Flex
|
<DashboardOverviewPage
|
||||||
gap='large'
|
pageName='SalesOverview'
|
||||||
vertical='true'
|
collapseDefaults={collapseDefaults}
|
||||||
style={{
|
sections={sections}
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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,
|
formValid,
|
||||||
disabled,
|
disabled,
|
||||||
loading,
|
loading,
|
||||||
type
|
type,
|
||||||
|
requirePermission = true
|
||||||
}) => {
|
}) => {
|
||||||
const { userProfile } = useContext(AuthContext)
|
const { userProfile } = useContext(AuthContext)
|
||||||
const { currentObjectType } = useActions()
|
const { currentObjectType } = useActions()
|
||||||
const modelType = type || currentObjectType
|
const modelType = type || currentObjectType
|
||||||
const model = modelType ? getModelByName(modelType) : null
|
const model = modelType ? getModelByName(modelType) : null
|
||||||
const denied = model
|
const denied =
|
||||||
? !hasActionPermission(userProfile, model, 'edit')
|
requirePermission && model
|
||||||
: false
|
? !hasActionPermission(userProfile, model, 'edit')
|
||||||
|
: false
|
||||||
const editDisabled = disabled || denied
|
const editDisabled = disabled || denied
|
||||||
|
|
||||||
return isEditing ? (
|
return isEditing ? (
|
||||||
@ -92,7 +94,8 @@ EditButtons.propTypes = {
|
|||||||
formValid: PropTypes.bool.isRequired,
|
formValid: PropTypes.bool.isRequired,
|
||||||
disabled: PropTypes.any,
|
disabled: PropTypes.any,
|
||||||
loading: PropTypes.bool.isRequired,
|
loading: PropTypes.bool.isRequired,
|
||||||
type: PropTypes.string
|
type: PropTypes.string,
|
||||||
|
requirePermission: PropTypes.bool
|
||||||
}
|
}
|
||||||
|
|
||||||
export default EditButtons
|
export default EditButtons
|
||||||
|
|||||||
@ -12,14 +12,19 @@ const InfoCollapse = ({
|
|||||||
onToggle,
|
onToggle,
|
||||||
className = '',
|
className = '',
|
||||||
collapseKey = 'default',
|
collapseKey = 'default',
|
||||||
canCollapse = true
|
canCollapse = true,
|
||||||
|
dragHandle = null,
|
||||||
|
extra = null
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
expandIconPosition='end'
|
expandIconPosition='end'
|
||||||
activeKey={canCollapse ? (active ? [collapseKey] : []) : [collapseKey]}
|
activeKey={canCollapse ? (active ? [collapseKey] : []) : [collapseKey]}
|
||||||
onChange={(keys) => onToggle(keys.length > 0)}
|
onChange={(keys) => {
|
||||||
|
if (!canCollapse) return
|
||||||
|
onToggle(keys.length > 0)
|
||||||
|
}}
|
||||||
expandIcon={({ isActive }) =>
|
expandIcon={({ isActive }) =>
|
||||||
canCollapse ? (
|
canCollapse ? (
|
||||||
<CaretLeftOutlined
|
<CaretLeftOutlined
|
||||||
@ -35,10 +40,12 @@ const InfoCollapse = ({
|
|||||||
children: children,
|
children: children,
|
||||||
label: (
|
label: (
|
||||||
<Flex align='center' gap={'middle'} className='info-collapse-label'>
|
<Flex align='center' gap={'middle'} className='info-collapse-label'>
|
||||||
|
{dragHandle}
|
||||||
{icon}
|
{icon}
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
{title}
|
{title}
|
||||||
</Title>
|
</Title>
|
||||||
|
{extra}
|
||||||
</Flex>
|
</Flex>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -55,7 +62,9 @@ InfoCollapse.propTypes = {
|
|||||||
onToggle: PropTypes.func.isRequired,
|
onToggle: PropTypes.func.isRequired,
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
collapseKey: PropTypes.string,
|
collapseKey: PropTypes.string,
|
||||||
canCollapse: PropTypes.bool
|
canCollapse: PropTypes.bool,
|
||||||
|
dragHandle: PropTypes.node,
|
||||||
|
extra: PropTypes.node
|
||||||
}
|
}
|
||||||
|
|
||||||
export default InfoCollapse
|
export default InfoCollapse
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import { createElement, useContext, useEffect, useRef } from 'react'
|
import { createElement, useContext, useEffect, useRef } from 'react'
|
||||||
import { Dropdown, Button } from 'antd'
|
|
||||||
import { getModelByName } from '../../../database/ObjectModels'
|
import { getModelByName } from '../../../database/ObjectModels'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { useActionsModal } from '../context/ActionsModalContext'
|
import { useActionsModal } from '../context/ActionsModalContext'
|
||||||
import { useActions } from '../context/ActionsContext'
|
import { useActions } from '../context/ActionsContext'
|
||||||
import KeyboardShortcut from './KeyboardShortcut'
|
|
||||||
import { AuthContext } from '../context/AuthContext'
|
import { AuthContext } from '../context/AuthContext'
|
||||||
|
import ActionsButton from './ActionsButton'
|
||||||
import {
|
import {
|
||||||
actionVisibleOnPage,
|
actionVisibleOnPage,
|
||||||
buildActionUrl,
|
buildActionUrl,
|
||||||
@ -125,8 +124,7 @@ function mapActionsToMenuItems(
|
|||||||
if (action.disabled) {
|
if (action.disabled) {
|
||||||
if (typeof action.disabled === 'function') {
|
if (typeof action.disabled === 'function') {
|
||||||
disabled =
|
disabled =
|
||||||
denied ||
|
denied || action.disabled({ ...objectData, _user: userProfile })
|
||||||
action.disabled({ ...objectData, _user: userProfile })
|
|
||||||
} else {
|
} else {
|
||||||
disabled = denied || action.disabled
|
disabled = denied || action.disabled
|
||||||
}
|
}
|
||||||
@ -267,17 +265,13 @@ const ObjectActions = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardShortcut shortcut='alt+a' onTrigger={openActionsModal}>
|
<ActionsButton
|
||||||
<Dropdown menu={menu} {...dropdownProps}>
|
menu={menu}
|
||||||
<Button
|
onOpenModal={openActionsModal}
|
||||||
{...buttonProps}
|
disabled={disabled || filteredActions.length === 0}
|
||||||
disabled={disabled || filteredActions.length === 0}
|
buttonProps={buttonProps}
|
||||||
onClick={openActionsModal}
|
{...dropdownProps}
|
||||||
>
|
/>
|
||||||
Actions
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
</KeyboardShortcut>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { Flex, Tag, Card, Typography, Skeleton, Badge } from 'antd'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { getModelByName } from '../../../database/ObjectModels'
|
import { getModelByName } from '../../../database/ObjectModels'
|
||||||
import { ApiServerContext } from '../context/ApiServerContext'
|
import { ApiServerContext } from '../context/ApiServerContext'
|
||||||
import { AuthContext } from '../context/AuthContext'
|
import { AuthContext } from '../context/AuthContext'
|
||||||
|
import { useOverviewLayout } from '../context/OverviewLayoutContext'
|
||||||
import { round } from '../utils/Utils'
|
import { round } from '../utils/Utils'
|
||||||
|
import ViewButton from './ViewButton'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
@ -159,14 +161,58 @@ const extractBaseStatName = (statName) => {
|
|||||||
return baseName
|
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 StatsDisplay = ({ objectType, stats: statsProp }) => {
|
||||||
const { getModelStats, connected, subscribeToModelStats } =
|
const { getModelStats, connected, subscribeToModelStats } =
|
||||||
useContext(ApiServerContext)
|
useContext(ApiServerContext)
|
||||||
const { token } = useContext(AuthContext)
|
const { token } = useContext(AuthContext)
|
||||||
|
const { isLayoutEditing, getStatsOrder, reorderStats, getStatsVisibility } =
|
||||||
|
useOverviewLayout()
|
||||||
const [stats, setStats] = useState(statsProp || null)
|
const [stats, setStats] = useState(statsProp || null)
|
||||||
const [loading, setLoading] = useState(!statsProp)
|
const [loading, setLoading] = useState(!statsProp)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const initializedRef = useRef(false)
|
const initializedRef = useRef(false)
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState(null)
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If stats are provided as prop, use them and don't fetch
|
// 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])
|
}, [objectType, connected, subscribeToModelStats, statsProp])
|
||||||
|
|
||||||
if (!objectType) {
|
const model = objectType ? getModelByName(objectType) : null
|
||||||
return 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) {
|
const handleDragOver = useCallback(
|
||||||
return null
|
(e, index) => {
|
||||||
}
|
if (!isLayoutEditing || draggedIndex === null) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
e.dataTransfer.dropEffect = 'move'
|
||||||
|
setDragOverIndex(index)
|
||||||
|
},
|
||||||
|
[isLayoutEditing, draggedIndex]
|
||||||
|
)
|
||||||
|
|
||||||
const Icon = model.icon
|
const handleDragLeave = useCallback(() => {
|
||||||
const modelStats = model.stats || []
|
setDragOverIndex(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (modelStats.length === 0) {
|
const handleDrop = useCallback(
|
||||||
return null
|
(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 null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex justify='flex-start' gap='middle' wrap='wrap' align='flex-start'>
|
<Flex
|
||||||
{modelStats.map((statDef) => {
|
justify='flex-start'
|
||||||
|
gap='middle'
|
||||||
|
wrap='wrap'
|
||||||
|
align='stretch'
|
||||||
|
className='overview-stats-display'
|
||||||
|
>
|
||||||
|
{visibleStats.map((statDef, index) => {
|
||||||
const baseStatName = extractBaseStatName(statDef.name)
|
const baseStatName = extractBaseStatName(statDef.name)
|
||||||
var statValue = getStatValue(stats, baseStatName, statDef)
|
var statValue = getStatValue(stats, baseStatName, statDef)
|
||||||
const tagColor = statDef.color || getTagColor(statDef.name)
|
const tagColor = statDef.color || getTagColor(statDef.name)
|
||||||
@ -309,30 +419,59 @@ const StatsDisplay = ({ objectType, stats: statsProp }) => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (tagColor === 'default') {
|
const minWidth = statDef?.cardWidth || 175
|
||||||
return (
|
const statStyle = {
|
||||||
|
width: '100%',
|
||||||
|
minWidth,
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}
|
||||||
|
|
||||||
|
const statCard =
|
||||||
|
tagColor === 'default' ? (
|
||||||
<Card
|
<Card
|
||||||
key={statDef.name}
|
style={statStyle}
|
||||||
style={{ minWidth: statDef?.cardWidth || 175 }}
|
|
||||||
styles={{ body: { padding: '16px 24px' } }}
|
styles={{ body: { padding: '16px 24px' } }}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</Card>
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Tag
|
||||||
|
color={tagColor}
|
||||||
|
style={{
|
||||||
|
...statStyle,
|
||||||
|
padding: '16px 24px',
|
||||||
|
margin: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Tag>
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
const isDragging = draggedIndex === index
|
||||||
|
const isDragOver = dragOverIndex === index
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tag
|
<div
|
||||||
key={statDef.name}
|
key={statDef.name}
|
||||||
color={tagColor}
|
draggable={isLayoutEditing}
|
||||||
style={{
|
onDragStart={
|
||||||
minWidth: statDef?.cardWidth || 175,
|
isLayoutEditing ? (e) => handleDragStart(e, index) : undefined
|
||||||
padding: '16px 24px',
|
}
|
||||||
margin: 0
|
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}
|
{statCard}
|
||||||
</Tag>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@ -37,7 +37,8 @@ const createEmptyUserSettings = () => ({
|
|||||||
filterSidebarVisibility: {},
|
filterSidebarVisibility: {},
|
||||||
sortSidebarVisibility: {},
|
sortSidebarVisibility: {},
|
||||||
columnVisibility: {},
|
columnVisibility: {},
|
||||||
collapseState: {}
|
collapseState: {},
|
||||||
|
pageLayout: {}
|
||||||
})
|
})
|
||||||
|
|
||||||
const normalizeUserSettingsCategory = (category) =>
|
const normalizeUserSettingsCategory = (category) =>
|
||||||
@ -74,7 +75,8 @@ const normalizeUserSettings = (settings = {}) => ({
|
|||||||
settings?.sortSidebarVisibility
|
settings?.sortSidebarVisibility
|
||||||
),
|
),
|
||||||
columnVisibility: normalizeUserSettingsCategory(settings?.columnVisibility),
|
columnVisibility: normalizeUserSettingsCategory(settings?.columnVisibility),
|
||||||
collapseState: normalizeUserSettingsCategory(settings?.collapseState)
|
collapseState: normalizeUserSettingsCategory(settings?.collapseState),
|
||||||
|
pageLayout: normalizeUserSettingsCategory(settings?.pageLayout)
|
||||||
})
|
})
|
||||||
|
|
||||||
const emitWithAcknowledgement = (socket, eventName, data) =>
|
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