Added better UI and implmented missing features

This commit is contained in:
Tom Butcher 2025-06-02 00:32:22 +01:00
parent ce8f4cb038
commit b39e761546
4 changed files with 293 additions and 76 deletions

View File

@ -174,7 +174,7 @@ const AppContent = () => {
path='*' path='*'
element={ element={
<AppError <AppError
message='404! Page not found.' message='Error 404. Page not found.'
showRefresh={false} showRefresh={false}
/> />
} }

View File

@ -11,7 +11,11 @@ import {
Modal, Modal,
message, message,
Dropdown, Dropdown,
Typography Typography,
Popover,
Checkbox,
Input,
Spin
} from 'antd' } from 'antd'
import { createStyles } from 'antd-style' import { createStyles } from 'antd-style'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
@ -27,6 +31,9 @@ import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon' import ReloadIcon from '../../Icons/ReloadIcon'
import FilamentStockState from '../common/FilamentStockState' import FilamentStockState from '../common/FilamentStockState'
import TimeDisplay from '../common/TimeDisplay' import TimeDisplay from '../common/TimeDisplay'
import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon'
import useColumnVisibility from '../hooks/useColumnVisibility'
import config from '../../../config' import config from '../../../config'
@ -57,38 +64,105 @@ const FilamentStocks = () => {
const { socket } = useContext(SocketContext) const { socket } = useContext(SocketContext)
const [filamentStocksData, setFilamentStocksData] = useState([]) const [filamentStocksData, setFilamentStocksData] = useState([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({
field: 'createdAt',
order: 'descend'
})
const [newFilamentStockOpen, setNewFilamentStockOpen] = useState(false) const [newFilamentStockOpen, setNewFilamentStockOpen] = useState(false)
const [loading, setLoading] = useState(true)
const [initialized, setInitialized] = useState(false) const [initialized, setInitialized] = useState(false)
const { authenticated } = useContext(AuthContext) const { authenticated } = useContext(AuthContext)
const fetchFilamentStocksData = useCallback(async () => { const getFilterDropdown = ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName
}) => {
return (
<div style={{ padding: 8 }}>
<Space.Compact>
<Input
placeholder={'Search ' + propertyName}
value={selectedKeys[0]}
onChange={(e) =>
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
onPressEnter={() => confirm()}
style={{ width: 200, display: 'block' }}
/>
<Button
onClick={() => {
clearFilters()
confirm()
}}
icon={<XMarkIcon />}
/>
<Button
type='primary'
onClick={() => confirm()}
icon={<CheckIcon />}
/>
</Space.Compact>
</div>
)
}
const fetchFilamentStocksData = useCallback(
async (pageNum = 1, append = false) => {
try { try {
const response = await axios.get(`${config.backendUrl}/filamentstocks`, { const response = await axios.get(
`${config.backendUrl}/filamentstocks`,
{
params: {
page: pageNum,
limit: 25,
...filters,
sort: sorter.field,
order: sorter.order
},
headers: { headers: {
Accept: 'application/json' Accept: 'application/json'
}, },
withCredentials: true // Important for including cookies withCredentials: true
})
setFilamentStocksData(response.data)
setLoading(false)
} catch (err) {
messageApi.info(err)
} }
}, [messageApi]) )
const newData = response.data
setHasMore(newData.length === 25)
if (append) {
setFilamentStocksData((prev) => [...prev, ...newData])
} else {
setFilamentStocksData(newData)
}
setLoading(false)
setLazyLoading(false)
} catch (err) {
messageApi.error('Error fetching filament stocks:', err)
setLoading(false)
setLazyLoading(false)
}
},
[messageApi, filters, sorter]
)
useEffect(() => { useEffect(() => {
// Fetch initial data
if (authenticated) { if (authenticated) {
fetchFilamentStocksData() fetchFilamentStocksData()
} }
}, [authenticated, fetchFilamentStocksData]) }, [authenticated, fetchFilamentStocksData])
useEffect(() => { useEffect(() => {
// Add WebSocket event listener for real-time updates
if (socket && !initialized) { if (socket && !initialized) {
setInitialized(true) setInitialized(true)
socket.on('notify_filamentstock_update', (updateData) => { socket.on('notify_filamentstock_update', (updateData) => {
@ -134,6 +208,43 @@ const FilamentStocks = () => {
} }
} }
const handleScroll = useCallback(
(e) => {
const { target } = e
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
if (
scrollHeight - scrollTop - clientHeight < 100 &&
!lazyLoading &&
hasMore
) {
setLazyLoading(true)
const nextPage = page + 1
setPage(nextPage)
fetchFilamentStocksData(nextPage, true)
}
},
[page, lazyLoading, hasMore, fetchFilamentStocksData]
)
const handleTableChange = (pagination, filters, sorter) => {
const newFilters = {}
Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) {
newFilters[key] = value[0]
}
})
setPage(1)
setFilters(newFilters)
setSorter({
field: sorter.field,
order: sorter.order
})
fetchFilamentStocksData(1)
}
// Column definitions // Column definitions
const columns = [ const columns = [
{ {
@ -150,6 +261,20 @@ const FilamentStocks = () => {
key: 'name', key: 'name',
width: 200, width: 200,
fixed: 'left', fixed: 'left',
sorter: true,
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'filament name'
}),
render: (filament) => <Text ellipsis>{filament.name}</Text> render: (filament) => <Text ellipsis>{filament.name}</Text>
}, },
{ {
@ -171,7 +296,8 @@ const FilamentStocks = () => {
title: 'Current (g)', title: 'Current (g)',
dataIndex: 'currentNetWeight', dataIndex: 'currentNetWeight',
key: 'currentNetWeight', key: 'currentNetWeight',
width: 120, width: 140,
sorter: true,
render: (currentNetWeight) => ( render: (currentNetWeight) => (
<Text ellipsis>{currentNetWeight.toFixed(2) + 'g'}</Text> <Text ellipsis>{currentNetWeight.toFixed(2) + 'g'}</Text>
) )
@ -180,7 +306,8 @@ const FilamentStocks = () => {
title: 'Starting (g)', title: 'Starting (g)',
dataIndex: 'startingNetWeight', dataIndex: 'startingNetWeight',
key: 'startingNetWeight', key: 'startingNetWeight',
width: 120, width: 140,
sorter: true,
render: (startingNetWeight) => ( render: (startingNetWeight) => (
<Text ellipsis>{startingNetWeight.toFixed(2) + 'g'}</Text> <Text ellipsis>{startingNetWeight.toFixed(2) + 'g'}</Text>
) )
@ -190,6 +317,8 @@ const FilamentStocks = () => {
dataIndex: 'createdAt', dataIndex: 'createdAt',
key: 'createdAt', key: 'createdAt',
width: 180, width: 180,
sorter: true,
defaultSortOrder: 'descend',
render: (createdAt) => { render: (createdAt) => {
if (createdAt) { if (createdAt) {
return <TimeDisplay dateTime={createdAt} /> return <TimeDisplay dateTime={createdAt} />
@ -203,6 +332,7 @@ const FilamentStocks = () => {
dataIndex: 'updatedAt', dataIndex: 'updatedAt',
key: 'updatedAt', key: 'updatedAt',
width: 180, width: 180,
sorter: true,
render: (updatedAt) => { render: (updatedAt) => {
if (updatedAt) { if (updatedAt) {
return <TimeDisplay dateTime={updatedAt} /> return <TimeDisplay dateTime={updatedAt} />
@ -236,6 +366,39 @@ const FilamentStocks = () => {
} }
] ]
const getViewDropdownItems = () => {
const columnItems = columns
.filter((col) => col.key && col.title !== '')
.map((col) => (
<Checkbox
checked={columnVisibility[col.key]}
key={col.key}
onChange={(e) => {
updateColumnVisibility(col.key, e.target.checked)
}}
>
{col.title}
</Checkbox>
))
return (
<Flex vertical>
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
{columnItems}
</Flex>
</Flex>
)
}
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
'FilamentStocks',
columns
)
const visibleColumns = columns.filter(
(col) => !col.key || columnVisibility[col.key]
)
const actionItems = { const actionItems = {
items: [ items: [
{ {
@ -252,7 +415,8 @@ const FilamentStocks = () => {
], ],
onClick: ({ key }) => { onClick: ({ key }) => {
if (key === 'reloadList') { if (key === 'reloadList') {
fetchFilamentStocksData() setPage(1)
fetchFilamentStocksData(1)
} else if (key === 'newFilamentStock') { } else if (key === 'newFilamentStock') {
setNewFilamentStockOpen(true) setNewFilamentStockOpen(true)
} }
@ -263,19 +427,32 @@ const FilamentStocks = () => {
<> <>
<Flex vertical={'true'} gap='large'> <Flex vertical={'true'} gap='large'>
{contextHolder} {contextHolder}
<Space> <Flex justify={'space-between'}>
<Space size='small'>
<Dropdown menu={actionItems}> <Dropdown menu={actionItems}>
<Button>Actions</Button> <Button>Actions</Button>
</Dropdown> </Dropdown>
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button>View</Button>
</Popover>
</Space> </Space>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Flex>
<Table <Table
dataSource={filamentStocksData} dataSource={filamentStocksData}
className={styles.customTable} className={styles.customTable}
columns={columns} columns={visibleColumns}
pagination={false} pagination={false}
rowKey='_id' rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }} loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
scroll={{ y: 'calc(100vh - 270px)' }} scroll={{ y: 'calc(100vh - 270px)' }}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
/> />
</Flex> </Flex>
<Modal <Modal

View File

@ -11,11 +11,11 @@ import {
Checkbox, Checkbox,
Dropdown, Dropdown,
Table, Table,
Typography Typography,
Input
} from 'antd' } from 'antd'
import { createStyles } from 'antd-style' import { createStyles } from 'antd-style'
import { LoadingOutlined, AuditOutlined } from '@ant-design/icons' import { LoadingOutlined, AuditOutlined } from '@ant-design/icons'
import moment from 'moment'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { SocketContext } from '../context/SocketContext' import { SocketContext } from '../context/SocketContext'
@ -25,6 +25,9 @@ import ReloadIcon from '../../Icons/ReloadIcon'
import PlusMinusIcon from '../../Icons/PlusMinusIcon' import PlusMinusIcon from '../../Icons/PlusMinusIcon'
import SubJobIcon from '../../Icons/SubJobIcon' import SubJobIcon from '../../Icons/SubJobIcon'
import PlayCircleIcon from '../../Icons/PlayCircleIcon' import PlayCircleIcon from '../../Icons/PlayCircleIcon'
import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon'
import useColumnVisibility from '../hooks/useColumnVisibility'
import config from '../../../config' import config from '../../../config'
@ -54,6 +57,16 @@ const StockEvents = () => {
const { socket } = useContext(SocketContext) const { socket } = useContext(SocketContext)
const [initialized, setInitialized] = useState(false) const [initialized, setInitialized] = useState(false)
// Helper function to convert text to camelCase
const toCamelCase = (text) => {
return text
.toLowerCase()
.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => {
return index === 0 ? word.toLowerCase() : word.toUpperCase()
})
.replace(/\s+/g, '')
}
const [stockEventsData, setStockEventsData] = useState([]) const [stockEventsData, setStockEventsData] = useState([])
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true) const [hasMore, setHasMore] = useState(true)
@ -61,7 +74,10 @@ const StockEvents = () => {
const [lazyLoading, setLazyLoading] = useState(false) const [lazyLoading, setLazyLoading] = useState(false)
const [filters, setFilters] = useState({}) const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({}) const [sorter, setSorter] = useState({
field: 'createdAt',
order: 'descend'
})
// Column definitions for visibility // Column definitions for visibility
const columns = [ const columns = [
@ -87,40 +103,27 @@ const StockEvents = () => {
dataIndex: 'type', dataIndex: 'type',
key: 'type', key: 'type',
width: 200, width: 200,
sorter: (a, b) => a.type.localeCompare(b.type), sorter: true,
filters: [ filterDropdown: ({
{ text: 'Sub Job', value: 'Sub Job' }, setSelectedKeys,
{ text: 'Audit Adjustment', value: 'Audit Adjustment' }, selectedKeys,
{ text: 'Initial', value: 'Initial' } confirm,
], clearFilters
onFilter: (value, record) => { }) =>
const recordType = record.type.toLowerCase() getFilterDropdown({
if (recordType === 'subjob') { setSelectedKeys,
return value === 'Sub Job' selectedKeys,
} else if (recordType === 'audit') { confirm,
return value === 'Audit Adjustment' clearFilters,
} propertyName: 'type'
return ( })
value === recordType.charAt(0).toUpperCase() + recordType.slice(1)
)
},
render: (type) => {
switch (type.toLowerCase()) {
case 'subjob':
return 'Sub Job'
case 'audit':
return 'Audit Adjustment'
default:
return type.charAt(0).toUpperCase() + type.slice(1)
}
}
}, },
{ {
title: <PlusMinusIcon />, title: <PlusMinusIcon />,
dataIndex: 'value', dataIndex: 'value',
key: 'value', key: 'value',
width: 100, width: 100,
sorter: (a, b) => a.value - b.value, sorter: true,
render: (value, record) => { render: (value, record) => {
const formattedValue = value.toFixed(2) + record.unit const formattedValue = value.toFixed(2) + record.unit
return ( return (
@ -180,8 +183,8 @@ const StockEvents = () => {
dataIndex: 'createdAt', dataIndex: 'createdAt',
key: 'createdAt', key: 'createdAt',
width: 180, width: 180,
sorter: true,
defaultSortOrder: 'descend', defaultSortOrder: 'descend',
sorter: (a, b) => moment(a.createdAt).unix() - moment(b.createdAt).unix(),
render: (createdAt) => { render: (createdAt) => {
if (createdAt) { if (createdAt) {
return <TimeDisplay dateTime={createdAt} /> return <TimeDisplay dateTime={createdAt} />
@ -195,7 +198,7 @@ const StockEvents = () => {
dataIndex: 'updatedAt', dataIndex: 'updatedAt',
key: 'updatedAt', key: 'updatedAt',
width: 180, width: 180,
sorter: (a, b) => moment(a.updatedAt).unix() - moment(b.updatedAt).unix(), sorter: true,
render: (updatedAt) => { render: (updatedAt) => {
if (updatedAt) { if (updatedAt) {
return <TimeDisplay dateTime={updatedAt} /> return <TimeDisplay dateTime={updatedAt} />
@ -206,15 +209,47 @@ const StockEvents = () => {
} }
] ]
const [columnVisibility, setColumnVisibility] = useState( const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
columns.reduce((acc, col) => { 'StockEvents',
if (col.key) { columns
acc[col.key] = true
}
return acc
}, {})
) )
const getFilterDropdown = ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName
}) => {
return (
<div style={{ padding: 8 }}>
<Space.Compact>
<Input
placeholder={'Search ' + propertyName}
value={selectedKeys[0]}
onChange={(e) =>
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
onPressEnter={() => confirm()}
style={{ width: 200, display: 'block' }}
/>
<Button
onClick={() => {
clearFilters()
confirm()
}}
icon={<XMarkIcon />}
/>
<Button
type='primary'
onClick={() => confirm()}
icon={<CheckIcon />}
/>
</Space.Compact>
</div>
)
}
const { authenticated } = useContext(AuthContext) const { authenticated } = useContext(AuthContext)
const fetchStockEventsData = useCallback( const fetchStockEventsData = useCallback(
@ -224,6 +259,7 @@ const StockEvents = () => {
params: { params: {
page: pageNum, page: pageNum,
limit: 25, limit: 25,
type: filters.type,
...filters, ...filters,
sort: sorter.field, sort: sorter.field,
order: sorter.order order: sorter.order
@ -341,8 +377,13 @@ const StockEvents = () => {
const newFilters = {} const newFilters = {}
Object.entries(filters).forEach(([key, value]) => { Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) { if (value && value.length > 0) {
// Convert type filter to camelCase
if (key === 'type') {
newFilters[key] = toCamelCase(value[0])
} else {
newFilters[key] = value[0] newFilters[key] = value[0]
} }
}
}) })
setPage(1) setPage(1)
setFilters(newFilters) setFilters(newFilters)
@ -350,6 +391,8 @@ const StockEvents = () => {
field: sorter.field, field: sorter.field,
order: sorter.order order: sorter.order
}) })
// Trigger a new fetch with the updated filters
fetchStockEventsData(1)
} }
const getViewDropdownItems = () => { const getViewDropdownItems = () => {
@ -360,10 +403,7 @@ const StockEvents = () => {
checked={columnVisibility[col.key]} checked={columnVisibility[col.key]}
key={col.key} key={col.key}
onChange={(e) => { onChange={(e) => {
setColumnVisibility((prev) => ({ updateColumnVisibility(col.key, e.target.checked)
...prev,
[col.key]: e.target.checked
}))
}} }}
> >
{col.title} {col.title}

View File

@ -91,7 +91,7 @@ const Settings = () => {
<Select <Select
value={getCurrentThemeValue()} value={getCurrentThemeValue()}
onChange={handleThemeChange} onChange={handleThemeChange}
style={{ width: '200px' }} style={{ width: '100%' }}
> >
<Option value='light'>Light</Option> <Option value='light'>Light</Option>
<Option value='dark'>Dark</Option> <Option value='dark'>Dark</Option>
@ -102,7 +102,7 @@ const Settings = () => {
<Select <Select
value={isCompact ? 'compact' : 'comfortable'} value={isCompact ? 'compact' : 'comfortable'}
onChange={handleCompactChange} onChange={handleCompactChange}
style={{ width: '200px' }} style={{ width: '100%' }}
> >
<Option value='comfortable'>Comfortable</Option> <Option value='comfortable'>Comfortable</Option>
<Option value='compact'>Compact</Option> <Option value='compact'>Compact</Option>