Compare commits
No commits in common. "35a6b847e658f5ecc674457ce6fa6d292b5a273b" and "e19200c0599875f53fc53e1d446a7a9c1a09ddd1" have entirely different histories.
35a6b847e6
...
e19200c059
@ -1,102 +0,0 @@
|
|||||||
import { useContext } from 'react'
|
|
||||||
import { Flex } from 'antd'
|
|
||||||
import useCollapseState from '../hooks/useCollapseState'
|
|
||||||
import StatsDisplay from '../common/StatsDisplay'
|
|
||||||
import InfoCollapse from '../common/InfoCollapse'
|
|
||||||
import ScrollBox from '../common/ScrollBox'
|
|
||||||
|
|
||||||
import { ApiServerContext } from '../context/ApiServerContext'
|
|
||||||
|
|
||||||
const InventoryOverview = () => {
|
|
||||||
const { connected } = useContext(ApiServerContext)
|
|
||||||
|
|
||||||
const [collapseState, updateCollapseState] = useCollapseState(
|
|
||||||
'InventoryOverview',
|
|
||||||
{
|
|
||||||
inventoryStats: true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!connected) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex
|
|
||||||
gap='large'
|
|
||||||
vertical='true'
|
|
||||||
style={{
|
|
||||||
maxHeight: '100%',
|
|
||||||
minHeight: 0
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ScrollBox>
|
|
||||||
<Flex vertical gap={'large'}>
|
|
||||||
<InfoCollapse
|
|
||||||
title='Inventory Statistics'
|
|
||||||
icon={null}
|
|
||||||
active={collapseState.inventoryStats}
|
|
||||||
onToggle={(isActive) =>
|
|
||||||
updateCollapseState('inventoryStats', isActive)
|
|
||||||
}
|
|
||||||
className='no-t-padding-collapse'
|
|
||||||
collapseKey='inventoryStats'
|
|
||||||
>
|
|
||||||
<Flex
|
|
||||||
justify='flex-start'
|
|
||||||
gap='middle'
|
|
||||||
wrap='wrap'
|
|
||||||
align='flex-start'
|
|
||||||
>
|
|
||||||
<Flex gap='middle' wrap='wrap'>
|
|
||||||
<StatsDisplay objectType='partStock' />
|
|
||||||
<StatsDisplay objectType='filamentStock' />
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</InfoCollapse>
|
|
||||||
|
|
||||||
<Flex gap='large' wrap='wrap'>
|
|
||||||
<Flex flex={1} vertical style={{ minWidth: '300px' }}>
|
|
||||||
<InfoCollapse
|
|
||||||
title='Part Stock History'
|
|
||||||
icon={null}
|
|
||||||
active={collapseState.partStockHistory}
|
|
||||||
onToggle={(isActive) =>
|
|
||||||
updateCollapseState('partStockHistory', isActive)
|
|
||||||
}
|
|
||||||
collapseKey='partStockHistory'
|
|
||||||
canCollapse={false}
|
|
||||||
></InfoCollapse>
|
|
||||||
</Flex>
|
|
||||||
<Flex flex={1} vertical style={{ minWidth: '300px' }}>
|
|
||||||
<InfoCollapse
|
|
||||||
title='Filament Stock History'
|
|
||||||
icon={null}
|
|
||||||
active={collapseState.filamentStockHistory}
|
|
||||||
onToggle={(isActive) =>
|
|
||||||
updateCollapseState('filamentStockHistory', isActive)
|
|
||||||
}
|
|
||||||
canCollapse={false}
|
|
||||||
collapseKey='filamentStockHistory'
|
|
||||||
></InfoCollapse>
|
|
||||||
</Flex>
|
|
||||||
<Flex flex={1} vertical style={{ minWidth: '300px' }}>
|
|
||||||
<InfoCollapse
|
|
||||||
title='Stock Event History'
|
|
||||||
icon={null}
|
|
||||||
active={collapseState.stockEventHistory}
|
|
||||||
onToggle={(isActive) =>
|
|
||||||
updateCollapseState('stockEventHistory', isActive)
|
|
||||||
}
|
|
||||||
canCollapse={false}
|
|
||||||
collapseKey='stockEventHistory'
|
|
||||||
></InfoCollapse>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</ScrollBox>
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default InventoryOverview
|
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import DashboardSidebar from '../common/DashboardSidebar'
|
import DashboardSidebar from '../common/DashboardSidebar'
|
||||||
|
import { DashboardOutlined } from '@ant-design/icons'
|
||||||
import FilamentStockIcon from '../../Icons/FilamentStockIcon'
|
import FilamentStockIcon from '../../Icons/FilamentStockIcon'
|
||||||
import PartStockIcon from '../../Icons/PartStockIcon'
|
import PartStockIcon from '../../Icons/PartStockIcon'
|
||||||
import ProductStockIcon from '../../Icons/ProductStockIcon'
|
import ProductStockIcon from '../../Icons/ProductStockIcon'
|
||||||
@ -8,13 +9,12 @@ import StockAuditIcon from '../../Icons/StockAuditIcon'
|
|||||||
import PurchaseOrderIcon from '../../Icons/PurchaseOrderIcon'
|
import PurchaseOrderIcon from '../../Icons/PurchaseOrderIcon'
|
||||||
import ShipmentIcon from '../../Icons/ShipmentIcon'
|
import ShipmentIcon from '../../Icons/ShipmentIcon'
|
||||||
import OrderItemIcon from '../../Icons/OrderItemIcon'
|
import OrderItemIcon from '../../Icons/OrderItemIcon'
|
||||||
import InventoryIcon from '../../Icons/InventoryIcon'
|
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
key: 'overview',
|
key: 'overview',
|
||||||
label: 'Overview',
|
label: 'Overview',
|
||||||
icon: <InventoryIcon />,
|
icon: <DashboardOutlined />,
|
||||||
path: '/dashboard/inventory/overview'
|
path: '/dashboard/inventory/overview'
|
||||||
},
|
},
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
|
|||||||
@ -1,51 +1,179 @@
|
|||||||
import { useContext } from 'react'
|
import { useEffect, useState, useCallback, useContext } from 'react'
|
||||||
import { Flex } from 'antd'
|
import {
|
||||||
|
Descriptions,
|
||||||
|
Space,
|
||||||
|
Flex,
|
||||||
|
Alert,
|
||||||
|
Typography,
|
||||||
|
Spin,
|
||||||
|
message,
|
||||||
|
Button,
|
||||||
|
Collapse,
|
||||||
|
Segmented,
|
||||||
|
Card
|
||||||
|
} from 'antd'
|
||||||
|
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||||
|
import { Line } from '@ant-design/charts'
|
||||||
|
import axios from 'axios'
|
||||||
|
import PrinterIcon from '../../Icons/PrinterIcon'
|
||||||
|
import JobIcon from '../../Icons/JobIcon'
|
||||||
|
import ReloadIcon from '../../Icons/ReloadIcon'
|
||||||
import useCollapseState from '../hooks/useCollapseState'
|
import useCollapseState from '../hooks/useCollapseState'
|
||||||
import StatsDisplay from '../common/StatsDisplay'
|
|
||||||
import HistoryDisplay from '../common/HistoryDisplay'
|
|
||||||
import InfoCollapse from '../common/InfoCollapse'
|
|
||||||
import ScrollBox from '../common/ScrollBox'
|
|
||||||
|
|
||||||
import { ApiServerContext } from '../context/ApiServerContext'
|
import config from '../../../config'
|
||||||
|
import { AuthContext } from '../context/AuthContext'
|
||||||
|
|
||||||
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
const ProductionOverview = () => {
|
const ProductionOverview = () => {
|
||||||
const { connected } = useContext(ApiServerContext)
|
const { token } = useContext(AuthContext)
|
||||||
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [fetchPrinterStatsLoading, setFetchPrinterStatsLoading] = useState(true)
|
||||||
|
const [chartData, setChartData] = useState([])
|
||||||
const [collapseState, updateCollapseState] = useCollapseState(
|
const [collapseState, updateCollapseState] = useCollapseState(
|
||||||
'ProductionOverview',
|
'ProductionOverview',
|
||||||
{
|
{
|
||||||
|
overview: true,
|
||||||
printerStats: true,
|
printerStats: true,
|
||||||
printerHistory: true,
|
jobStats: true
|
||||||
jobStats: true,
|
|
||||||
productionStats: true,
|
|
||||||
jobStatsDetails: true
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
const [timeRanges, setTimeRanges] = useState({
|
||||||
|
overview: '24h',
|
||||||
|
printerStats: '24h',
|
||||||
|
jobStats: '24h'
|
||||||
|
})
|
||||||
|
|
||||||
if (!connected) {
|
const [stats, setStats] = useState({
|
||||||
return null
|
totalPrinters: 0,
|
||||||
|
activePrinters: 0,
|
||||||
|
totalJobs: 0,
|
||||||
|
activeJobs: 0,
|
||||||
|
completedJobs: 0,
|
||||||
|
printerStatus: {
|
||||||
|
idle: 0,
|
||||||
|
printing: 0,
|
||||||
|
error: 0,
|
||||||
|
offline: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchAllStats = useCallback(async () => {
|
||||||
|
await fetchPrinterStats()
|
||||||
|
await fetchJobstats()
|
||||||
|
await fetchChartData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchPrinterStats = async () => {
|
||||||
|
try {
|
||||||
|
setFetchPrinterStatsLoading(true)
|
||||||
|
const response = await axios.get(`${config.backendUrl}/printers/stats`, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const printStats = response.data
|
||||||
|
setStats((prev) => ({ ...prev, printers: printStats }))
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError('Failed to fetch printer details')
|
||||||
|
messageApi.error('Failed to fetch printer details')
|
||||||
|
} finally {
|
||||||
|
setFetchPrinterStatsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchJobstats = async () => {
|
||||||
|
try {
|
||||||
|
setFetchPrinterStatsLoading(true)
|
||||||
|
const response = await axios.get(`${config.backendUrl}/jobs/stats`, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const jobstats = response.data
|
||||||
|
setStats((prev) => ({ ...prev, jobs: jobstats }))
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError('Failed to fetch printer details')
|
||||||
|
messageApi.error('Failed to fetch printer details')
|
||||||
|
} finally {
|
||||||
|
setFetchPrinterStatsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchChartData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${config.backendUrl}/stats/history`, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setChartData(response.data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
console.error('Failed to fetch chart data:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token != null) {
|
||||||
|
fetchAllStats()
|
||||||
|
}
|
||||||
|
}, [fetchAllStats, token])
|
||||||
|
|
||||||
|
if (fetchPrinterStatsLoading || fetchPrinterStatsLoading) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||||
|
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !stats) {
|
||||||
|
return (
|
||||||
|
<Space
|
||||||
|
direction='vertical'
|
||||||
|
style={{ width: '100%', textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
<p>{error || 'Printer not found'}</p>
|
||||||
|
<Button icon={<ReloadIcon />} onClick={fetchAllStats}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||||
gap='large'
|
{contextHolder}
|
||||||
vertical='true'
|
<Flex gap='large' vertical>
|
||||||
style={{
|
<Collapse
|
||||||
maxHeight: '100%',
|
ghost
|
||||||
minHeight: 0
|
expandIconPosition='end'
|
||||||
}}
|
activeKey={collapseState.overview ? ['1'] : []}
|
||||||
>
|
onChange={(keys) => updateCollapseState('overview', keys.length > 0)}
|
||||||
<ScrollBox>
|
expandIcon={({ isActive }) => (
|
||||||
<Flex vertical gap={'large'}>
|
<CaretLeftOutlined
|
||||||
<InfoCollapse
|
rotate={isActive ? 90 : 0}
|
||||||
title='Printer Statistics'
|
style={{ paddingTop: '2px' }}
|
||||||
icon={null}
|
/>
|
||||||
active={collapseState.printerStats}
|
)}
|
||||||
onToggle={(isActive) =>
|
className='no-h-padding-collapse no-t-padding-collapse'
|
||||||
updateCollapseState('printerStats', isActive)
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header={
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Status Overview
|
||||||
|
</Title>
|
||||||
}
|
}
|
||||||
className='no-t-padding-collapse'
|
key='1'
|
||||||
collapseKey='printerStats'
|
|
||||||
>
|
>
|
||||||
<Flex
|
<Flex
|
||||||
justify='flex-start'
|
justify='flex-start'
|
||||||
@ -53,61 +181,268 @@ const ProductionOverview = () => {
|
|||||||
wrap='wrap'
|
wrap='wrap'
|
||||||
align='flex-start'
|
align='flex-start'
|
||||||
>
|
>
|
||||||
<StatsDisplay objectType='printer' />
|
<Alert
|
||||||
|
type='success'
|
||||||
|
style={{ minWidth: 150 }}
|
||||||
|
description={
|
||||||
|
<Flex vertical>
|
||||||
|
<Text type='secondary'>Ready</Text>
|
||||||
|
<Flex gap={'small'}>
|
||||||
|
<PrinterIcon style={{ fontSize: 26 }} />
|
||||||
|
<Text style={{ fontSize: 28, fontWeight: 600 }}>
|
||||||
|
{(stats.printers.standby || 0) +
|
||||||
|
(stats.printers.complete || 0)}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Alert
|
||||||
|
type='info'
|
||||||
|
style={{ minWidth: 150 }}
|
||||||
|
description={
|
||||||
|
<Flex vertical>
|
||||||
|
<Text type='secondary'>Printing</Text>
|
||||||
|
<Flex gap={'small'}>
|
||||||
|
<PrinterIcon style={{ fontSize: 26 }} />
|
||||||
|
<Text style={{ fontSize: 28, fontWeight: 600 }}>
|
||||||
|
{stats.printers.printing || 0}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Alert
|
||||||
|
type='warning'
|
||||||
|
style={{ minWidth: 150 }}
|
||||||
|
description={
|
||||||
|
<Flex vertical>
|
||||||
|
<Text type='secondary'>Queued</Text>
|
||||||
|
<Flex gap={'small'}>
|
||||||
|
<JobIcon style={{ fontSize: 26 }} />
|
||||||
|
<Text style={{ fontSize: 28, fontWeight: 600 }}>
|
||||||
|
{stats.jobs.queued || 0}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Alert
|
||||||
|
type='info'
|
||||||
|
style={{ minWidth: 150 }}
|
||||||
|
description={
|
||||||
|
<Flex vertical>
|
||||||
|
<Text type='secondary'>Printing</Text>
|
||||||
|
<Flex gap={'small'}>
|
||||||
|
<JobIcon style={{ fontSize: 26 }} />
|
||||||
|
<Text style={{ fontSize: 28, fontWeight: 600 }}>
|
||||||
|
{stats.jobs.printing || 0}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Alert
|
||||||
|
type='error'
|
||||||
|
style={{ minWidth: 150 }}
|
||||||
|
description={
|
||||||
|
<Flex vertical>
|
||||||
|
<Text type='secondary'>Failed</Text>
|
||||||
|
<Flex gap={'small'}>
|
||||||
|
<JobIcon style={{ fontSize: 26 }} />
|
||||||
|
<Text style={{ fontSize: 28, fontWeight: 600 }}>
|
||||||
|
{(stats.jobs.failed || 0) + (stats.jobs.cancelled || 0)}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Alert
|
||||||
|
type='success'
|
||||||
|
style={{ minWidth: 150 }}
|
||||||
|
description={
|
||||||
|
<Flex vertical>
|
||||||
|
<Text type='secondary'>Complete</Text>
|
||||||
|
<Flex gap={'small'}>
|
||||||
|
<JobIcon style={{ fontSize: 26 }} />
|
||||||
|
<Text style={{ fontSize: 28, fontWeight: 600 }}>
|
||||||
|
{stats.jobs.complete || 0}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
</InfoCollapse>
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
<InfoCollapse
|
<Flex gap='large' wrap='wrap'>
|
||||||
title='Job Statistics'
|
<Flex flex={1} vertical>
|
||||||
icon={null}
|
<Collapse
|
||||||
active={collapseState.jobStats}
|
ghost
|
||||||
onToggle={(isActive) => updateCollapseState('jobStats', isActive)}
|
expandIconPosition='end'
|
||||||
className='no-t-padding-collapse'
|
activeKey={collapseState.printerStats ? ['2'] : []}
|
||||||
collapseKey='jobStats'
|
onChange={(keys) =>
|
||||||
>
|
updateCollapseState('printerStats', keys.length > 0)
|
||||||
<Flex
|
}
|
||||||
justify='flex-start'
|
expandIcon={({ isActive }) => (
|
||||||
gap='middle'
|
<CaretLeftOutlined
|
||||||
wrap='wrap'
|
rotate={isActive ? 90 : 0}
|
||||||
align='flex-start'
|
style={{ paddingTop: '2px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
className='no-h-padding-collapse'
|
||||||
>
|
>
|
||||||
<StatsDisplay objectType='job' />
|
<Collapse.Panel
|
||||||
</Flex>
|
header={
|
||||||
</InfoCollapse>
|
<Flex
|
||||||
|
align='center'
|
||||||
<Flex gap='large' wrap='wrap'>
|
justify='space-between'
|
||||||
<Flex flex={1} vertical style={{ minWidth: '300px' }}>
|
style={{ width: '100%' }}
|
||||||
<InfoCollapse
|
>
|
||||||
title='Printer History'
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
icon={null}
|
Production Statistics
|
||||||
active={collapseState.printerHistory}
|
</Title>
|
||||||
onToggle={(isActive) =>
|
<Segmented
|
||||||
updateCollapseState('printerHistory', isActive)
|
options={['4h', '8h', '12h', '24h']}
|
||||||
|
value={timeRanges.printerStats}
|
||||||
|
onChange={(value) =>
|
||||||
|
setTimeRanges((prev) => ({
|
||||||
|
...prev,
|
||||||
|
printerStats: value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
size='small'
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
collapseKey='printerHistory'
|
key='2'
|
||||||
canCollapse={false}
|
|
||||||
>
|
>
|
||||||
<HistoryDisplay objectType='printer' />
|
<Flex vertical gap={'middle'}>
|
||||||
</InfoCollapse>
|
<Card style={{ height: 250, width: '100%' }}>
|
||||||
</Flex>
|
<Line
|
||||||
<Flex flex={1} vertical style={{ minWidth: '300px' }}>
|
data={chartData}
|
||||||
<InfoCollapse
|
xField='timestamp'
|
||||||
title='Job History'
|
yField='value'
|
||||||
icon={null}
|
seriesField='type'
|
||||||
active={collapseState.jobHistory}
|
smooth
|
||||||
onToggle={(isActive) =>
|
animation={{
|
||||||
updateCollapseState('jobHistory', isActive)
|
appear: {
|
||||||
|
animation: 'wave-in',
|
||||||
|
duration: 1000
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
point={{
|
||||||
|
size: 4,
|
||||||
|
shape: 'circle'
|
||||||
|
}}
|
||||||
|
tooltip={{
|
||||||
|
showMarkers: false
|
||||||
|
}}
|
||||||
|
legend={{
|
||||||
|
position: 'top'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Descriptions column={1} bordered>
|
||||||
|
<Descriptions.Item label='Completed'>
|
||||||
|
{stats.totalPrinters}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label='Error'>
|
||||||
|
{stats.activePrinters}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label='Paused'>
|
||||||
|
{stats.activePrinters}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Flex>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
</Flex>
|
||||||
|
<Flex flex={1} vertical>
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
expandIconPosition='end'
|
||||||
|
activeKey={collapseState.jobStats ? ['3'] : []}
|
||||||
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('jobStats', keys.length > 0)
|
||||||
|
}
|
||||||
|
expandIcon={({ isActive }) => (
|
||||||
|
<CaretLeftOutlined
|
||||||
|
rotate={isActive ? 90 : 0}
|
||||||
|
style={{ paddingTop: '2px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
className='no-h-padding-collapse'
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header={
|
||||||
|
<Flex
|
||||||
|
align='center'
|
||||||
|
justify='space-between'
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Job Statistics
|
||||||
|
</Title>
|
||||||
|
<Segmented
|
||||||
|
options={['4h', '8h', '12h', '24h']}
|
||||||
|
value={timeRanges.jobStats}
|
||||||
|
onChange={(value) =>
|
||||||
|
setTimeRanges((prev) => ({ ...prev, jobStats: value }))
|
||||||
|
}
|
||||||
|
size='small'
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
canCollapse={false}
|
key='3'
|
||||||
collapseKey='jobHistory'
|
|
||||||
>
|
>
|
||||||
<HistoryDisplay objectType='job' />
|
<Flex vertical gap={'middle'}>
|
||||||
</InfoCollapse>
|
<Card
|
||||||
</Flex>
|
style={{ height: 250, width: '100%', minWidth: '300px' }}
|
||||||
|
>
|
||||||
|
<Line
|
||||||
|
data={chartData}
|
||||||
|
xField='timestamp'
|
||||||
|
yField='value'
|
||||||
|
seriesField='type'
|
||||||
|
smooth
|
||||||
|
animation={{
|
||||||
|
appear: {
|
||||||
|
animation: 'wave-in',
|
||||||
|
duration: 1000
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
point={{
|
||||||
|
size: 4,
|
||||||
|
shape: 'circle'
|
||||||
|
}}
|
||||||
|
tooltip={{
|
||||||
|
showMarkers: false
|
||||||
|
}}
|
||||||
|
legend={{
|
||||||
|
position: 'top'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Descriptions column={1} bordered>
|
||||||
|
<Descriptions.Item label='Completed'>
|
||||||
|
{stats.totalJobs}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label='Failed'>
|
||||||
|
{stats.activeJobs}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label='Queued'>
|
||||||
|
{stats.completedJobs}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Flex>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</ScrollBox>
|
</Flex>
|
||||||
</Flex>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,323 +0,0 @@
|
|||||||
import { useEffect, useState, useContext, useMemo } from 'react'
|
|
||||||
import { Card, Spin, Segmented, Flex } from 'antd'
|
|
||||||
import { LoadingOutlined } from '@ant-design/icons'
|
|
||||||
import { Column } from '@ant-design/charts'
|
|
||||||
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'
|
|
||||||
|
|
||||||
const HistoryDisplay = ({ objectType, startDate, endDate, styles }) => {
|
|
||||||
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 { 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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])
|
|
||||||
|
|
||||||
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,
|
|
||||||
info: themeColors.colorInfo,
|
|
||||||
error: themeColors.colorError,
|
|
||||||
warning: themeColors.colorWarning,
|
|
||||||
default: '#8c8c8c'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 config = {
|
|
||||||
data: chartData,
|
|
||||||
xField: 'dateFormatted',
|
|
||||||
yField: 'value',
|
|
||||||
theme: {
|
|
||||||
type: isDarkMode ? 'dark' : 'light',
|
|
||||||
fontFamily: '"DM Sans", -apple-system, BlinkMacSystemFont, sans-serif'
|
|
||||||
},
|
|
||||||
stack: true,
|
|
||||||
colorField: 'category',
|
|
||||||
columnWidthRatio: 1,
|
|
||||||
scale: {
|
|
||||||
color: {
|
|
||||||
range: colorRange
|
|
||||||
}
|
|
||||||
},
|
|
||||||
smooth: true,
|
|
||||||
interaction: {
|
|
||||||
tooltip: {
|
|
||||||
marker: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
position: 'top'
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
appear: {
|
|
||||||
animation: 'wave-in',
|
|
||||||
duration: 1000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Spin spinning={loading} indicator={<LoadingOutlined spin />}>
|
|
||||||
<Card
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
styles={{ body: { padding: '12px', ...styles } }}
|
|
||||||
>
|
|
||||||
{!startDate && !endDate && (
|
|
||||||
<Flex justify='flex-end'>
|
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
<Column {...config} />
|
|
||||||
</Card>
|
|
||||||
</Spin>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
HistoryDisplay.propTypes = {
|
|
||||||
objectType: PropTypes.string.isRequired,
|
|
||||||
startDate: PropTypes.oneOfType([
|
|
||||||
PropTypes.string,
|
|
||||||
PropTypes.instanceOf(Date)
|
|
||||||
]),
|
|
||||||
styles: PropTypes.object,
|
|
||||||
endDate: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)])
|
|
||||||
}
|
|
||||||
|
|
||||||
export default HistoryDisplay
|
|
||||||
@ -11,18 +11,17 @@ const InfoCollapse = ({
|
|||||||
active,
|
active,
|
||||||
onToggle,
|
onToggle,
|
||||||
className = '',
|
className = '',
|
||||||
collapseKey = 'default',
|
collapseKey = 'default'
|
||||||
canCollapse = true
|
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
expandIconPosition='end'
|
expandIconPosition='end'
|
||||||
activeKey={canCollapse ? (active ? [collapseKey] : []) : [collapseKey]}
|
activeKey={active ? [collapseKey] : []}
|
||||||
onChange={(keys) => onToggle(keys.length > 0)}
|
onChange={(keys) => onToggle(keys.length > 0)}
|
||||||
expandIcon={({ isActive }) =>
|
expandIcon={({ isActive }) => (
|
||||||
canCollapse ? <CaretLeftOutlined rotate={isActive ? -90 : 0} /> : null
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
}
|
)}
|
||||||
className={`no-h-padding-collapse ${className}`}
|
className={`no-h-padding-collapse ${className}`}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
@ -49,8 +48,7 @@ InfoCollapse.propTypes = {
|
|||||||
active: PropTypes.bool.isRequired,
|
active: PropTypes.bool.isRequired,
|
||||||
onToggle: PropTypes.func.isRequired,
|
onToggle: PropTypes.func.isRequired,
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
collapseKey: PropTypes.string,
|
collapseKey: PropTypes.string
|
||||||
canCollapse: PropTypes.bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default InfoCollapse
|
export default InfoCollapse
|
||||||
|
|||||||
@ -354,14 +354,14 @@ const ObjectSelect = ({
|
|||||||
value &&
|
value &&
|
||||||
typeof value === 'object' &&
|
typeof value === 'object' &&
|
||||||
value !== null &&
|
value !== null &&
|
||||||
getValueIdentity(valueRef.current) !== getValueIdentity(value) &&
|
valueRef.current !== value &&
|
||||||
type != 'unknown'
|
type != 'unknown'
|
||||||
) {
|
) {
|
||||||
// console.log('fetching full object', value)
|
console.log('fetching full object', value)
|
||||||
valueRef.current = value
|
valueRef.current = value
|
||||||
// Check if value is a minimal object and fetch full object if needed
|
// Check if value is a minimal object and fetch full object if needed
|
||||||
const fullValue = await fetchFullObjectIfNeeded(value)
|
const fullValue = await fetchFullObjectIfNeeded(value)
|
||||||
// console.log('fullValue', fullValue)
|
console.log('fullValue', fullValue)
|
||||||
// Build a new filter from value's properties that are in the properties list
|
// Build a new filter from value's properties that are in the properties list
|
||||||
const valueFilter = { ...filter }
|
const valueFilter = { ...filter }
|
||||||
properties.forEach((prop) => {
|
properties.forEach((prop) => {
|
||||||
@ -384,7 +384,7 @@ const ObjectSelect = ({
|
|||||||
})
|
})
|
||||||
// Fetch with the new filter
|
// Fetch with the new filter
|
||||||
handleFetchObjectsProperties(valueFilter)
|
handleFetchObjectsProperties(valueFilter)
|
||||||
// console.log('setting treeSelectValue', valueRef.current._id)
|
console.log('setting treeSelectValue', valueRef.current._id)
|
||||||
setTreeSelectValue(valueRef.current._id)
|
setTreeSelectValue(valueRef.current._id)
|
||||||
setInitialized(true)
|
setInitialized(true)
|
||||||
return
|
return
|
||||||
@ -420,14 +420,13 @@ const ObjectSelect = ({
|
|||||||
token,
|
token,
|
||||||
fetchFullObjectIfNeeded,
|
fetchFullObjectIfNeeded,
|
||||||
type,
|
type,
|
||||||
connected,
|
connected
|
||||||
getValueIdentity
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const prevValuesRef = useRef({ type, masterFilter })
|
const prevValuesRef = useRef({ type, masterFilter })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// console.log('treeSelectValue', treeSelectValue)
|
console.log('treeSelectValue', treeSelectValue)
|
||||||
}, [treeSelectValue])
|
}, [treeSelectValue])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -1,319 +0,0 @@
|
|||||||
import { useEffect, useState, useContext, useRef } from 'react'
|
|
||||||
import { Flex, Alert, Card, Typography, Skeleton } from 'antd'
|
|
||||||
import PropTypes from 'prop-types'
|
|
||||||
import { getModelByName } from '../../../database/ObjectModels'
|
|
||||||
import { ApiServerContext } from '../context/ApiServerContext'
|
|
||||||
import { AuthContext } from '../context/AuthContext'
|
|
||||||
import { round } from '../utils/Utils'
|
|
||||||
|
|
||||||
const { Text } = Typography
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps stat names to Alert types for visual indication
|
|
||||||
*/
|
|
||||||
const getAlertType = (statName) => {
|
|
||||||
const name = statName.toLowerCase()
|
|
||||||
|
|
||||||
// Success states
|
|
||||||
if (
|
|
||||||
name.includes('complete') ||
|
|
||||||
name.includes('ready') ||
|
|
||||||
name.includes('standby') ||
|
|
||||||
name.includes('success')
|
|
||||||
) {
|
|
||||||
return 'success'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error states
|
|
||||||
if (
|
|
||||||
name.includes('error') ||
|
|
||||||
name.includes('failed') ||
|
|
||||||
name.includes('cancelled')
|
|
||||||
) {
|
|
||||||
return 'error'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warning states
|
|
||||||
if (
|
|
||||||
name.includes('queued') ||
|
|
||||||
name.includes('pending') ||
|
|
||||||
name.includes('waiting')
|
|
||||||
) {
|
|
||||||
return 'warning'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name.includes('printing')) {
|
|
||||||
return 'info'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default states
|
|
||||||
return 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets a nested value from an object using dot notation
|
|
||||||
* e.g., getNestedValue(obj, 'states.ready') -> obj.states.ready
|
|
||||||
*/
|
|
||||||
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 extractRollupValue = (value) => {
|
|
||||||
if (typeof value === 'number') {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
if (value && typeof value === 'object') {
|
|
||||||
// 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 the stats data object
|
|
||||||
* Handles both flattened and nested structures, including dot notation paths
|
|
||||||
* Now also handles new format with operation objects ({count: 0}, {sum: 100}, etc.)
|
|
||||||
*/
|
|
||||||
const getStatValue = (stats, statName, statDef) => {
|
|
||||||
if (!stats) return 0
|
|
||||||
|
|
||||||
// Handle special combined stats (support both 'combine' and 'sum' properties)
|
|
||||||
const combineList = statDef.combine || statDef.sum
|
|
||||||
if (combineList) {
|
|
||||||
return combineList.reduce((sum, name) => {
|
|
||||||
const value = getStatValue(stats, name, {})
|
|
||||||
return sum + (value || 0)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle dot notation paths (e.g., "states.ready")
|
|
||||||
if (statName.includes('.')) {
|
|
||||||
const value = getNestedValue(stats, statName)
|
|
||||||
return extractRollupValue(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try direct access first (flattened structure)
|
|
||||||
if (stats[statName] !== undefined) {
|
|
||||||
return extractRollupValue(stats[statName])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try nested structure (stats.states.statName)
|
|
||||||
if (stats.states && stats.states[statName] !== undefined) {
|
|
||||||
return extractRollupValue(stats.states[statName])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try with objectType prefix removed (e.g., "standbyPrinters" -> "standby")
|
|
||||||
const simplifiedName = statName.replace(
|
|
||||||
/^(standby|printing|error|offline|queued|complete|failed|cancelled|draft)/i,
|
|
||||||
(match) => {
|
|
||||||
return match.toLowerCase()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (stats[simplifiedName] !== undefined) {
|
|
||||||
return extractRollupValue(stats[simplifiedName])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stats.states && stats.states[simplifiedName] !== undefined) {
|
|
||||||
return extractRollupValue(stats.states[simplifiedName])
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts the base stat name from model stat names
|
|
||||||
* e.g., "standbyPrinters" -> "standby", "printingPrinters" -> "printing"
|
|
||||||
* Preserves dot notation paths (e.g., "states.ready" -> "states.ready")
|
|
||||||
*/
|
|
||||||
const extractBaseStatName = (statName) => {
|
|
||||||
// If it's a dot notation path, return as-is
|
|
||||||
if (statName.includes('.')) {
|
|
||||||
return statName
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove common suffixes
|
|
||||||
const baseName = statName
|
|
||||||
.replace(/Printers?$/i, '')
|
|
||||||
.replace(/Jobs?$/i, '')
|
|
||||||
.toLowerCase()
|
|
||||||
|
|
||||||
return baseName
|
|
||||||
}
|
|
||||||
|
|
||||||
const StatsDisplay = ({ objectType, stats: statsProp }) => {
|
|
||||||
const { getModelStats, connected, subscribeToModelStats } =
|
|
||||||
useContext(ApiServerContext)
|
|
||||||
const { token } = useContext(AuthContext)
|
|
||||||
const [stats, setStats] = useState(statsProp || null)
|
|
||||||
const [loading, setLoading] = useState(!statsProp)
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
const initializedRef = useRef(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// If stats are provided as prop, use them and don't fetch
|
|
||||||
if (statsProp) {
|
|
||||||
setStats(statsProp)
|
|
||||||
setLoading(false)
|
|
||||||
initializedRef.current = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, fetch stats
|
|
||||||
if (
|
|
||||||
!objectType ||
|
|
||||||
!getModelStats ||
|
|
||||||
!token ||
|
|
||||||
!connected ||
|
|
||||||
initializedRef.current == true
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchStats = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
const fetchedStats = await getModelStats(objectType)
|
|
||||||
if (fetchedStats) {
|
|
||||||
setStats(fetchedStats)
|
|
||||||
} else {
|
|
||||||
setError('Failed to fetch stats')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching stats:', err)
|
|
||||||
setError('Failed to fetch stats')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
initializedRef.current = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchStats()
|
|
||||||
}, [objectType, getModelStats, token, connected, statsProp])
|
|
||||||
|
|
||||||
// Subscribe to real-time stats updates
|
|
||||||
useEffect(() => {
|
|
||||||
// Only subscribe if stats are not provided as prop and we have the required dependencies
|
|
||||||
if (!objectType || !connected || !subscribeToModelStats) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const statsUnsubscribe = subscribeToModelStats(
|
|
||||||
objectType,
|
|
||||||
(updatedStats) => {
|
|
||||||
setStats(updatedStats)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (statsUnsubscribe) statsUnsubscribe()
|
|
||||||
}
|
|
||||||
}, [objectType, connected, subscribeToModelStats, statsProp])
|
|
||||||
|
|
||||||
if (!objectType) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const model = getModelByName(objectType)
|
|
||||||
|
|
||||||
if (!model) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const Icon = model.icon
|
|
||||||
const modelStats = model.stats || []
|
|
||||||
|
|
||||||
if (modelStats.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex justify='flex-start' gap='middle' wrap='wrap' align='flex-start'>
|
|
||||||
{modelStats.map((statDef) => {
|
|
||||||
const baseStatName = extractBaseStatName(statDef.name)
|
|
||||||
var statValue = getStatValue(stats, baseStatName, statDef)
|
|
||||||
const alertType = getAlertType(statDef.name)
|
|
||||||
const label = statDef.label || statDef.name
|
|
||||||
|
|
||||||
if (statDef?.roundNumber) {
|
|
||||||
statValue = round(statValue, statDef?.roundNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = (
|
|
||||||
<Flex vertical>
|
|
||||||
<Text type='secondary'>{label}</Text>
|
|
||||||
<Flex gap={'small'}>
|
|
||||||
{Icon && <Icon style={{ fontSize: 26 }} />}
|
|
||||||
{loading ? (
|
|
||||||
<Flex justify='center' align='center' style={{ height: 44 }}>
|
|
||||||
<Skeleton.Button
|
|
||||||
active
|
|
||||||
size='small'
|
|
||||||
style={{ width: 50, height: 30 }}
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
) : (
|
|
||||||
<Text style={{ fontSize: 28, fontWeight: 600 }}>
|
|
||||||
{statDef?.prefix}
|
|
||||||
{statValue}
|
|
||||||
{statDef?.suffix}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (alertType === 'default') {
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={statDef.name}
|
|
||||||
style={{ minWidth: statDef?.cardWidth || 175 }}
|
|
||||||
styles={{ body: { padding: '20px 24px' } }}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Alert
|
|
||||||
key={statDef.name}
|
|
||||||
type={alertType}
|
|
||||||
style={{ minWidth: statDef?.cardWidth || 175 }}
|
|
||||||
description={content}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
StatsDisplay.propTypes = {
|
|
||||||
objectType: PropTypes.string.isRequired,
|
|
||||||
stats: PropTypes.object // Optional - if not provided, will fetch automatically
|
|
||||||
}
|
|
||||||
|
|
||||||
export default StatsDisplay
|
|
||||||
@ -37,7 +37,14 @@ export const Job = {
|
|||||||
url: (_id) => `/dashboard/production/jobs/info?jobId=${_id}&action=reload`
|
url: (_id) => `/dashboard/production/jobs/info?jobId=${_id}&action=reload`
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
columns: ['_id', 'gcodeFile', 'quantity', 'state', 'createdAt'],
|
columns: [
|
||||||
|
'_id',
|
||||||
|
'gcodeFile',
|
||||||
|
'quantity',
|
||||||
|
'state',
|
||||||
|
|
||||||
|
'createdAt'
|
||||||
|
],
|
||||||
filters: ['state', '_id', 'gcodeFile', 'quantity'],
|
filters: ['state', '_id', 'gcodeFile', 'quantity'],
|
||||||
sorters: ['createdAt', 'state', 'quantity', 'gcodeFile'],
|
sorters: ['createdAt', 'state', 'quantity', 'gcodeFile'],
|
||||||
properties: [
|
properties: [
|
||||||
@ -108,38 +115,5 @@ export const Job = {
|
|||||||
required: true,
|
required: true,
|
||||||
showHyperlink: true
|
showHyperlink: true
|
||||||
}
|
}
|
||||||
],
|
|
||||||
stats: [
|
|
||||||
{
|
|
||||||
name: 'draft.count',
|
|
||||||
label: 'Draft',
|
|
||||||
type: 'number',
|
|
||||||
color: 'default'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'queued.count',
|
|
||||||
label: 'Queued',
|
|
||||||
type: 'number',
|
|
||||||
color: 'warning'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'printing.count',
|
|
||||||
label: 'Printing',
|
|
||||||
type: 'number',
|
|
||||||
color: 'info'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'failed.count',
|
|
||||||
label: 'Failed',
|
|
||||||
type: 'number',
|
|
||||||
combine: ['failed.count', 'cancelled.count'],
|
|
||||||
color: 'error'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'complete.count',
|
|
||||||
label: 'Complete',
|
|
||||||
type: 'number',
|
|
||||||
color: 'success'
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -101,14 +101,5 @@ export const PartStock = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
|
||||||
stats: [
|
|
||||||
{
|
|
||||||
name: 'totalCurrentQuantity.sum',
|
|
||||||
label: 'Total Current Quantity',
|
|
||||||
type: 'number',
|
|
||||||
roundNumber: 2,
|
|
||||||
cardWidth: 200
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -364,32 +364,5 @@ export const Printer = {
|
|||||||
required: false,
|
required: false,
|
||||||
readOnly: true
|
readOnly: true
|
||||||
}
|
}
|
||||||
],
|
|
||||||
stats: [
|
|
||||||
{
|
|
||||||
name: 'standby.count',
|
|
||||||
label: 'Ready',
|
|
||||||
type: 'number',
|
|
||||||
sum: ['standby.count', 'complete.count'],
|
|
||||||
color: 'success'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'printing.count',
|
|
||||||
label: 'Printing',
|
|
||||||
type: 'number',
|
|
||||||
color: 'info'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'error.count',
|
|
||||||
label: 'Error',
|
|
||||||
type: 'number',
|
|
||||||
color: 'error'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'offline.count',
|
|
||||||
label: 'Offline',
|
|
||||||
type: 'number',
|
|
||||||
color: 'default'
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,22 +46,13 @@ export const PurchaseOrder = {
|
|||||||
type: 'dateTime',
|
type: 'dateTime',
|
||||||
readOnly: true
|
readOnly: true
|
||||||
},
|
},
|
||||||
{
|
{ name: 'state', label: 'State', type: 'state', readOnly: true },
|
||||||
name: '_reference',
|
|
||||||
label: 'Reference',
|
|
||||||
type: 'reference',
|
|
||||||
required: true,
|
|
||||||
objectType: 'purchaseOrder',
|
|
||||||
showCopy: true,
|
|
||||||
readOnly: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'updatedAt',
|
name: 'updatedAt',
|
||||||
label: 'Updated At',
|
label: 'Updated At',
|
||||||
type: 'dateTime',
|
type: 'dateTime',
|
||||||
readOnly: true
|
readOnly: true
|
||||||
},
|
},
|
||||||
{ name: 'state', label: 'State', type: 'state', readOnly: true },
|
|
||||||
{
|
{
|
||||||
name: 'vendor',
|
name: 'vendor',
|
||||||
label: 'Vendor',
|
label: 'Vendor',
|
||||||
@ -74,10 +65,17 @@ export const PurchaseOrder = {
|
|||||||
name: 'cost',
|
name: 'cost',
|
||||||
label: 'Cost',
|
label: 'Cost',
|
||||||
type: 'netGross',
|
type: 'netGross',
|
||||||
|
required: true,
|
||||||
prefix: '£',
|
prefix: '£',
|
||||||
min: 0,
|
min: 0,
|
||||||
step: 0.01,
|
step: 0.01,
|
||||||
readOnly: true
|
value: (objectData) => {
|
||||||
|
const net = objectData?.items?.reduce(
|
||||||
|
(acc, item) => acc + item.price,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
return { net: net, gross: net }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,14 +13,8 @@ import OrderItems from '../components/Dashboard/Inventory/OrderItems.jsx'
|
|||||||
import OrderItemInfo from '../components/Dashboard/Inventory/OrderItems/OrderItemInfo.jsx'
|
import OrderItemInfo from '../components/Dashboard/Inventory/OrderItems/OrderItemInfo.jsx'
|
||||||
import Shipments from '../components/Dashboard/Inventory/Shipments.jsx'
|
import Shipments from '../components/Dashboard/Inventory/Shipments.jsx'
|
||||||
import ShipmentInfo from '../components/Dashboard/Inventory/Shipments/ShipmentInfo.jsx'
|
import ShipmentInfo from '../components/Dashboard/Inventory/Shipments/ShipmentInfo.jsx'
|
||||||
import InventoryOverview from '../components/Dashboard/Inventory/InventoryOverview.jsx'
|
|
||||||
|
|
||||||
const InventoryRoutes = [
|
const InventoryRoutes = [
|
||||||
<Route
|
|
||||||
key='overview'
|
|
||||||
path='inventory/overview'
|
|
||||||
element={<InventoryOverview />}
|
|
||||||
/>,
|
|
||||||
<Route
|
<Route
|
||||||
key='filamentstocks'
|
key='filamentstocks'
|
||||||
path='inventory/filamentstocks'
|
path='inventory/filamentstocks'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user