From 044abf32a84b163dd6129ddca718bef79e5e8c84 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Tue, 25 Aug 2026 01:19:17 +0100 Subject: [PATCH] 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. --- assets/stylesheets/App.css | 207 ++++ .../Dashboard/Finance/FinanceOverview.jsx | 91 +- .../Dashboard/Inventory/InventoryOverview.jsx | 457 +++----- .../Production/ProductionOverview.jsx | 197 ++-- .../Dashboard/Sales/SalesOverview.jsx | 371 +++---- .../Dashboard/common/ActionsButton.jsx | 43 + .../common/DashboardOverviewFlexColumns.jsx | 251 +++++ .../common/DashboardOverviewPage.jsx | 997 ++++++++++++++++++ .../Dashboard/common/EditButtons.jsx | 13 +- .../Dashboard/common/InfoCollapse.jsx | 15 +- .../Dashboard/common/ObjectActions.jsx | 24 +- .../Dashboard/common/StatsDisplay.jsx | 199 +++- .../Dashboard/context/ApiServerContext.jsx | 6 +- .../context/OverviewLayoutContext.jsx | 14 + .../Dashboard/hooks/usePageLayout.jsx | 213 ++++ 15 files changed, 2338 insertions(+), 760 deletions(-) create mode 100644 src/components/Dashboard/common/ActionsButton.jsx create mode 100644 src/components/Dashboard/common/DashboardOverviewFlexColumns.jsx create mode 100644 src/components/Dashboard/common/DashboardOverviewPage.jsx create mode 100644 src/components/Dashboard/context/OverviewLayoutContext.jsx create mode 100644 src/components/Dashboard/hooks/usePageLayout.jsx 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 ? ( +
+ ) +} + +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