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:
parent
fed7de8663
commit
4d9db747a2
8
assets/icons/graphicon.svg
Normal file
8
assets/icons/graphicon.svg
Normal 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 |
@ -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>
|
||||
|
||||
@ -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 />}
|
||||
|
||||
@ -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 />}
|
||||
|
||||
@ -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])
|
||||
}
|
||||
|
||||
|
||||
439
src/components/Dashboard/common/ModelHistoryDisplay.jsx
Normal file
439
src/components/Dashboard/common/ModelHistoryDisplay.jsx
Normal 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
|
||||
6
src/components/Icons/GraphIcon.jsx
Normal file
6
src/components/Icons/GraphIcon.jsx
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user