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:
Tom Butcher 2026-08-25 01:19:17 +01:00
parent f0e93b0519
commit 044abf32a8
15 changed files with 2338 additions and 760 deletions

View File

@ -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);
}

View File

@ -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>
) )
} }

View File

@ -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>
) )
} }

View File

@ -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>
) )
} }

View File

@ -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>
) )
} }

View 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

View 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

View 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

View File

@ -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

View File

@ -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

View File

@ -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>
) )
} }

View File

@ -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>

View File

@ -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) =>

View 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)

View 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