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.
This commit is contained in:
Tom Butcher 2026-07-26 18:37:18 +01:00
parent fed7de8663
commit 4d9db747a2
7 changed files with 724 additions and 259 deletions

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.989624,0,0,0.989624,3,3.012667)">
<path d="M32.232,55.642C32.232,57.251 30.901,58.583 29.291,58.583C27.682,58.583 26.351,57.251 26.351,55.642C26.351,54.032 27.682,52.701 29.291,52.701C30.901,52.701 32.232,54.032 32.232,55.642ZM32.232,46.867C32.232,48.476 30.901,49.807 29.291,49.807C27.682,49.807 26.351,48.476 26.351,46.867C26.351,45.258 27.682,43.926 29.291,43.926C30.901,43.926 32.232,45.258 32.232,46.867ZM32.232,38.066C32.232,39.676 30.901,41.007 29.291,41.007C28.398,41.007 27.591,40.597 27.05,39.956C27.916,38.466 28.763,36.862 29.604,35.143C31.07,35.301 32.232,36.562 32.232,38.066ZM58.608,29.291C58.608,30.901 57.277,32.232 55.667,32.232C54.058,32.232 52.727,30.901 52.727,29.291C52.727,27.682 54.058,26.351 55.667,26.351C57.277,26.351 58.608,27.682 58.608,29.291ZM5.881,29.291C5.881,30.901 4.55,32.232 2.941,32.232C1.331,32.232 0,30.901 0,29.291C0,27.682 1.331,26.351 2.941,26.351C4.55,26.351 5.881,27.682 5.881,29.291ZM14.656,29.291C14.656,30.901 13.351,32.232 11.716,32.232C10.106,32.232 8.775,30.901 8.775,29.291C8.775,27.682 10.106,26.351 11.716,26.351C13.351,26.351 14.656,27.682 14.656,29.291ZM23.457,29.291C23.457,30.901 22.126,32.232 20.516,32.232C18.907,32.232 17.576,30.901 17.576,29.291C17.576,27.682 18.907,26.351 20.516,26.351C22.126,26.351 23.457,27.682 23.457,29.291ZM41.032,29.291C41.032,30.901 39.701,32.232 38.092,32.232C36.483,32.232 35.151,30.901 35.151,29.291C35.151,27.682 36.483,26.351 38.092,26.351C39.701,26.351 41.032,27.682 41.032,29.291ZM49.807,29.291C49.807,30.901 48.476,32.232 46.867,32.232C45.258,32.232 43.926,30.901 43.926,29.291C43.926,27.682 45.258,26.351 46.867,26.351C48.476,26.351 49.807,27.682 49.807,29.291ZM31.442,31.281C31.58,30.996 31.718,30.691 31.857,30.384C31.977,30.118 32.096,29.855 32.214,29.607C32.145,30.248 31.864,30.83 31.442,31.281ZM26.771,28.065L26.377,28.924C26.451,28.32 26.715,27.77 27.108,27.338L26.771,28.065ZM31.523,18.59C30.658,20.084 29.813,21.691 28.972,23.413C27.51,23.255 26.351,22.016 26.351,20.491C26.351,18.881 27.682,17.55 29.291,17.55C30.179,17.55 30.983,17.955 31.523,18.59ZM32.232,11.716C32.232,13.325 30.901,14.656 29.291,14.656C27.682,14.656 26.351,13.325 26.351,11.716C26.351,10.106 27.682,8.775 29.291,8.775C30.901,8.775 32.232,10.106 32.232,11.716ZM32.232,2.941C32.232,4.55 30.901,5.881 29.291,5.881C27.682,5.881 26.351,4.55 26.351,2.941C26.351,1.331 27.682,0 29.291,0C30.901,0 32.232,1.331 32.232,2.941Z" style="fill-rule:nonzero;"/>
<path d="M2.803,52.396C1.307,52.643 0.237,54.108 0.504,55.597C0.751,57.093 2.216,58.169 3.711,57.928C17.696,55.598 24.556,46.57 31.857,30.384C38.113,16.508 43.608,8.067 55.805,6.052C57.295,5.806 58.371,4.341 58.129,2.851C57.851,1.356 56.392,0.28 54.897,0.521C40.932,2.825 34.078,11.879 26.771,28.065C20.515,41.941 15,50.382 2.803,52.396Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -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 = () => {
<ObjectTable
type='stockEvent'
masterFilter={{ 'parent._id': filamentStockId }}
visibleColumns={{ 'parent._id': false }}
visibleColumns={{ parent: false }}
/>
)}
</InfoCollapse>
<InfoCollapse
title='Filament Stock History'
icon={<GraphIcon />}
active={collapseState.history}
onToggle={(expanded) => updateCollapseState('history', expanded)}
collapseKey='history'
>
{objectFormState.loading ? (
<InfoCollapsePlaceholder />
) : (
<HistoryDisplay
history={objectFormState.objectData?.history || []}
objectType='filamentStock'
chartType='line'
loading={objectFormState.loading}
/>
)}
</InfoCollapse>

View File

@ -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 = () => {
</InfoCollapse>
</ActionHandler>
<InfoCollapse
title='Part Stock History'
icon={<GraphIcon />}
active={collapseState.history}
onToggle={(expanded) => updateCollapseState('history', expanded)}
collapseKey='history'
>
{objectFormState.loading ? (
<InfoCollapsePlaceholder />
) : (
<HistoryDisplay
history={objectFormState.objectData?.history || []}
objectType='partStock'
chartType='line'
loading={objectFormState.loading}
/>
)}
</InfoCollapse>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}

View File

@ -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 = () => {
</ObjectForm>
</ActionHandler>
<InfoCollapse
title='Product Stock History'
icon={<GraphIcon />}
active={collapseState.history}
onToggle={(expanded) => updateCollapseState('history', expanded)}
collapseKey='history'
>
{objectFormState.loading ? (
<InfoCollapsePlaceholder />
) : (
<HistoryDisplay
history={objectFormState.objectData?.history || []}
objectType='productStock'
chartType='line'
loading={objectFormState.loading}
/>
)}
</InfoCollapse>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}

View File

@ -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 = (
<Space.Compact>
@ -323,6 +227,71 @@ const HistoryDisplay = ({
</Space.Compact>
)
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 = (
<>
<CartesianGrid
strokeDasharray='3 3'
stroke={isDarkMode ? '#303030' : '#f0f0f0'}
/>
<XAxis dataKey='dateFormatted' tick={axisTickStyle} />
<YAxis tick={axisTickStyle} />
<Tooltip
contentStyle={tooltipStyle}
formatter={(value, name) => [formatTooltipValue(value, name), name]}
/>
<Legend />
</>
)
if (chartType === 'line') {
return (
<LineChart {...chartProps}>
{commonElements}
{config.series.map((seriesDef, index) => (
<Line
key={seriesDef.label}
type='monotone'
dataKey={seriesDef.label}
stroke={colorRange[index] || colors.default}
strokeWidth={2}
dot={false}
/>
))}
</LineChart>
)
}
return (
<BarChart {...chartProps}>
{commonElements}
{config.series.map((seriesDef, index) => (
<Bar
key={seriesDef.label}
dataKey={seriesDef.label}
stackId='history'
fill={colorRange[index] || colors.default}
/>
))}
</BarChart>
)
}
return (
<Card
style={{ width: '100%' }}
@ -358,7 +327,10 @@ const HistoryDisplay = ({
{ label: '12hr', value: '12hrs' },
{ label: '8hr', value: '8hrs' },
{ label: '4hr', value: '4hrs' },
{ label: '1hr', value: '1hrs' }
{ label: '1hr', value: '1hrs' },
{ label: '30m', value: '30mins' },
{ label: '15m', value: '15mins' },
{ label: '5m', value: '5mins' }
]}
value={timeRange}
onChange={setTimeRange}
@ -370,44 +342,7 @@ const HistoryDisplay = ({
{chartRows.length > 0 && (
<div style={{ width: '100%', height: `${height}px` }}>
<ResponsiveContainer width='100%' height='100%'>
<BarChart data={chartRows}>
<CartesianGrid
strokeDasharray='3 3'
stroke={isDarkMode ? '#303030' : '#f0f0f0'}
/>
<XAxis
dataKey='dateFormatted'
tick={{
fill: isDarkMode ? '#d9d9d9' : '#595959',
fontSize: 12
}}
/>
<YAxis
tick={{
fill: isDarkMode ? '#d9d9d9' : '#595959',
fontSize: 12
}}
/>
<Tooltip
contentStyle={{
backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff',
border: `1px solid ${isDarkMode ? '#303030' : '#f0f0f0'}`,
borderRadius: 8
}}
/>
<Legend />
{modelStats.map((statDef, index) => {
const category = statDef.label || statDef.name
return (
<Bar
key={category}
dataKey={category}
stackId='history'
fill={colorRange[index] || colors.default}
/>
)
})}
</BarChart>
{renderChart()}
</ResponsiveContainer>
</div>
)}
@ -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])
}

View File

@ -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 = (
<Space.Compact>
<DatePicker.RangePicker
onChange={(dates) => {
if (dates) {
setStartCustomDate(dates[0])
setEndCustomDate(dates[1])
} else {
setStartCustomDate(null)
setEndCustomDate(null)
}
}}
value={[startCustomDate, endCustomDate]}
/>
<Button
type='primary'
onClick={() => {
if (startCustomDate && endCustomDate) {
setTimeRange('custom')
}
}}
disabled={!startCustomDate || !endCustomDate}
icon={<CheckIcon />}
></Button>
</Space.Compact>
)
return (
<Card
style={{ width: '100%' }}
styles={{ body: { padding: '12px', ...styles } }}
>
<Flex gap='small' vertical>
{!startDate && !endDate && (
<Flex justify='space-between'>
<Flex align='center' gap='5px'>
<Popover
content={customTimeRangeContent}
trigger='hover'
arrow={false}
placement='bottomLeft'
styles={{ body: { borderRadius: '22.5px' } }}
>
<Segmented
size='small'
options={[{ label: 'Custom', value: 'custom' }]}
value={timeRange}
onChange={setTimeRange}
disabled={loading}
/>
</Popover>
</Flex>
<Flex gap='middle'>
{loading == true && <LoadingOutlined />}
<Segmented
size='small'
options={[
{ label: '24hr', value: '24hrs' },
{ label: '12hr', value: '12hrs' },
{ label: '8hr', value: '8hrs' },
{ label: '4hr', value: '4hrs' },
{ label: '1hr', value: '1hrs' }
]}
value={timeRange}
onChange={setTimeRange}
disabled={loading}
/>
</Flex>
</Flex>
)}
{chartRows.length > 0 && (
<div style={{ width: '100%', height: `${height}px` }}>
<ResponsiveContainer width='100%' height='100%'>
<BarChart data={chartRows}>
<CartesianGrid
strokeDasharray='3 3'
stroke={isDarkMode ? '#303030' : '#f0f0f0'}
/>
<XAxis
dataKey='dateFormatted'
tick={{
fill: isDarkMode ? '#d9d9d9' : '#595959',
fontSize: 12
}}
/>
<YAxis
tick={{
fill: isDarkMode ? '#d9d9d9' : '#595959',
fontSize: 12
}}
/>
<Tooltip
contentStyle={{
backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff',
border: `1px solid ${isDarkMode ? '#303030' : '#f0f0f0'}`,
borderRadius: 8
}}
/>
<Legend />
{modelStats.map((statDef, index) => {
const category = statDef.label || statDef.name
return (
<Bar
key={category}
dataKey={category}
stackId='history'
fill={colorRange[index] || colors.default}
/>
)
})}
</BarChart>
</ResponsiveContainer>
</div>
)}
{loading == false && chartRows.length == 0 && (
<Flex
justify='center'
align='center'
style={{ height: `${height}px` }}
>
<MissingPlaceholder message='No data available.' />
</Flex>
)}
</Flex>
</Card>
)
}
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

View File

@ -0,0 +1,6 @@
import Icon from '@ant-design/icons'
import CustomIconSvg from '../../../assets/icons/graphicon.svg?react'
const GraphIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default GraphIcon