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