- 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.
384 lines
10 KiB
JavaScript
384 lines
10 KiB
JavaScript
import { useMemo, useState } from 'react'
|
|
import { Card, Segmented, Flex, Popover, DatePicker, Button, Space } from 'antd'
|
|
import {
|
|
ResponsiveContainer,
|
|
BarChart,
|
|
Bar,
|
|
LineChart,
|
|
Line,
|
|
CartesianGrid,
|
|
XAxis,
|
|
YAxis,
|
|
Tooltip,
|
|
Legend
|
|
} from 'recharts'
|
|
|
|
import PropTypes from 'prop-types'
|
|
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 [timeRange, setTimeRange] = useState('4hrs')
|
|
const [startCustomDate, setStartCustomDate] = useState(null)
|
|
const [endCustomDate, setEndCustomDate] = useState(null)
|
|
const { isDarkMode, getColors } = useThemeContext()
|
|
|
|
const config = HISTORY_SERIES_CONFIG[objectType]
|
|
|
|
const { defaultStartDate, defaultEndDate } = useMemo(() => {
|
|
const now = new Date()
|
|
|
|
if (startDate || endDate) {
|
|
return {
|
|
defaultStartDate:
|
|
startDate || new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
|
defaultEndDate: endDate || now
|
|
}
|
|
}
|
|
|
|
if (timeRange === 'custom' && startCustomDate && endCustomDate) {
|
|
return {
|
|
defaultStartDate: startCustomDate.toDate(),
|
|
defaultEndDate: endCustomDate.toDate()
|
|
}
|
|
}
|
|
|
|
const timeRangeMap = {
|
|
'24hrs': 24 * 60,
|
|
'12hrs': 12 * 60,
|
|
'8hrs': 8 * 60,
|
|
'4hrs': 4 * 60,
|
|
'1hrs': 60,
|
|
'30mins': 30,
|
|
'15mins': 15,
|
|
'5mins': 5
|
|
}
|
|
const minutes = timeRangeMap[timeRange] || 60
|
|
return {
|
|
defaultStartDate: new Date(now.getTime() - minutes * 60 * 1000),
|
|
defaultEndDate: now
|
|
}
|
|
}, [startDate, endDate, timeRange, startCustomDate, endCustomDate])
|
|
|
|
const chartRows = useMemo(() => {
|
|
if (!config || !Array.isArray(history)) {
|
|
return []
|
|
}
|
|
|
|
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')
|
|
}
|
|
|
|
config.series.forEach((seriesDef) => {
|
|
row[seriesDef.label] = getNestedValue(point, seriesDef.key) ?? 0
|
|
})
|
|
|
|
return row
|
|
})
|
|
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
|
|
}, [history, config, defaultStartDate, defaultEndDate])
|
|
|
|
if (!objectType || !config) {
|
|
return null
|
|
}
|
|
|
|
const themeColors = getColors()
|
|
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
|
|
}
|
|
|
|
const colorRange = config.series.map((seriesDef) => {
|
|
if (seriesDef.color) {
|
|
return colors[seriesDef.color] || seriesDef.color
|
|
}
|
|
return colors.default
|
|
})
|
|
|
|
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>
|
|
<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>
|
|
)
|
|
|
|
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%' }}
|
|
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' },
|
|
{ label: '30m', value: '30mins' },
|
|
{ label: '15m', value: '15mins' },
|
|
{ label: '5m', value: '5mins' }
|
|
]}
|
|
value={timeRange}
|
|
onChange={setTimeRange}
|
|
disabled={loading}
|
|
/>
|
|
</Flex>
|
|
</Flex>
|
|
)}
|
|
{chartRows.length > 0 && (
|
|
<div style={{ width: '100%', height: `${height}px` }}>
|
|
<ResponsiveContainer width='100%' height='100%'>
|
|
{renderChart()}
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
{loading == false && chartRows.length == 0 && (
|
|
<Flex
|
|
justify='center'
|
|
align='center'
|
|
style={{ height: `${height}px` }}
|
|
>
|
|
<MissingPlaceholder message='No data available.' />
|
|
</Flex>
|
|
)}
|
|
</Flex>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
HistoryDisplay.propTypes = {
|
|
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)
|
|
]),
|
|
loading: PropTypes.bool,
|
|
endDate: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]),
|
|
styles: PropTypes.object,
|
|
height: PropTypes.oneOfType([PropTypes.number, PropTypes.string])
|
|
}
|
|
|
|
export default HistoryDisplay
|