From 4d9db747a21233c3dbdf3c7d58f8c87c5acb7ac0 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 26 Jul 2026 18:37:18 +0100 Subject: [PATCH] Enhance Inventory Components with History Display Feature - Added a HistoryDisplay component to FilamentStockInfo, PartStockInfo, and ProductStockInfo, allowing users to view historical data for filament, part, and product stocks. - Introduced a new GraphIcon for visual representation in the collapsible history sections. - Updated the state management to include history data, improving the overall functionality and user experience in inventory tracking. --- assets/icons/graphicon.svg | 8 + .../FilamentStocks/FilamentStockInfo.jsx | 24 +- .../Inventory/PartStocks/PartStockInfo.jsx | 23 + .../ProductStocks/ProductStockInfo.jsx | 23 + .../Dashboard/common/HistoryDisplay.jsx | 460 ++++++++---------- .../Dashboard/common/ModelHistoryDisplay.jsx | 439 +++++++++++++++++ src/components/Icons/GraphIcon.jsx | 6 + 7 files changed, 724 insertions(+), 259 deletions(-) create mode 100644 assets/icons/graphicon.svg create mode 100644 src/components/Dashboard/common/ModelHistoryDisplay.jsx create mode 100644 src/components/Icons/GraphIcon.jsx diff --git a/assets/icons/graphicon.svg b/assets/icons/graphicon.svg new file mode 100644 index 0000000..1d99c69 --- /dev/null +++ b/assets/icons/graphicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/components/Dashboard/Inventory/FilamentStocks/FilamentStockInfo.jsx b/src/components/Dashboard/Inventory/FilamentStocks/FilamentStockInfo.jsx index 59e404f..41d1983 100644 --- a/src/components/Dashboard/Inventory/FilamentStocks/FilamentStockInfo.jsx +++ b/src/components/Dashboard/Inventory/FilamentStocks/FilamentStockInfo.jsx @@ -16,10 +16,12 @@ import InfoCircleIcon from '../../../Icons/InfoCircleIcon' import FilamentStockIcon from '../../../Icons/FilamentStockIcon' import NoteIcon from '../../../Icons/NoteIcon' import AuditLogIcon from '../../../Icons/AuditLogIcon' +import GraphIcon from '../../../Icons/GraphIcon' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder' import DocumentPrintButton from '../../common/DocumentPrintButton' import UserNotifierToggle from '../../common/UserNotifierToggle' import ScrollBox from '../../common/ScrollBox' +import HistoryDisplay from '../../common/HistoryDisplay' const FilamentStockInfo = () => { const location = useLocation() @@ -33,6 +35,7 @@ const FilamentStockInfo = () => { { info: true, events: true, + history: true, notes: true, auditLogs: false } @@ -82,6 +85,7 @@ const FilamentStockInfo = () => { items={[ { key: 'info', label: 'Filament Stock Information' }, { key: 'events', label: 'Filament Stock Events' }, + { key: 'history', label: 'Filament Stock History' }, { key: 'notes', label: 'Notes' }, { key: 'auditLogs', label: 'Audit Logs' } ]} @@ -170,7 +174,25 @@ const FilamentStockInfo = () => { + )} + + } + active={collapseState.history} + onToggle={(expanded) => updateCollapseState('history', expanded)} + collapseKey='history' + > + {objectFormState.loading ? ( + + ) : ( + )} diff --git a/src/components/Dashboard/Inventory/PartStocks/PartStockInfo.jsx b/src/components/Dashboard/Inventory/PartStocks/PartStockInfo.jsx index 5bc7e94..fc40e93 100644 --- a/src/components/Dashboard/Inventory/PartStocks/PartStockInfo.jsx +++ b/src/components/Dashboard/Inventory/PartStocks/PartStockInfo.jsx @@ -12,6 +12,7 @@ import ViewButton from '../../common/ViewButton.jsx' import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx' import NoteIcon from '../../../Icons/NoteIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' +import GraphIcon from '../../../Icons/GraphIcon.jsx' import ObjectForm from '../../common/ObjectForm.jsx' import EditButtons from '../../common/EditButtons.jsx' import ActivityIndicator from '../../common/ActivityIndicator.jsx' @@ -22,6 +23,7 @@ import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import DocumentPrintButton from '../../common/DocumentPrintButton.jsx' import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' import ScrollBox from '../../common/ScrollBox.jsx' +import HistoryDisplay from '../../common/HistoryDisplay.jsx' const log = loglevel.getLogger('PartStockInfo') log.setLevel(config.logLevel) @@ -36,6 +38,7 @@ const PartStockInfo = () => { { info: true, stocks: true, + history: true, notes: true, auditLogs: false } @@ -88,6 +91,7 @@ const PartStockInfo = () => { disabled={objectFormState.loading} items={[ { key: 'info', label: 'Part Stock Information' }, + { key: 'history', label: 'Part Stock History' }, { key: 'notes', label: 'Notes' }, { key: 'auditLogs', label: 'Audit Logs' } ]} @@ -173,6 +177,25 @@ const PartStockInfo = () => { + } + active={collapseState.history} + onToggle={(expanded) => updateCollapseState('history', expanded)} + collapseKey='history' + > + {objectFormState.loading ? ( + + ) : ( + + )} + + } diff --git a/src/components/Dashboard/Inventory/ProductStocks/ProductStockInfo.jsx b/src/components/Dashboard/Inventory/ProductStocks/ProductStockInfo.jsx index 3f7bff7..6c2ad50 100644 --- a/src/components/Dashboard/Inventory/ProductStocks/ProductStockInfo.jsx +++ b/src/components/Dashboard/Inventory/ProductStocks/ProductStockInfo.jsx @@ -19,6 +19,7 @@ import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx' import NoteIcon from '../../../Icons/NoteIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' import PartStockIcon from '../../../Icons/PartStockIcon.jsx' +import GraphIcon from '../../../Icons/GraphIcon.jsx' import ObjectForm from '../../common/ObjectForm.jsx' import EditButtons from '../../common/EditButtons.jsx' import ActivityIndicator from '../../common/ActivityIndicator.jsx' @@ -29,6 +30,7 @@ import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import DocumentPrintButton from '../../common/DocumentPrintButton.jsx' import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' import ScrollBox from '../../common/ScrollBox.jsx' +import HistoryDisplay from '../../common/HistoryDisplay.jsx' const log = loglevel.getLogger('ProductStockInfo') log.setLevel(config.logLevel) @@ -46,6 +48,7 @@ const ProductStockInfo = () => { { info: true, partStocks: true, + history: true, notes: true, auditLogs: false } @@ -112,6 +115,7 @@ const ProductStockInfo = () => { items={[ { key: 'info', label: 'Product Stock Information' }, { key: 'partStocks', label: 'Part Stocks' }, + { key: 'history', label: 'Product Stock History' }, { key: 'notes', label: 'Notes' }, { key: 'auditLogs', label: 'Audit Logs' } ]} @@ -217,6 +221,25 @@ const ProductStockInfo = () => { + } + active={collapseState.history} + onToggle={(expanded) => updateCollapseState('history', expanded)} + collapseKey='history' + > + {objectFormState.loading ? ( + + ) : ( + + )} + + } diff --git a/src/components/Dashboard/common/HistoryDisplay.jsx b/src/components/Dashboard/common/HistoryDisplay.jsx index a46f885..8173883 100644 --- a/src/components/Dashboard/common/HistoryDisplay.jsx +++ b/src/components/Dashboard/common/HistoryDisplay.jsx @@ -1,9 +1,11 @@ -import { useEffect, useState, useContext, useMemo } from 'react' +import { useMemo, useState } from 'react' import { Card, Segmented, Flex, Popover, DatePicker, Button, Space } from 'antd' import { ResponsiveContainer, BarChart, Bar, + LineChart, + Line, CartesianGrid, XAxis, YAxis, @@ -12,36 +14,101 @@ import { } from 'recharts' import PropTypes from 'prop-types' -import { getModelByName } from '../../../database/ObjectModels' -import { ApiServerContext } from '../context/ApiServerContext' -import { AuthContext } from '../context/AuthContext' import dayjs from 'dayjs' import { useThemeContext } from '../context/ThemeContext' import MissingPlaceholder from './MissingPlaceholder' import CheckIcon from '../../Icons/CheckIcon' import { LoadingOutlined } from '@ant-design/icons' +const HISTORY_SERIES_CONFIG = { + filamentStock: { + series: [ + { + key: 'currentWeight.net', + label: 'Net Weight', + color: 'processing', + suffix: 'g' + }, + { + key: 'currentWeight.gross', + label: 'Gross Weight', + color: 'warning', + suffix: 'g' + } + ], + formatValue: (value, suffix = '') => + `${Number(value ?? 0).toFixed(2)}${suffix}` + }, + partStock: { + series: [ + { + key: 'currentQuantity', + label: 'Current Quantity', + color: 'processing' + } + ], + formatValue: (value) => Number(value ?? 0).toFixed(2) + }, + productStock: { + series: [ + { + key: 'currentQuantity', + label: 'Current Quantity', + color: 'processing' + } + ], + formatValue: (value) => Number(value ?? 0).toFixed(2) + }, + stockEvent: { + series: [ + { + key: 'value', + label: 'Value', + color: 'processing', + variance: true + } + ], + formatValue: (value, suffix = '') => { + const numericValue = Number(value ?? 0) + const isPositive = numericValue > 0 + const isNegative = numericValue < 0 + return `${isPositive ? '+' : isNegative ? '-' : ''}${Math.abs(numericValue).toFixed(2)}${suffix}` + } + } +} + +const getNestedValue = (obj, path) => { + if (!obj || !path) return undefined + const keys = path.split('.') + let value = obj + for (const key of keys) { + if (value === null || value === undefined) return undefined + value = value[key] + } + return value +} + const HistoryDisplay = ({ + loading = false, + history = [], objectType, + chartType = 'bar', + unit, startDate, endDate, styles, height = 400 }) => { - const { getModelHistory, connected } = useContext(ApiServerContext) - const { token } = useContext(AuthContext) - const [historyData, setHistoryData] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) const [timeRange, setTimeRange] = useState('4hrs') const [startCustomDate, setStartCustomDate] = useState(null) const [endCustomDate, setEndCustomDate] = useState(null) const { isDarkMode, getColors } = useThemeContext() - // Calculate dates based on selected time range or provided props + + const config = HISTORY_SERIES_CONFIG[objectType] + const { defaultStartDate, defaultEndDate } = useMemo(() => { const now = new Date() - // If startDate/endDate props are provided, use them (ignore time range) if (startDate || endDate) { return { defaultStartDate: @@ -50,7 +117,6 @@ const HistoryDisplay = ({ } } - // Handle custom date range if (timeRange === 'custom' && startCustomDate && endCustomDate) { return { defaultStartDate: startCustomDate.toDate(), @@ -58,209 +124,56 @@ const HistoryDisplay = ({ } } - // Otherwise, calculate based on selected time range - const hoursMap = { - '8hrs': 8, - '12hrs': 12, - '24hrs': 24, - '4hrs': 4, - '1hrs': 1 + const timeRangeMap = { + '24hrs': 24 * 60, + '12hrs': 12 * 60, + '8hrs': 8 * 60, + '4hrs': 4 * 60, + '1hrs': 60, + '30mins': 30, + '15mins': 15, + '5mins': 5 } - const hours = hoursMap[timeRange] || 1 + const minutes = timeRangeMap[timeRange] || 60 return { - defaultStartDate: new Date(now.getTime() - hours * 60 * 60 * 1000), + defaultStartDate: new Date(now.getTime() - minutes * 60 * 1000), defaultEndDate: now } }, [startDate, endDate, timeRange, startCustomDate, endCustomDate]) - useEffect(() => { - if (!objectType || !getModelHistory || !token || !connected) { - return + const chartRows = useMemo(() => { + if (!config || !Array.isArray(history)) { + return [] } - const fetchHistory = async () => { - try { - setLoading(true) - setError(null) - const fetchedHistory = await getModelHistory( - objectType, - defaultStartDate, - defaultEndDate - ) - if (fetchedHistory) { - setHistoryData(fetchedHistory) - } else { - setError('Failed to fetch history') + const start = new Date(defaultStartDate).getTime() + const end = new Date(defaultEndDate).getTime() + + return history + .filter((point) => { + const timestamp = new Date(point.timestamp).getTime() + return timestamp >= start && timestamp <= end + }) + .map((point) => { + const row = { + timestamp: point.timestamp, + dateFormatted: dayjs(point.timestamp).format('DD/MM HH:mm') } - } catch (err) { - console.error('Error fetching history:', err) - setError('Failed to fetch history') - } finally { - setLoading(false) - } - } - fetchHistory() - }, [ - objectType, - getModelHistory, - token, - connected, - defaultStartDate, - defaultEndDate - ]) + config.series.forEach((seriesDef) => { + row[seriesDef.label] = getNestedValue(point, seriesDef.key) ?? 0 + }) - if (!objectType) { + return row + }) + .sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)) + }, [history, config, defaultStartDate, defaultEndDate]) + + if (!objectType || !config) { return null } - const model = getModelByName(objectType) - - if (!model) { - return null - } - - const modelStats = model.stats || [] - - if (modelStats.length === 0) { - return null - } - - if (error || !Array.isArray(historyData)) { - return null - } - - /** - * Extracts the stat key from history data based on stat definition name - * e.g., "states.standby.count" -> "standby", "standbyPrinters" -> "standby" - * The history data keys are the rollup names (like "standby", "printing") - */ - const extractStatKey = (statName) => { - // Handle dot notation paths like "states.standby.count" - if (statName.includes('.')) { - const parts = statName.split('.') - // Extract the middle part (e.g., "standby" from "states.standby.count") - // Skip first part (usually "states") and last part (usually "count") - if (parts.length >= 2) { - return parts[parts.length - 2] // Get second-to-last part - } - return parts[parts.length - 1] // Fallback to last part - } - // Remove common suffixes - return statName - .replace(/Printers?$/i, '') - .replace(/Jobs?$/i, '') - .toLowerCase() - } - - /** - * Gets a nested value from an object using dot notation - */ - const getNestedValue = (obj, path) => { - if (!obj || !path) return undefined - const keys = path.split('.') - let value = obj - for (const key of keys) { - if (value === null || value === undefined) return undefined - value = value[key] - } - return value - } - - /** - * Extracts numeric value from a rollup object - * Handles both old format (number) and new format ({count: 0}, {sum: 100}, {avg: 5.5}) - */ - const extractValue = (value, preferredOperation) => { - if (typeof value === 'number') { - return value - } - if (value && typeof value === 'object') { - // If a preferred operation is specified and exists, use it - if (preferredOperation && value[preferredOperation] !== undefined) { - return value[preferredOperation] - } - - // Handle new format: {count: 0}, {sum: 100}, {avg: 5.5} - const operationKeys = ['count', 'sum', 'avg'] - for (const key of operationKeys) { - if (value[key] !== undefined) { - return value[key] - } - } - // Fallback: return first numeric value found - const firstValue = Object.values(value)[0] - return typeof firstValue === 'number' ? firstValue : 0 - } - return 0 - } - - /** - * Gets the stat value from history data point - */ - const getStatValueFromPoint = (point, statDef) => { - // Handle special combined stats (support both 'combine' and 'sum' properties) - const combineList = statDef.combine || statDef.sum - if (combineList) { - return combineList.reduce((sum, statName) => { - const tempStatDef = { name: statName } - const value = getStatValueFromPoint(point, tempStatDef) - return sum + (value || 0) - }, 0) - } - - const statName = statDef.name - - // Determine operation from stat name - let operation - if (statName.endsWith('.count')) operation = 'count' - else if (statName.endsWith('.sum')) operation = 'sum' - else if (statName.endsWith('.avg')) operation = 'avg' - - let value - - // Extract the key that matches the history data structure - // History data has keys like "standby", "printing" (the rollup names) - const statKey = extractStatKey(statName) - value = point[statKey] - - // If not found with extracted key, try direct access with full path - if (value === undefined && statName.includes('.')) { - value = getNestedValue(point, statName) - } - - // If still not found, try with original stat name - if (value === undefined) { - value = point[statName] - } - - return extractValue(value, operation) - } - - // Transform data for the chart using model stats configuration - // Each data point has: { date: '2024-01-01', standby: {count: 0}, printing: {count: 0}, ... } - // We need to transform to: [{ date: '2024-01-01', category: 'Ready', value: 0 }, ...] - const chartData = historyData.flatMap((point) => { - return modelStats.map((statDef) => { - const statValue = getStatValueFromPoint(point, statDef) - const label = statDef.label || statDef.name - return { - date: point.date, - dateFormatted: dayjs(point.date).format('DD/MM HH:mm'), - category: label, - value: statValue || 0 - } - }) - }) - - // Sort by date - chartData.sort((a, b) => { - return new Date(a.date) - new Date(b.date) - }) - const themeColors = getColors() - - // Create color mapping from model stats const colors = { success: themeColors.colorSuccess, processing: themeColors.colorInfo, @@ -274,27 +187,18 @@ const HistoryDisplay = ({ volcano: themeColors.colorVolcano } - // Build color range array based on model stats order - const colorRange = modelStats.map((stat) => { - if (stat.color) { - return colors[stat.color] || stat.color + const colorRange = config.series.map((seriesDef) => { + if (seriesDef.color) { + return colors[seriesDef.color] || seriesDef.color } return colors.default }) - const chartRows = Array.from( - chartData - .reduce((acc, item) => { - const existing = acc.get(item.date) || { - date: item.date, - dateFormatted: item.dateFormatted - } - existing[item.category] = item.value - acc.set(item.date, existing) - return acc - }, new Map()) - .values() - ).sort((a, b) => new Date(a.date) - new Date(b.date)) + const formatTooltipValue = (value, seriesLabel) => { + const seriesDef = config.series.find((item) => item.label === seriesLabel) + const suffix = seriesDef?.suffix || unit || '' + return config.formatValue(value, suffix) + } const customTimeRangeContent = ( @@ -323,6 +227,71 @@ const HistoryDisplay = ({ ) + const chartProps = { + data: chartRows + } + + const axisTickStyle = { + fill: isDarkMode ? '#d9d9d9' : '#595959', + fontSize: 12 + } + + const tooltipStyle = { + backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff', + border: `1px solid ${isDarkMode ? '#303030' : '#f0f0f0'}`, + borderRadius: 8 + } + + const renderChart = () => { + const commonElements = ( + <> + + + + [formatTooltipValue(value, name), name]} + /> + + + ) + + if (chartType === 'line') { + return ( + + {commonElements} + {config.series.map((seriesDef, index) => ( + + ))} + + ) + } + + return ( + + {commonElements} + {config.series.map((seriesDef, index) => ( + + ))} + + ) + } + return ( 0 && (
- - - - - - - {modelStats.map((statDef, index) => { - const category = statDef.label || statDef.name - return ( - - ) - })} - + {renderChart()}
)} @@ -426,13 +361,22 @@ const HistoryDisplay = ({ } HistoryDisplay.propTypes = { - objectType: PropTypes.string.isRequired, + history: PropTypes.arrayOf(PropTypes.object), + objectType: PropTypes.oneOf([ + 'filamentStock', + 'partStock', + 'productStock', + 'stockEvent' + ]).isRequired, + chartType: PropTypes.oneOf(['line', 'bar']), + unit: PropTypes.string, startDate: PropTypes.oneOfType([ PropTypes.string, PropTypes.instanceOf(Date) ]), - styles: PropTypes.object, + loading: PropTypes.bool, endDate: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]), + styles: PropTypes.object, height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) } diff --git a/src/components/Dashboard/common/ModelHistoryDisplay.jsx b/src/components/Dashboard/common/ModelHistoryDisplay.jsx new file mode 100644 index 0000000..3b95716 --- /dev/null +++ b/src/components/Dashboard/common/ModelHistoryDisplay.jsx @@ -0,0 +1,439 @@ +import { useEffect, useState, useContext, useMemo } from 'react' +import { Card, Segmented, Flex, Popover, DatePicker, Button, Space } from 'antd' +import { + ResponsiveContainer, + BarChart, + Bar, + CartesianGrid, + XAxis, + YAxis, + Tooltip, + Legend +} from 'recharts' + +import PropTypes from 'prop-types' +import { getModelByName } from '../../../database/ObjectModels' +import { ApiServerContext } from '../context/ApiServerContext' +import { AuthContext } from '../context/AuthContext' +import dayjs from 'dayjs' +import { useThemeContext } from '../context/ThemeContext' +import MissingPlaceholder from './MissingPlaceholder' +import CheckIcon from '../../Icons/CheckIcon' +import { LoadingOutlined } from '@ant-design/icons' + +const ModelHistoryDisplay = ({ + objectType, + startDate, + endDate, + styles, + height = 400 +}) => { + const { getModelHistory, connected } = useContext(ApiServerContext) + const { token } = useContext(AuthContext) + const [historyData, setHistoryData] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [timeRange, setTimeRange] = useState('4hrs') + const [startCustomDate, setStartCustomDate] = useState(null) + const [endCustomDate, setEndCustomDate] = useState(null) + const { isDarkMode, getColors } = useThemeContext() + // Calculate dates based on selected time range or provided props + const { defaultStartDate, defaultEndDate } = useMemo(() => { + const now = new Date() + + // If startDate/endDate props are provided, use them (ignore time range) + if (startDate || endDate) { + return { + defaultStartDate: + startDate || new Date(now.getTime() - 24 * 60 * 60 * 1000), + defaultEndDate: endDate || now + } + } + + // Handle custom date range + if (timeRange === 'custom' && startCustomDate && endCustomDate) { + return { + defaultStartDate: startCustomDate.toDate(), + defaultEndDate: endCustomDate.toDate() + } + } + + // Otherwise, calculate based on selected time range + const hoursMap = { + '8hrs': 8, + '12hrs': 12, + '24hrs': 24, + '4hrs': 4, + '1hrs': 1 + } + const hours = hoursMap[timeRange] || 1 + return { + defaultStartDate: new Date(now.getTime() - hours * 60 * 60 * 1000), + defaultEndDate: now + } + }, [startDate, endDate, timeRange, startCustomDate, endCustomDate]) + + useEffect(() => { + if (!objectType || !getModelHistory || !token || !connected) { + return + } + + const fetchHistory = async () => { + try { + setLoading(true) + setError(null) + const fetchedHistory = await getModelHistory( + objectType, + defaultStartDate, + defaultEndDate + ) + if (fetchedHistory) { + setHistoryData(fetchedHistory) + } else { + setError('Failed to fetch history') + } + } catch (err) { + console.error('Error fetching history:', err) + setError('Failed to fetch history') + } finally { + setLoading(false) + } + } + + fetchHistory() + }, [ + objectType, + getModelHistory, + token, + connected, + defaultStartDate, + defaultEndDate + ]) + + if (!objectType) { + return null + } + + const model = getModelByName(objectType) + + if (!model) { + return null + } + + const modelStats = model.stats || [] + + if (modelStats.length === 0) { + return null + } + + if (error || !Array.isArray(historyData)) { + return null + } + + /** + * Extracts the stat key from history data based on stat definition name + * e.g., "states.standby.count" -> "standby", "standbyPrinters" -> "standby" + * The history data keys are the rollup names (like "standby", "printing") + */ + const extractStatKey = (statName) => { + // Handle dot notation paths like "states.standby.count" + if (statName.includes('.')) { + const parts = statName.split('.') + // Extract the middle part (e.g., "standby" from "states.standby.count") + // Skip first part (usually "states") and last part (usually "count") + if (parts.length >= 2) { + return parts[parts.length - 2] // Get second-to-last part + } + return parts[parts.length - 1] // Fallback to last part + } + // Remove common suffixes + return statName + .replace(/Printers?$/i, '') + .replace(/Jobs?$/i, '') + .toLowerCase() + } + + /** + * Gets a nested value from an object using dot notation + */ + const getNestedValue = (obj, path) => { + if (!obj || !path) return undefined + const keys = path.split('.') + let value = obj + for (const key of keys) { + if (value === null || value === undefined) return undefined + value = value[key] + } + return value + } + + /** + * Extracts numeric value from a rollup object + * Handles both old format (number) and new format ({count: 0}, {sum: 100}, {avg: 5.5}) + */ + const extractValue = (value, preferredOperation) => { + if (typeof value === 'number') { + return value + } + if (value && typeof value === 'object') { + // If a preferred operation is specified and exists, use it + if (preferredOperation && value[preferredOperation] !== undefined) { + return value[preferredOperation] + } + + // Handle new format: {count: 0}, {sum: 100}, {avg: 5.5} + const operationKeys = ['count', 'sum', 'avg'] + for (const key of operationKeys) { + if (value[key] !== undefined) { + return value[key] + } + } + // Fallback: return first numeric value found + const firstValue = Object.values(value)[0] + return typeof firstValue === 'number' ? firstValue : 0 + } + return 0 + } + + /** + * Gets the stat value from history data point + */ + const getStatValueFromPoint = (point, statDef) => { + // Handle special combined stats (support both 'combine' and 'sum' properties) + const combineList = statDef.combine || statDef.sum + if (combineList) { + return combineList.reduce((sum, statName) => { + const tempStatDef = { name: statName } + const value = getStatValueFromPoint(point, tempStatDef) + return sum + (value || 0) + }, 0) + } + + const statName = statDef.name + + // Determine operation from stat name + let operation + if (statName.endsWith('.count')) operation = 'count' + else if (statName.endsWith('.sum')) operation = 'sum' + else if (statName.endsWith('.avg')) operation = 'avg' + + let value + + // Extract the key that matches the history data structure + // History data has keys like "standby", "printing" (the rollup names) + const statKey = extractStatKey(statName) + value = point[statKey] + + // If not found with extracted key, try direct access with full path + if (value === undefined && statName.includes('.')) { + value = getNestedValue(point, statName) + } + + // If still not found, try with original stat name + if (value === undefined) { + value = point[statName] + } + + return extractValue(value, operation) + } + + // Transform data for the chart using model stats configuration + // Each data point has: { date: '2024-01-01', standby: {count: 0}, printing: {count: 0}, ... } + // We need to transform to: [{ date: '2024-01-01', category: 'Ready', value: 0 }, ...] + const chartData = historyData.flatMap((point) => { + return modelStats.map((statDef) => { + const statValue = getStatValueFromPoint(point, statDef) + const label = statDef.label || statDef.name + return { + date: point.date, + dateFormatted: dayjs(point.date).format('DD/MM HH:mm'), + category: label, + value: statValue || 0 + } + }) + }) + + // Sort by date + chartData.sort((a, b) => { + return new Date(a.date) - new Date(b.date) + }) + + const themeColors = getColors() + + // Create color mapping from model stats + const colors = { + success: themeColors.colorSuccess, + processing: themeColors.colorInfo, + error: themeColors.colorError, + warning: themeColors.colorWarning, + default: '#8c8c8c', + cyan: themeColors.colorCyan, + pink: themeColors.colorPink, + purple: themeColors.colorPurple, + magenta: themeColors.colorMagenta, + volcano: themeColors.colorVolcano + } + + // Build color range array based on model stats order + const colorRange = modelStats.map((stat) => { + if (stat.color) { + return colors[stat.color] || stat.color + } + return colors.default + }) + + const chartRows = Array.from( + chartData + .reduce((acc, item) => { + const existing = acc.get(item.date) || { + date: item.date, + dateFormatted: item.dateFormatted + } + existing[item.category] = item.value + acc.set(item.date, existing) + return acc + }, new Map()) + .values() + ).sort((a, b) => new Date(a.date) - new Date(b.date)) + + const customTimeRangeContent = ( + + { + if (dates) { + setStartCustomDate(dates[0]) + setEndCustomDate(dates[1]) + } else { + setStartCustomDate(null) + setEndCustomDate(null) + } + }} + value={[startCustomDate, endCustomDate]} + /> + + + ) + + return ( + + + {!startDate && !endDate && ( + + + + + + + + + {loading == true && } + + + + )} + {chartRows.length > 0 && ( +
+ + + + + + + + {modelStats.map((statDef, index) => { + const category = statDef.label || statDef.name + return ( + + ) + })} + + +
+ )} + {loading == false && chartRows.length == 0 && ( + + + + )} +
+
+ ) +} + +ModelHistoryDisplay.propTypes = { + objectType: PropTypes.string.isRequired, + startDate: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.instanceOf(Date) + ]), + styles: PropTypes.object, + endDate: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]), + height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) +} + +export default ModelHistoryDisplay diff --git a/src/components/Icons/GraphIcon.jsx b/src/components/Icons/GraphIcon.jsx new file mode 100644 index 0000000..5cbe99f --- /dev/null +++ b/src/components/Icons/GraphIcon.jsx @@ -0,0 +1,6 @@ +import Icon from '@ant-design/icons' +import CustomIconSvg from '../../../assets/icons/graphicon.svg?react' + +const GraphIcon = (props) => + +export default GraphIcon