diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css
index 662ecce5..b32da2f4 100644
--- a/assets/stylesheets/App.css
+++ b/assets/stylesheets/App.css
@@ -1911,3 +1911,210 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
.no-navigation-labels.ant-menu-horizontal .ant-menu-item {
padding: 0 5px;
}
+
+.overview-drag-handle {
+ cursor: grab;
+ display: inline-flex;
+ align-items: center;
+ color: var(--color-text);
+ padding: 0 4px;
+}
+
+.overview-drag-handle svg path {
+ stroke: currentColor;
+ stroke-width: 3px;
+}
+
+.overview-drag-handle:active {
+ cursor: grabbing;
+}
+
+.overview-section-wrapper.overview-dragging,
+.overview-flex-columns-item.overview-dragging,
+.overview-stat-wrapper.overview-dragging {
+ opacity: 0.4;
+}
+
+.overview-stat-wrapper.overview-drag-over,
+.overview-flex-columns-item.overview-drag-over {
+ outline: 2px dashed color-mix(in srgb, var(--color-primary) 50%, transparent);
+ outline-offset: 4px;
+ border-radius: 12px;
+}
+
+.overview-stats-display {
+ width: 100%;
+}
+
+.overview-stat-item {
+ flex: 1 1 var(--stat-min-width, 175px);
+ min-width: var(--stat-min-width, 175px);
+}
+
+.overview-stat-item .ant-card,
+.overview-stat-item .ant-tag {
+ width: 100%;
+ height: 100%;
+ box-sizing: border-box;
+}
+
+.overview-stat-item .ant-tag {
+ display: inline-flex;
+}
+
+.overview-section-wrapper {
+ position: relative;
+}
+
+.overview-flex-columns {
+ box-sizing: border-box;
+ width: 100%;
+}
+
+.overview-flex-columns.is-editing {
+ border: 2px dashed color-mix(in srgb, var(--color-text) 25%, transparent);
+ border-radius: 24px;
+ padding: 12px;
+}
+
+.overview-flex-columns.is-empty {
+ min-height: 72px;
+}
+
+.overview-flex-columns-toolbar {
+ margin-bottom: 8px;
+}
+
+.overview-flex-columns-row {
+ width: 100%;
+}
+
+.overview-flex-columns-empty {
+ flex: 1;
+ min-height: 48px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: color-mix(in srgb, var(--color-text) 45%, transparent);
+}
+
+.overview-flex-columns-item {
+ position: relative;
+ flex: 1 1 0;
+ min-width: 0;
+ overflow: visible;
+ transition: flex-grow 0.1s ease-in-out;
+}
+
+.overview-flex-columns.is-resizing,
+.overview-flex-columns.is-resizing * {
+ cursor: col-resize !important;
+ user-select: none;
+}
+
+.overview-flex-columns-separator {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 100%;
+ margin-left: calc(var(--ant-margin-lg, 24px) / 2);
+ transform: translateX(-50%);
+ width: 16px;
+ z-index: 3;
+ display: flex;
+ justify-content: center;
+ cursor: col-resize;
+ touch-action: none;
+ user-select: none;
+}
+
+.overview-flex-columns-separator-line {
+ width: 2px;
+ height: 100%;
+ box-sizing: border-box;
+ border-left: 2px dashed color-mix(in srgb, var(--color-text) 25%, transparent);
+ pointer-events: none;
+}
+
+.overview-flex-columns-separator:hover .overview-flex-columns-separator-line,
+.overview-flex-columns-separator.is-active
+ .overview-flex-columns-separator-line {
+ border-left: none;
+ background-color: var(--color-primary);
+ opacity: 0.5;
+}
+
+.overview-flex-columns-separator-handle {
+ position: absolute;
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 2px;
+ height: 10px;
+ background-color: var(--color-primary);
+ pointer-events: none;
+}
+
+.overview-flex-columns-item > .ant-collapse {
+ width: 100%;
+ min-width: 0;
+}
+
+.overview-flex-columns.is-drag-over {
+ outline: 2px dashed color-mix(in srgb, var(--color-primary) 50%, transparent);
+ outline-offset: 2px;
+}
+
+.overview-section-wrapper.overview-insert-before::before,
+.overview-section-wrapper.overview-insert-after::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 0;
+ border-top: 2px dashed
+ color-mix(in srgb, var(--color-primary) 50%, transparent);
+ pointer-events: none;
+ z-index: 2;
+}
+
+.overview-section-wrapper.overview-insert-before::before {
+ top: calc(var(--ant-margin-lg, 24px) / -2);
+ transform: translateY(-50%);
+}
+
+.overview-section-wrapper.overview-insert-after::after {
+ bottom: calc(var(--ant-margin-lg, 24px) / -2);
+ transform: translateY(50%);
+}
+
+.overview-stat-wrapper {
+ cursor: grab;
+}
+
+.overview-stat-wrapper:active {
+ cursor: grabbing;
+}
+
+.info-collapse-resize-handle {
+ display: flex;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ justify-content: center;
+ align-items: center;
+ height: 12px;
+ margin-top: -2px;
+ cursor: ns-resize;
+ touch-action: none;
+ user-select: none;
+}
+
+.info-collapse-resize-handle::after {
+ content: '';
+ width: 20px;
+ height: 2px;
+ border-radius: 1px;
+ background: color-mix(in srgb, var(--color-text) 50%, transparent);
+}
diff --git a/src/components/Dashboard/Finance/FinanceOverview.jsx b/src/components/Dashboard/Finance/FinanceOverview.jsx
index 60f7089d..410f677d 100644
--- a/src/components/Dashboard/Finance/FinanceOverview.jsx
+++ b/src/components/Dashboard/Finance/FinanceOverview.jsx
@@ -1,72 +1,33 @@
-import { Flex } from 'antd'
-import useCollapseState from '../hooks/useCollapseState'
+import DashboardOverviewPage from '../common/DashboardOverviewPage'
import StatsDisplay from '../common/StatsDisplay'
-import InfoCollapse from '../common/InfoCollapse'
-import ScrollBox from '../common/ScrollBox'
+
+const collapseDefaults = {
+ invoiceStats: true,
+ paymentStats: true
+}
+
+const sections = [
+ {
+ key: 'invoiceStats',
+ title: 'Invoice Statistics',
+ className: 'no-t-padding-collapse',
+ content:
+ },
+ {
+ key: 'paymentStats',
+ title: 'Payment Statistics',
+ className: 'no-t-padding-collapse',
+ content:
+ }
+]
const FinanceOverview = () => {
- const [collapseState, updateCollapseState] = useCollapseState(
- 'FinanceOverview',
- {
- invoiceStats: true,
- paymentStats: true
- }
- )
-
return (
-
-
-
-
- updateCollapseState('invoiceStats', isActive)
- }
- className='no-t-padding-collapse'
- collapseKey='invoiceStats'
- canCollapse={false}
- >
-
-
-
-
-
- updateCollapseState('paymentStats', isActive)
- }
- className='no-t-padding-collapse'
- collapseKey='paymentStats'
- canCollapse={false}
- >
-
-
-
-
-
-
-
+
)
}
diff --git a/src/components/Dashboard/Inventory/InventoryOverview.jsx b/src/components/Dashboard/Inventory/InventoryOverview.jsx
index 6584847c..04958c8f 100644
--- a/src/components/Dashboard/Inventory/InventoryOverview.jsx
+++ b/src/components/Dashboard/Inventory/InventoryOverview.jsx
@@ -1,292 +1,183 @@
-import { Flex } from 'antd'
-import useCollapseState from '../hooks/useCollapseState.jsx'
+import DashboardOverviewPage from '../common/DashboardOverviewPage'
import StatsDisplay from '../common/StatsDisplay'
import ModelHistoryDisplay from '../common/ModelHistoryDisplay'
-import InfoCollapse from '../common/InfoCollapse'
-import ScrollBox from '../common/ScrollBox'
import ObjectTable from '../common/ObjectTable'
+const collapseDefaults = {
+ partStockStats: true,
+ filamentStockStats: true,
+ productStockStats: true,
+ purchaseOrderStats: true,
+ stockEventStats: true,
+ partStockHistory: true,
+ newPartStocks: true,
+ filamentStockHistory: true,
+ unconsumedFilamentStocks: true,
+ productStockHistory: true,
+ draftProductStocks: true,
+ purchaseOrderHistory: true,
+ draftPurchaseOrders: true,
+ stockEventHistory: true
+}
+
+const sections = [
+ {
+ key: 'partStockStats',
+ title: 'Part Stock Statistics',
+ className: 'no-t-padding-collapse',
+ content:
+ },
+ {
+ key: 'filamentStockStats',
+ title: 'Filament Stock Statistics',
+ className: 'no-t-padding-collapse',
+ content:
+ },
+ {
+ key: 'productStockStats',
+ title: 'Product Stock Statistics',
+ className: 'no-t-padding-collapse',
+ content:
+ },
+ {
+ key: 'purchaseOrderStats',
+ title: 'Purchase Order Statistics',
+ className: 'no-t-padding-collapse',
+ content:
+ },
+ {
+ key: 'stockEventStats',
+ title: 'Stock Event Statistics',
+ className: 'no-t-padding-collapse',
+ content:
+ },
+ {
+ key: 'partAndFilamentHistoryRow',
+ type: 'row',
+ columns: [
+ [
+ {
+ key: 'partStockHistory',
+ title: 'Part Stock History',
+ content: (
+
+ )
+ }
+ ],
+ [
+ {
+ key: 'filamentStockHistory',
+ title: 'Filament Stock History',
+ content: (
+
+ )
+ }
+ ]
+ ]
+ },
+ {
+ key: 'newPartAndUnconsumedFilamentRow',
+ type: 'row',
+ columns: [
+ [
+ {
+ key: 'newPartStocks',
+ title: 'New Part Stocks',
+ content: (
+
+ )
+ }
+ ],
+ [
+ {
+ key: 'unconsumedFilamentStocks',
+ title: 'Unconsumed Filament Stocks',
+ content: (
+
+ )
+ }
+ ]
+ ]
+ },
+ {
+ key: 'productAndPurchaseOrderHistoryRow',
+ type: 'row',
+ columns: [
+ [
+ {
+ key: 'productStockHistory',
+ title: 'Product Stock History',
+ content: (
+
+ )
+ }
+ ],
+ [
+ {
+ key: 'purchaseOrderHistory',
+ title: 'Purchase Order History',
+ content:
+ }
+ ]
+ ]
+ },
+ {
+ key: 'draftProductAndPurchaseOrderRow',
+ type: 'row',
+ columns: [
+ [
+ {
+ key: 'draftProductStocks',
+ title: 'Draft Product Stocks',
+ content: (
+
+ )
+ }
+ ],
+ [
+ {
+ key: 'draftPurchaseOrders',
+ title: 'Draft Purchase Orders',
+ content: (
+
+ )
+ }
+ ]
+ ]
+ },
+ {
+ key: 'stockEventHistoryRow',
+ type: 'row',
+ columns: [
+ [
+ {
+ key: 'stockEventHistory',
+ title: 'Stock Event History',
+ content: (
+
+ )
+ }
+ ]
+ ]
+ }
+]
+
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 (
-
-
-
-
- updateCollapseState('partStockStats', isActive)
- }
- className='no-t-padding-collapse'
- collapseKey='partStockStats'
- >
-
-
-
-
-
-
- updateCollapseState('filamentStockStats', isActive)
- }
- className='no-t-padding-collapse'
- collapseKey='filamentStockStats'
- >
-
-
-
-
-
-
- updateCollapseState('productStockStats', isActive)
- }
- className='no-t-padding-collapse'
- collapseKey='productStockStats'
- >
-
-
-
-
-
-
- updateCollapseState('purchaseOrderStats', isActive)
- }
- className='no-t-padding-collapse'
- collapseKey='purchaseOrderStats'
- >
-
-
-
-
-
-
- updateCollapseState('stockEventStats', isActive)
- }
- className='no-t-padding-collapse'
- collapseKey='stockEventStats'
- >
-
-
-
-
-
-
-
-
- updateCollapseState('partStockHistory', isActive)
- }
- collapseKey='partStockHistory'
- canCollapse={false}
- >
-
-
-
- updateCollapseState('newPartStocks', isActive)
- }
- collapseKey='newPartStocks'
- canCollapse={false}
- >
-
-
-
-
-
- updateCollapseState('filamentStockHistory', isActive)
- }
- canCollapse={false}
- collapseKey='filamentStockHistory'
- >
-
-
-
- updateCollapseState('unconsumedFilamentStocks', isActive)
- }
- canCollapse={false}
- collapseKey='unconsumedFilamentStocks'
- >
-
-
-
-
-
-
-
-
- updateCollapseState('productStockHistory', isActive)
- }
- collapseKey='productStockHistory'
- canCollapse={false}
- >
-
-
-
- updateCollapseState('draftProductStocks', isActive)
- }
- collapseKey='draftProductStocks'
- canCollapse={false}
- >
-
-
-
-
-
- updateCollapseState('purchaseOrderHistory', isActive)
- }
- canCollapse={false}
- collapseKey='purchaseOrderHistory'
- >
-
-
-
- updateCollapseState('draftPurchaseOrders', isActive)
- }
- canCollapse={false}
- collapseKey='draftPurchaseOrders'
- >
-
-
-
-
-
-
-
-
- updateCollapseState('stockEventHistory', isActive)
- }
- canCollapse={false}
- collapseKey='stockEventHistory'
- >
-
-
-
-
-
-
-
+
)
}
diff --git a/src/components/Dashboard/Production/ProductionOverview.jsx b/src/components/Dashboard/Production/ProductionOverview.jsx
index 93d91268..757a3c13 100644
--- a/src/components/Dashboard/Production/ProductionOverview.jsx
+++ b/src/components/Dashboard/Production/ProductionOverview.jsx
@@ -1,132 +1,83 @@
-import { Flex } from 'antd'
-import useCollapseState from '../hooks/useCollapseState.jsx'
+import DashboardOverviewPage from '../common/DashboardOverviewPage'
import StatsDisplay from '../common/StatsDisplay'
import ModelHistoryDisplay from '../common/ModelHistoryDisplay'
-import InfoCollapse from '../common/InfoCollapse'
-import ScrollBox from '../common/ScrollBox'
import ObjectTable from '../common/ObjectTable'
+const collapseDefaults = {
+ printerStats: true,
+ printerHistory: true,
+ onlinePrinters: true,
+ jobStats: true,
+ productionStats: true,
+ jobStatsDetails: true,
+ jobHistory: true,
+ queuedJobs: true
+}
+
+const sections = [
+ {
+ key: 'printerStats',
+ title: 'Printer Statistics',
+ className: 'no-t-padding-collapse',
+ content:
+ },
+ {
+ key: 'jobStats',
+ title: 'Job Statistics',
+ className: 'no-t-padding-collapse',
+ content:
+ },
+ {
+ key: 'printerAndJobHistoryRow',
+ type: 'row',
+ columns: [
+ [
+ {
+ key: 'printerHistory',
+ title: 'Printer History',
+ content:
+ }
+ ],
+ [
+ {
+ key: 'jobHistory',
+ title: 'Job History',
+ content:
+ }
+ ]
+ ]
+ },
+ {
+ key: 'onlinePrintersAndQueuedJobsRow',
+ type: 'row',
+ columns: [
+ [
+ {
+ key: 'onlinePrinters',
+ title: 'Online Printers',
+ content: (
+
+ )
+ }
+ ],
+ [
+ {
+ key: 'queuedJobs',
+ title: 'Queued Jobs',
+ content:
+ }
+ ]
+ ]
+ }
+]
+
const ProductionOverview = () => {
- const [collapseState, updateCollapseState] = useCollapseState(
- 'ProductionOverview',
- {
- printerStats: true,
- printerHistory: true,
- onlinePrinters: true,
- jobStats: true,
- productionStats: true,
- jobStatsDetails: true
- }
- )
-
return (
-
-
-
-
- updateCollapseState('printerStats', isActive)
- }
- className='no-t-padding-collapse'
- collapseKey='printerStats'
- >
-
-
-
-
-
- updateCollapseState('jobStats', isActive)}
- className='no-t-padding-collapse'
- collapseKey='jobStats'
- >
-
-
-
-
-
-
-
-
- updateCollapseState('printerHistory', isActive)
- }
- collapseKey='printerHistory'
- canCollapse={false}
- >
-
-
-
- updateCollapseState('onlinePrinters', isActive)
- }
- collapseKey='onlinePrinters'
- canCollapse={false}
- >
-
-
-
-
-
- updateCollapseState('jobHistory', isActive)
- }
- canCollapse={false}
- collapseKey='jobHistory'
- >
-
-
-
- updateCollapseState('queuedJobs', isActive)
- }
- canCollapse={false}
- collapseKey='queuedJobs'
- >
-
-
-
-
-
-
-
+
)
}
diff --git a/src/components/Dashboard/Sales/SalesOverview.jsx b/src/components/Dashboard/Sales/SalesOverview.jsx
index 175fdac1..70d46d83 100644
--- a/src/components/Dashboard/Sales/SalesOverview.jsx
+++ b/src/components/Dashboard/Sales/SalesOverview.jsx
@@ -1,243 +1,146 @@
-import { Flex } from 'antd'
-import useCollapseState from '../hooks/useCollapseState.jsx'
+import DashboardOverviewPage from '../common/DashboardOverviewPage'
import StatsDisplay from '../common/StatsDisplay'
import ModelHistoryDisplay from '../common/ModelHistoryDisplay'
-import InfoCollapse from '../common/InfoCollapse'
-import ScrollBox from '../common/ScrollBox'
import ObjectTable from '../common/ObjectTable'
+const collapseDefaults = {
+ salesOrderStats: true,
+ listingStats: true,
+ marketplaceStats: true,
+ clientStats: true,
+ salesOrderHistory: true,
+ confirmedSalesOrders: true,
+ listingHistory: true,
+ draftListings: true,
+ marketplaceHistory: true,
+ readyMarketplaces: true,
+ clientHistory: true,
+ activeClients: true
+}
+
+const sections = [
+ {
+ key: 'salesOrderStats',
+ title: 'Sales Order Statistics',
+ className: 'no-t-padding-collapse',
+ content:
+ },
+ {
+ key: 'listingStats',
+ title: 'Listing Statistics',
+ className: 'no-t-padding-collapse',
+ content:
+ },
+ {
+ key: 'marketplaceStats',
+ title: 'Marketplace Statistics',
+ className: 'no-t-padding-collapse',
+ content:
+ },
+ {
+ key: 'clientStats',
+ title: 'Client Statistics',
+ className: 'no-t-padding-collapse',
+ content:
+ },
+ {
+ key: 'salesOrderAndListingHistoryRow',
+ type: 'row',
+ columns: [
+ [
+ {
+ key: 'salesOrderHistory',
+ title: 'Sales Order History',
+ content:
+ }
+ ],
+ [
+ {
+ key: 'listingHistory',
+ title: 'Listing History',
+ content:
+ }
+ ]
+ ]
+ },
+ {
+ key: 'confirmedSalesOrdersAndDraftListingsRow',
+ type: 'row',
+ columns: [
+ [
+ {
+ key: 'confirmedSalesOrders',
+ title: 'Confirmed Sales Orders',
+ content: (
+
+ )
+ }
+ ],
+ [
+ {
+ key: 'draftListings',
+ title: 'Draft Listings',
+ content: (
+
+ )
+ }
+ ]
+ ]
+ },
+ {
+ key: 'marketplaceAndClientHistoryRow',
+ type: 'row',
+ columns: [
+ [
+ {
+ key: 'marketplaceHistory',
+ title: 'Marketplace History',
+ content:
+ }
+ ],
+ [
+ {
+ key: 'clientHistory',
+ title: 'Client History',
+ content:
+ }
+ ]
+ ]
+ },
+ {
+ key: 'readyMarketplacesAndActiveClientsRow',
+ type: 'row',
+ columns: [
+ [
+ {
+ key: 'readyMarketplaces',
+ title: 'Ready Marketplaces',
+ content: (
+
+ )
+ }
+ ],
+ [
+ {
+ key: 'activeClients',
+ title: 'Active Clients',
+ content:
+ }
+ ]
+ ]
+ }
+]
+
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 (
-
-
-
-
- updateCollapseState('salesOrderStats', isActive)
- }
- className='no-t-padding-collapse'
- collapseKey='salesOrderStats'
- >
-
-
-
-
-
-
- updateCollapseState('listingStats', isActive)
- }
- className='no-t-padding-collapse'
- collapseKey='listingStats'
- >
-
-
-
-
-
-
- updateCollapseState('marketplaceStats', isActive)
- }
- className='no-t-padding-collapse'
- collapseKey='marketplaceStats'
- >
-
-
-
-
-
-
- updateCollapseState('clientStats', isActive)
- }
- className='no-t-padding-collapse'
- collapseKey='clientStats'
- >
-
-
-
-
-
-
-
-
- updateCollapseState('salesOrderHistory', isActive)
- }
- collapseKey='salesOrderHistory'
- canCollapse={false}
- >
-
-
-
- updateCollapseState('confirmedSalesOrders', isActive)
- }
- collapseKey='confirmedSalesOrders'
- canCollapse={false}
- >
-
-
-
-
-
- updateCollapseState('listingHistory', isActive)
- }
- canCollapse={false}
- collapseKey='listingHistory'
- >
-
-
-
- updateCollapseState('draftListings', isActive)
- }
- canCollapse={false}
- collapseKey='draftListings'
- >
-
-
-
-
-
-
-
-
- updateCollapseState('marketplaceHistory', isActive)
- }
- collapseKey='marketplaceHistory'
- canCollapse={false}
- >
-
-
-
- updateCollapseState('readyMarketplaces', isActive)
- }
- collapseKey='readyMarketplaces'
- canCollapse={false}
- >
-
-
-
-
-
- updateCollapseState('clientHistory', isActive)
- }
- canCollapse={false}
- collapseKey='clientHistory'
- >
-
-
-
- updateCollapseState('activeClients', isActive)
- }
- canCollapse={false}
- collapseKey='activeClients'
- >
-
-
-
-
-
-
-
+
)
}
diff --git a/src/components/Dashboard/common/ActionsButton.jsx b/src/components/Dashboard/common/ActionsButton.jsx
new file mode 100644
index 00000000..e56b0810
--- /dev/null
+++ b/src/components/Dashboard/common/ActionsButton.jsx
@@ -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 (
+
+
+
+
+
+ )
+}
+
+ActionsButton.propTypes = {
+ menu: PropTypes.object,
+ onOpenModal: PropTypes.func,
+ disabled: PropTypes.bool,
+ buttonProps: PropTypes.object
+}
+
+export default ActionsButton
diff --git a/src/components/Dashboard/common/DashboardOverviewFlexColumns.jsx b/src/components/Dashboard/common/DashboardOverviewFlexColumns.jsx
new file mode 100644
index 00000000..b170b1e0
--- /dev/null
+++ b/src/components/Dashboard/common/DashboardOverviewFlexColumns.jsx
@@ -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 }) => (
+
event.stopPropagation()}
+ onDragStart={(event) => event.preventDefault()}
+ >
+
+
+
+)
+
+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 (
+
+ {showToolbar && (
+
+ {dragHandle || }
+ {onRemove ? (
+ }
+ onClick={onRemove}
+ aria-label='Remove columns'
+ />
+ ) : null}
+
+ )}
+
+ {isEmpty && isLayoutEditing ? (
+ Drop sections here
+ ) : (
+ 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 ? (
+
+ handlePointerDown(event, index)
+ }
+ />
+ ) : null}
+ >
+ )
+ })
+ })
+ )}
+
+
+ )
+}
+
+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
diff --git a/src/components/Dashboard/common/DashboardOverviewPage.jsx b/src/components/Dashboard/common/DashboardOverviewPage.jsx
new file mode 100644
index 00000000..2cd11b87
--- /dev/null
+++ b/src/components/Dashboard/common/DashboardOverviewPage.jsx
@@ -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) => (
+ e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ onDragStart={(e) => handleDragStart(e, item)}
+ onDragEnd={handleDragEnd}
+ >
+
+
+ )
+
+ 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 ? (
+
+ ) : null
+
+ let content = section.content
+ if (resizable && content) {
+ content = isHistory
+ ? cloneElement(content, { height: contentHeight })
+ : cloneElement(content, { scrollHeight: `${contentHeight}px` })
+ }
+
+ return (
+ 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}
+
+ )
+ }
+
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+ 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 (
+ handleRootDragOver(e, index, key)}
+ onDragLeave={handleDragLeave}
+ onDrop={(e) => handleDrop(e, 'root', index)}
+ className={getRootWrapperClass(index, key)}
+ >
+
= 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 (
+
+ 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
+ )}
+
+ )
+ })}
+
+
+ )
+ }
+
+ const section = sectionsByKey[key]
+ if (!section) return null
+ if (!isItemVisible(section.key)) {
+ return null
+ }
+
+ return (
+ 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
+ )}
+
+ )
+ })}
+
+
+
+
+ )
+}
+
+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
diff --git a/src/components/Dashboard/common/EditButtons.jsx b/src/components/Dashboard/common/EditButtons.jsx
index bc6ba39f..ab9fb31f 100644
--- a/src/components/Dashboard/common/EditButtons.jsx
+++ b/src/components/Dashboard/common/EditButtons.jsx
@@ -18,15 +18,17 @@ const EditButtons = ({
formValid,
disabled,
loading,
- type
+ type,
+ requirePermission = true
}) => {
const { userProfile } = useContext(AuthContext)
const { currentObjectType } = useActions()
const modelType = type || currentObjectType
const model = modelType ? getModelByName(modelType) : null
- const denied = model
- ? !hasActionPermission(userProfile, model, 'edit')
- : false
+ const denied =
+ requirePermission && model
+ ? !hasActionPermission(userProfile, model, 'edit')
+ : false
const editDisabled = disabled || denied
return isEditing ? (
@@ -92,7 +94,8 @@ EditButtons.propTypes = {
formValid: PropTypes.bool.isRequired,
disabled: PropTypes.any,
loading: PropTypes.bool.isRequired,
- type: PropTypes.string
+ type: PropTypes.string,
+ requirePermission: PropTypes.bool
}
export default EditButtons
diff --git a/src/components/Dashboard/common/InfoCollapse.jsx b/src/components/Dashboard/common/InfoCollapse.jsx
index 4a5a43eb..d1ea6431 100644
--- a/src/components/Dashboard/common/InfoCollapse.jsx
+++ b/src/components/Dashboard/common/InfoCollapse.jsx
@@ -12,14 +12,19 @@ const InfoCollapse = ({
onToggle,
className = '',
collapseKey = 'default',
- canCollapse = true
+ canCollapse = true,
+ dragHandle = null,
+ extra = null
}) => {
return (
onToggle(keys.length > 0)}
+ onChange={(keys) => {
+ if (!canCollapse) return
+ onToggle(keys.length > 0)
+ }}
expandIcon={({ isActive }) =>
canCollapse ? (
+ {dragHandle}
{icon}
{title}
+ {extra}
)
}
@@ -55,7 +62,9 @@ InfoCollapse.propTypes = {
onToggle: PropTypes.func.isRequired,
className: PropTypes.string,
collapseKey: PropTypes.string,
- canCollapse: PropTypes.bool
+ canCollapse: PropTypes.bool,
+ dragHandle: PropTypes.node,
+ extra: PropTypes.node
}
export default InfoCollapse
diff --git a/src/components/Dashboard/common/ObjectActions.jsx b/src/components/Dashboard/common/ObjectActions.jsx
index 3022a609..4155b121 100644
--- a/src/components/Dashboard/common/ObjectActions.jsx
+++ b/src/components/Dashboard/common/ObjectActions.jsx
@@ -1,12 +1,11 @@
import { createElement, useContext, useEffect, useRef } from 'react'
-import { Dropdown, Button } from 'antd'
import { getModelByName } from '../../../database/ObjectModels'
import PropTypes from 'prop-types'
import { useNavigate, useLocation } from 'react-router-dom'
import { useActionsModal } from '../context/ActionsModalContext'
import { useActions } from '../context/ActionsContext'
-import KeyboardShortcut from './KeyboardShortcut'
import { AuthContext } from '../context/AuthContext'
+import ActionsButton from './ActionsButton'
import {
actionVisibleOnPage,
buildActionUrl,
@@ -125,8 +124,7 @@ function mapActionsToMenuItems(
if (action.disabled) {
if (typeof action.disabled === 'function') {
disabled =
- denied ||
- action.disabled({ ...objectData, _user: userProfile })
+ denied || action.disabled({ ...objectData, _user: userProfile })
} else {
disabled = denied || action.disabled
}
@@ -267,17 +265,13 @@ const ObjectActions = ({
}
return (
-
-
-
-
-
+
)
}
diff --git a/src/components/Dashboard/common/StatsDisplay.jsx b/src/components/Dashboard/common/StatsDisplay.jsx
index 00ddab9a..25d944f8 100644
--- a/src/components/Dashboard/common/StatsDisplay.jsx
+++ b/src/components/Dashboard/common/StatsDisplay.jsx
@@ -1,10 +1,12 @@
-import { useEffect, useState, useContext, useRef } from 'react'
+import { useEffect, useState, useContext, useRef, useCallback } from 'react'
import { Flex, Tag, Card, Typography, Skeleton, Badge } from 'antd'
import PropTypes from 'prop-types'
import { getModelByName } from '../../../database/ObjectModels'
import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext'
+import { useOverviewLayout } from '../context/OverviewLayoutContext'
import { round } from '../utils/Utils'
+import ViewButton from './ViewButton'
const { Text } = Typography
@@ -159,14 +161,58 @@ const extractBaseStatName = (statName) => {
return baseName
}
+export const StatsViewButton = ({ objectType }) => {
+ const { isLayoutEditing, getStatsVisibility, updateStatsVisibility } =
+ useOverviewLayout()
+ const model = objectType ? getModelByName(objectType) : null
+ const modelStats = model?.stats || []
+
+ if (!isLayoutEditing || !objectType || modelStats.length === 0) {
+ return null
+ }
+
+ const items = modelStats.map((statDef) => ({
+ key: statDef.name,
+ label: statDef.label || statDef.name
+ }))
+ const visibleState = getStatsVisibility(
+ objectType,
+ modelStats.map((statDef) => statDef.name)
+ )
+
+ return (
+ e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ >
+
+ updateStatsVisibility(objectType, key, value)
+ }
+ />
+
+ )
+}
+
+StatsViewButton.propTypes = {
+ objectType: PropTypes.string
+}
+
const StatsDisplay = ({ objectType, stats: statsProp }) => {
const { getModelStats, connected, subscribeToModelStats } =
useContext(ApiServerContext)
const { token } = useContext(AuthContext)
+ const { isLayoutEditing, getStatsOrder, reorderStats, getStatsVisibility } =
+ useOverviewLayout()
const [stats, setStats] = useState(statsProp || null)
const [loading, setLoading] = useState(!statsProp)
const [error, setError] = useState(null)
const initializedRef = useRef(false)
+ const [draggedIndex, setDraggedIndex] = useState(null)
+ const [dragOverIndex, setDragOverIndex] = useState(null)
useEffect(() => {
// If stats are provided as prop, use them and don't fetch
@@ -229,30 +275,94 @@ const StatsDisplay = ({ objectType, stats: statsProp }) => {
}
}, [objectType, connected, subscribeToModelStats, statsProp])
- if (!objectType) {
- return null
- }
+ const model = objectType ? getModelByName(objectType) : null
+ const Icon = model?.icon
+ const modelStats = model?.stats || []
+ const defaultOrder = modelStats.map((statDef) => statDef.name)
+ const statsVisibility = getStatsVisibility(objectType, defaultOrder)
+ const orderedStats = getStatsOrder(objectType, defaultOrder)
+ .map((name) => modelStats.find((statDef) => statDef.name === name))
+ .filter(Boolean)
+ const visibleStats = orderedStats.filter(
+ (statDef) => statsVisibility[statDef.name] !== false
+ )
- const model = getModelByName(objectType)
+ const handleDragStart = useCallback(
+ (e, index) => {
+ if (!isLayoutEditing) return
+ e.stopPropagation()
+ setDraggedIndex(index)
+ e.dataTransfer.effectAllowed = 'move'
+ e.dataTransfer.setData('text/html', '')
+ },
+ [isLayoutEditing]
+ )
- if (!model) {
- return null
- }
+ const handleDragOver = useCallback(
+ (e, index) => {
+ if (!isLayoutEditing || draggedIndex === null) return
+ e.preventDefault()
+ e.stopPropagation()
+ e.dataTransfer.dropEffect = 'move'
+ setDragOverIndex(index)
+ },
+ [isLayoutEditing, draggedIndex]
+ )
- const Icon = model.icon
- const modelStats = model.stats || []
+ const handleDragLeave = useCallback(() => {
+ setDragOverIndex(null)
+ }, [])
- if (modelStats.length === 0) {
- return null
- }
+ const handleDrop = useCallback(
+ (e, dropIndex) => {
+ if (!isLayoutEditing || draggedIndex === null) return
+ e.preventDefault()
+ e.stopPropagation()
- if (error) {
+ if (draggedIndex === dropIndex) {
+ setDraggedIndex(null)
+ setDragOverIndex(null)
+ return
+ }
+
+ const newOrder = [...visibleStats.map((statDef) => statDef.name)]
+ const [draggedName] = newOrder.splice(draggedIndex, 1)
+ newOrder.splice(dropIndex, 0, draggedName)
+ const hiddenNames = orderedStats
+ .map((statDef) => statDef.name)
+ .filter((name) => !newOrder.includes(name))
+ reorderStats(objectType, [...newOrder, ...hiddenNames])
+ setDraggedIndex(null)
+ setDragOverIndex(null)
+ },
+ [
+ isLayoutEditing,
+ draggedIndex,
+ visibleStats,
+ orderedStats,
+ objectType,
+ reorderStats
+ ]
+ )
+
+ const handleDragEnd = useCallback(() => {
+ setDraggedIndex(null)
+ setDragOverIndex(null)
+ }, [])
+
+ if (!objectType || !model || modelStats.length === 0 || error) {
return null
}
return (
-
- {modelStats.map((statDef) => {
+
+ {visibleStats.map((statDef, index) => {
const baseStatName = extractBaseStatName(statDef.name)
var statValue = getStatValue(stats, baseStatName, statDef)
const tagColor = statDef.color || getTagColor(statDef.name)
@@ -309,30 +419,59 @@ const StatsDisplay = ({ objectType, stats: statsProp }) => {
)
- if (tagColor === 'default') {
- return (
+ const minWidth = statDef?.cardWidth || 175
+ const statStyle = {
+ width: '100%',
+ minWidth,
+ boxSizing: 'border-box'
+ }
+
+ const statCard =
+ tagColor === 'default' ? (
{content}
+ ) : (
+
+ {content}
+
)
- }
+
+ const isDragging = draggedIndex === index
+ const isDragOver = dragOverIndex === index
return (
- handleDragStart(e, index) : undefined
+ }
+ onDragOver={
+ isLayoutEditing ? (e) => handleDragOver(e, index) : undefined
+ }
+ onDragLeave={isLayoutEditing ? handleDragLeave : undefined}
+ onDrop={isLayoutEditing ? (e) => handleDrop(e, index) : undefined}
+ onDragEnd={isLayoutEditing ? handleDragEnd : undefined}
+ className={`overview-stat-item ${
+ isLayoutEditing ? 'overview-stat-wrapper' : ''
+ } ${isDragging ? 'overview-dragging' : ''} ${
+ isDragOver ? 'overview-drag-over' : ''
+ }`}
+ style={{ '--stat-min-width': `${minWidth}px` }}
>
- {content}
-
+ {statCard}
+
)
})}
diff --git a/src/components/Dashboard/context/ApiServerContext.jsx b/src/components/Dashboard/context/ApiServerContext.jsx
index 0826ba5f..369d5782 100644
--- a/src/components/Dashboard/context/ApiServerContext.jsx
+++ b/src/components/Dashboard/context/ApiServerContext.jsx
@@ -37,7 +37,8 @@ const createEmptyUserSettings = () => ({
filterSidebarVisibility: {},
sortSidebarVisibility: {},
columnVisibility: {},
- collapseState: {}
+ collapseState: {},
+ pageLayout: {}
})
const normalizeUserSettingsCategory = (category) =>
@@ -74,7 +75,8 @@ const normalizeUserSettings = (settings = {}) => ({
settings?.sortSidebarVisibility
),
columnVisibility: normalizeUserSettingsCategory(settings?.columnVisibility),
- collapseState: normalizeUserSettingsCategory(settings?.collapseState)
+ collapseState: normalizeUserSettingsCategory(settings?.collapseState),
+ pageLayout: normalizeUserSettingsCategory(settings?.pageLayout)
})
const emitWithAcknowledgement = (socket, eventName, data) =>
diff --git a/src/components/Dashboard/context/OverviewLayoutContext.jsx b/src/components/Dashboard/context/OverviewLayoutContext.jsx
new file mode 100644
index 00000000..a19fb6f1
--- /dev/null
+++ b/src/components/Dashboard/context/OverviewLayoutContext.jsx
@@ -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)
diff --git a/src/components/Dashboard/hooks/usePageLayout.jsx b/src/components/Dashboard/hooks/usePageLayout.jsx
new file mode 100644
index 00000000..7f2920c0
--- /dev/null
+++ b/src/components/Dashboard/hooks/usePageLayout.jsx
@@ -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