Added dayjs dependency for date manipulation and refactored components to replace IdText with IdDisplay for consistent ID representation. Updated logging to use loglevel for improved debugging and removed console logs for cleaner code.
1
package-lock.json
generated
@ -18,6 +18,7 @@
|
||||
"antd-style": "^3.7.1",
|
||||
"axios": "^1.9.0",
|
||||
"country-list": "^2.3.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.5.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
"antd-style": "^3.7.1",
|
||||
"axios": "^1.9.0",
|
||||
"country-list": "^2.3.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.5.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
|
||||
@ -110,7 +110,7 @@ code {
|
||||
margin-bottom: 0.15em;
|
||||
}
|
||||
|
||||
.idtext .ant-popover-inner {
|
||||
.iddisplay .ant-popover-inner {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
BIN
src/assets/icons/eyeicon.afdesign
Normal file
1
src/assets/icons/eyeicon.min.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 64 64"><path d="M43.116 54.548c25.906 0 43.105-20.844 43.105-27.249 0-6.406-17.22-27.228-43.105-27.228C17.604.071 0 20.893 0 27.299c0 6.405 17.583 27.249 43.116 27.249m0-6.806c-19.53 0-34.805-16.126-34.805-20.443 0-3.599 15.275-20.422 34.805-20.422 19.467 0 34.794 16.823 34.794 20.422 0 4.317-15.327 20.443-34.794 20.443m.01-4.136c9.062 0 16.359-7.357 16.359-16.359 0-9.042-7.297-16.339-16.359-16.339-9.051 0-16.38 7.287-16.38 16.339 0 9.002 7.329 16.359 16.38 16.359m-.02-11.141c-2.851 0-5.146-2.336-5.146-5.187s2.295-5.166 5.146-5.166a5.167 5.167 0 0 1 5.187 5.166 5.185 5.185 0 0 1-5.187 5.187" style="fill-rule:nonzero" transform="translate(1 12.362)scale(.71908)"/></svg>
|
||||
|
After Width: | Height: | Size: 837 B |
7
src/assets/icons/eyeicon.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<?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.719082,0,0,0.719082,1,12.3624)">
|
||||
<path d="M43.116,54.548C69.022,54.548 86.221,33.704 86.221,27.299C86.221,20.893 69.001,0.071 43.116,0.071C17.604,0.071 0,20.893 0,27.299C0,33.704 17.583,54.548 43.116,54.548ZM43.116,47.742C23.586,47.742 8.311,31.616 8.311,27.299C8.311,23.7 23.586,6.877 43.116,6.877C62.583,6.877 77.91,23.7 77.91,27.299C77.91,31.616 62.583,47.742 43.116,47.742ZM43.126,43.606C52.188,43.606 59.485,36.249 59.485,27.247C59.485,18.205 52.188,10.908 43.126,10.908C34.075,10.908 26.746,18.195 26.746,27.247C26.746,36.249 34.075,43.606 43.126,43.606ZM43.106,32.465C40.255,32.465 37.96,30.129 37.96,27.278C37.96,24.427 40.255,22.112 43.106,22.112C45.987,22.112 48.293,24.427 48.293,27.278C48.293,30.129 45.987,32.465 43.106,32.465Z" style="fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/icons/eyeslashicon.afdesign
Normal file
1
src/assets/icons/eyeslashicon.min.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 64 64"><path d="M20.606 21.108C13.038 26.119 8.311 32.214 8.311 34.217c0 4.317 15.275 20.443 34.805 20.443 3.398 0 6.669-.492 9.743-1.348l5.6 5.591a46.8 46.8 0 0 1-15.343 2.562C17.583 61.465 0 40.622 0 34.217 0 30.576 5.686 22.279 15.29 15.8zm65.615 13.109c0 3.575-5.359 11.649-14.609 18.082l-5.253-5.248c7.132-4.794 11.551-10.516 11.551-12.834 0-3.6-15.327-20.423-34.794-20.423-3.083 0-6.06.42-8.87 1.175L28.592 9.32a46.3 46.3 0 0 1 14.524-2.332c25.885 0 43.105 20.823 43.105 27.229M48.97 49.429a15.9 15.9 0 0 1-5.844 1.094c-9.051 0-16.38-7.357-16.38-16.359 0-2.061.38-4.031 1.091-5.836zm10.515-15.265a15.9 15.9 0 0 1-.85 5.171L37.949 18.669a16.1 16.1 0 0 1 5.177-.843c9.062 0 16.359 7.297 16.359 16.338m8.524 28.02c1.019 1.018 2.597 1.061 3.605 0 1.052-1.061 1.019-2.587 0-3.605L18.182 5.177c-.997-.997-2.618-.997-3.636 0-.966.966-.966 2.649 0 3.605z" style="fill-rule:nonzero" transform="translate(1 7.77)scale(.71908)"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
8
src/assets/icons/eyeslashicon.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.719082,0,0,0.719082,1,7.76939)">
|
||||
<path d="M20.606,21.108C13.038,26.119 8.311,32.214 8.311,34.217C8.311,38.534 23.586,54.66 43.116,54.66C46.514,54.66 49.785,54.168 52.859,53.312L58.459,58.903C53.82,60.508 48.668,61.465 43.116,61.465C17.583,61.465 0,40.622 0,34.217C0,30.576 5.686,22.279 15.29,15.8L20.606,21.108ZM86.221,34.217C86.221,37.792 80.862,45.866 71.612,52.299L66.359,47.051C73.491,42.257 77.91,36.535 77.91,34.217C77.91,30.617 62.583,13.794 43.116,13.794C40.033,13.794 37.056,14.214 34.246,14.969L28.592,9.32C33.034,7.855 37.91,6.988 43.116,6.988C69.001,6.988 86.221,27.811 86.221,34.217ZM48.97,49.429C47.163,50.141 45.19,50.523 43.126,50.523C34.075,50.523 26.746,43.166 26.746,34.164C26.746,32.103 27.126,30.133 27.837,28.328L48.97,49.429ZM59.485,34.164C59.485,35.972 59.191,37.713 58.635,39.335L37.949,18.669C39.572,18.118 41.315,17.826 43.126,17.826C52.188,17.826 59.485,25.123 59.485,34.164Z" style="fill-rule:nonzero;"/>
|
||||
<path d="M68.009,62.184C69.028,63.202 70.606,63.245 71.614,62.184C72.666,61.123 72.633,59.597 71.614,58.579L18.182,5.177C17.185,4.18 15.564,4.18 14.546,5.177C13.58,6.143 13.58,7.826 14.546,8.782L68.009,62.184Z" style="fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/assets/icons/linkicon.afdesign
Normal file
1
src/assets/icons/linkicon.min.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 64 64"><path d="m37.543 20.715-5.797 5.797c3.078.359 5.672 1.515 7.344 3.187 5.047 5.032 5.047 12.063.062 17.047L28.465 57.402c-5.016 5-12 5-17.016-.015-5.047-5.047-5.047-12.031-.047-17.047l4.016-4c-1.219-2.766-1.734-6.359-.813-9.438l-8.609 8.532c-8.016 7.953-7.984 19.328.031 27.343 8.047 8.063 19.375 8.032 27.344.063l11.188-11.188c7.984-7.984 8-19.328-.047-27.343-1.578-1.594-4-2.969-6.969-3.594m-6.25 27.375 5.797-5.797c-3.078-.344-5.672-1.5-7.344-3.172-5.031-5.047-5.047-12.062-.047-17.047l10.672-10.656c5.016-5.016 12-5.016 17.031.016 5.032 5.031 5.016 12.047.032 17.031l-4 4c1.203 2.797 1.703 6.359.812 9.453l8.61-8.547c8-7.953 7.984-19.312-.032-27.344-8.062-8.047-19.39-8.015-27.375-.031L24.293 17.152c-7.984 7.985-8 19.329.031 27.344 1.594 1.594 4 2.969 6.969 3.594" style="fill-rule:nonzero" transform="translate(2.99 3)scale(.84278)"/></svg>
|
||||
|
After Width: | Height: | Size: 1012 B |
7
src/assets/icons/linkicon.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<?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.842775,0,0,0.842775,2.98927,3)">
|
||||
<path d="M37.543,20.715L31.746,26.512C34.824,26.871 37.418,28.027 39.09,29.699C44.137,34.731 44.137,41.762 39.152,46.746L28.465,57.402C23.449,62.402 16.465,62.402 11.449,57.387C6.402,52.34 6.402,45.356 11.402,40.34L15.418,36.34C14.199,33.574 13.684,29.981 14.605,26.902L5.996,35.434C-2.02,43.387 -1.988,54.762 6.027,62.777C14.074,70.84 25.402,70.809 33.371,62.84L44.559,51.652C52.543,43.668 52.559,32.324 44.512,24.309C42.934,22.715 40.512,21.34 37.543,20.715ZM31.293,48.09L37.09,42.293C34.012,41.949 31.418,40.793 29.746,39.121C24.715,34.074 24.699,27.059 29.699,22.074L40.371,11.418C45.387,6.402 52.371,6.402 57.402,11.434C62.434,16.465 62.418,23.481 57.434,28.465L53.434,32.465C54.637,35.262 55.137,38.824 54.246,41.918L62.856,33.371C70.856,25.418 70.84,14.059 62.824,6.027C54.762,-2.02 43.434,-1.988 35.449,5.996L24.293,17.152C16.309,25.137 16.293,36.481 24.324,44.496C25.918,46.09 28.324,47.465 31.293,48.09Z" style="fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/assets/icons/newmailicon.afdesign
Normal file
1
src/assets/icons/newmailicon.min.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 64 64"><path d="M70.859 15.422q-.002.898.111 1.766H23.795L48.25 39.25c.813.719 1.625 1.094 2.484 1.094.875 0 1.688-.375 2.5-1.094l19.128-17.256c.934 2 2.3 3.761 3.972 5.168l-10.491 9.447 16.688 16.688V30.348c1.192.319 2.446.48 3.735.48 1.282 0 2.53-.159 3.718-.477v24.946c0 7.078-3.906 10.969-10.515 10.969H22.547c-7.109 0-11.031-3.891-11.031-10.969V20.703c0-7.078 3.906-10.969 10.515-10.969h49.936a15 15 0 0 0-1.108 5.688M57.234 44.359c-2.125 1.922-4.109 2.735-6.5 2.735-2.375 0-4.375-.813-6.484-2.735l-3.741-3.366-17.803 17.815q.067.006.138.005h55.781q.086 0 .166-.006L60.975 40.991zm-38.265 8.938 16.679-16.679-16.679-15.009z" style="fill-rule:nonzero" transform="translate(-7.354 6.26)scale(.72541)"/><path d="M86.266 26.125c5.843 0 10.718-4.859 10.718-10.703 0-5.859-4.875-10.719-10.718-10.719-5.844 0-10.703 4.86-10.703 10.719 0 5.844 4.859 10.703 10.703 10.703" style="fill-rule:nonzero" transform="translate(-7.354 6.26)scale(.72541)"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
8
src/assets/icons/newmailicon.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.725411,0,0,0.725411,-7.35354,6.25926)">
|
||||
<path d="M70.859,15.422C70.859,16.02 70.894,16.61 70.97,17.188L23.795,17.188L48.25,39.25C49.063,39.969 49.875,40.344 50.734,40.344C51.609,40.344 52.422,39.969 53.234,39.25L72.362,21.994C73.296,23.994 74.662,25.755 76.334,27.162L65.843,36.609L82.531,53.297L82.531,30.348C83.723,30.667 84.977,30.828 86.266,30.828C87.548,30.828 88.796,30.669 89.984,30.351L89.984,55.297C89.984,62.375 86.078,66.266 79.469,66.266L22.547,66.266C15.438,66.266 11.516,62.375 11.516,55.297L11.516,20.703C11.516,13.625 15.422,9.734 22.031,9.734L71.967,9.734C71.249,11.493 70.859,13.415 70.859,15.422ZM57.234,44.359C55.109,46.281 53.125,47.094 50.734,47.094C48.359,47.094 46.359,46.281 44.25,44.359L40.509,40.993L22.706,58.808C22.75,58.812 22.797,58.813 22.844,58.813L78.625,58.813C78.682,58.813 78.738,58.812 78.791,58.807L60.975,40.991L57.234,44.359ZM18.969,53.297L35.648,36.618L18.969,21.609L18.969,53.297Z" style="fill-rule:nonzero;"/>
|
||||
<path d="M86.266,26.125C92.109,26.125 96.984,21.266 96.984,15.422C96.984,9.563 92.109,4.703 86.266,4.703C80.422,4.703 75.563,9.563 75.563,15.422C75.563,21.266 80.422,26.125 86.266,26.125Z" style="fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@ -29,9 +29,7 @@ const AppParticles = () => {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const particlesLoaded = useCallback(() => {
|
||||
console.log('Particles Loaded!')
|
||||
}, [])
|
||||
const particlesLoaded = useCallback(() => {}, [])
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
|
||||
@ -19,7 +19,7 @@ import { AuthContext } from '../context/AuthContext'
|
||||
import { PrintServerContext } from '../context/PrintServerContext'
|
||||
|
||||
import NewFilamentStock from './FilamentStocks/NewFilamentStock'
|
||||
import IdText from '../common/IdText'
|
||||
import IdDisplay from '../common/IdDisplay'
|
||||
import FilamentStockIcon from '../../Icons/FilamentStockIcon'
|
||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||
import PlusIcon from '../../Icons/PlusIcon'
|
||||
@ -124,7 +124,7 @@ const FilamentStocks = () => {
|
||||
key: 'id',
|
||||
width: 180,
|
||||
render: (text) => (
|
||||
<IdText id={text} type={'filamentstock'} longId={false} />
|
||||
<IdDisplay id={text} type={'filamentstock'} longId={false} />
|
||||
)
|
||||
},
|
||||
{
|
||||
@ -216,7 +216,6 @@ const FilamentStocks = () => {
|
||||
if (printServer && !initialized) {
|
||||
setInitialized(true)
|
||||
printServer.on('notify_filamentstock_update', (updateData) => {
|
||||
console.log('Received filament stock update:', updateData)
|
||||
if (tableRef.current) {
|
||||
tableRef.current.updateData(updateData._id, updateData)
|
||||
}
|
||||
@ -225,7 +224,6 @@ const FilamentStocks = () => {
|
||||
|
||||
return () => {
|
||||
if (printServer && initialized) {
|
||||
console.log('Deregistering filament stock update listener')
|
||||
printServer.off('notify_filamentstock_update')
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
Checkbox
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||
import IdText from '../../common/IdText'
|
||||
import IdDisplay from '../../common/IdDisplay'
|
||||
import { PrintServerContext } from '../../context/PrintServerContext'
|
||||
import FilamentStockState from '../../common/FilamentStockState'
|
||||
import StockEventTable from '../../common/StockEventTable'
|
||||
@ -78,7 +78,6 @@ const FilamentStockInfo = () => {
|
||||
if (printServer && !initialized && filamentStockId) {
|
||||
setInitialized(true)
|
||||
printServer.on('notify_filamentstock_update', (statusUpdate) => {
|
||||
console.log('GOT FILAMENT STOCK UPDATE', statusUpdate)
|
||||
setFilamentStockData((prevData) => {
|
||||
if (statusUpdate?._id === filamentStockId) {
|
||||
return {
|
||||
@ -92,7 +91,6 @@ const FilamentStockInfo = () => {
|
||||
}
|
||||
return () => {
|
||||
if (printServer && initialized) {
|
||||
console.log('Deregistering filament stock update listener')
|
||||
printServer.off('notify_filamentstock_update')
|
||||
}
|
||||
}
|
||||
@ -260,7 +258,7 @@ const FilamentStockInfo = () => {
|
||||
{/* Read-only fields */}
|
||||
<Descriptions.Item label='ID' span={1}>
|
||||
{filamentStockData?.id ? (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={filamentStockData.id}
|
||||
type={'filamentstock'}
|
||||
/>
|
||||
@ -316,7 +314,7 @@ const FilamentStockInfo = () => {
|
||||
|
||||
<Descriptions.Item label='Filament ID' span={1}>
|
||||
{filamentStockData?.filament ? (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={filamentStockData.filament.id}
|
||||
type={'filament'}
|
||||
showHyperlink={true}
|
||||
|
||||
@ -90,7 +90,7 @@ const LoadFilamentStock = ({
|
||||
)
|
||||
)
|
||||
}
|
||||
console.log(statusUpdate)
|
||||
logger.debug(statusUpdate)
|
||||
}
|
||||
|
||||
printServer.emit('printer.objects.subscribe', params)
|
||||
|
||||
@ -7,7 +7,7 @@ import { Button, Flex, Space, Modal, message, Dropdown, Typography } from 'antd'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
|
||||
import NewPartStock from './PartStocks/NewPartStock'
|
||||
import IdText from '../common/IdText'
|
||||
import IdDisplay from '../common/IdDisplay'
|
||||
import PartStockIcon from '../../Icons/PartStockIcon'
|
||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||
import PlusIcon from '../../Icons/PlusIcon'
|
||||
@ -69,7 +69,9 @@ const PartStocks = () => {
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'partstock'} longId={false} />
|
||||
render: (text) => (
|
||||
<IdDisplay id={text} type={'partstock'} longId={false} />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'State',
|
||||
|
||||
@ -5,7 +5,7 @@ import { Button, Flex, Space, message, Dropdown, Typography } from 'antd'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import { PrintServerContext } from '../context/PrintServerContext'
|
||||
|
||||
import IdText from '../common/IdText'
|
||||
import IdDisplay from '../common/IdDisplay'
|
||||
import StockAuditIcon from '../../Icons/StockAuditIcon'
|
||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||
import PlusIcon from '../../Icons/PlusIcon'
|
||||
@ -30,7 +30,6 @@ const StockAudits = () => {
|
||||
if (printServer && !initialized) {
|
||||
setInitialized(true)
|
||||
printServer.on('notify_stockaudit_update', (updateData) => {
|
||||
console.log('Received stock audit update:', updateData)
|
||||
if (tableRef.current) {
|
||||
tableRef.current.updateData(updateData._id, updateData)
|
||||
}
|
||||
@ -39,7 +38,6 @@ const StockAudits = () => {
|
||||
|
||||
return () => {
|
||||
if (printServer && initialized) {
|
||||
console.log('Deregistering stock audit update listener')
|
||||
printServer.off('notify_stockaudit_update')
|
||||
}
|
||||
}
|
||||
@ -76,7 +74,9 @@ const StockAudits = () => {
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'stockaudit'} longId={false} />
|
||||
render: (text) => (
|
||||
<IdDisplay id={text} type={'stockaudit'} longId={false} />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
|
||||
import { AuthContext } from '../../context/AuthContext'
|
||||
import IdText from '../../common/IdText'
|
||||
import IdDisplay from '../../common/IdDisplay'
|
||||
import TimeDisplay from '../../common/TimeDisplay'
|
||||
|
||||
import config from '../../../../config'
|
||||
@ -105,7 +105,7 @@ const StockAuditInfo = () => {
|
||||
key: 'id',
|
||||
width: 180,
|
||||
render: (text) => (
|
||||
<IdText id={text} type={'stockaudititem'} longId={false} />
|
||||
<IdDisplay id={text} type={'stockaudititem'} longId={false} />
|
||||
)
|
||||
},
|
||||
{
|
||||
@ -180,7 +180,11 @@ const StockAuditInfo = () => {
|
||||
<Title level={4}>Stock Audit Details</Title>
|
||||
<Descriptions bordered>
|
||||
<Descriptions.Item label='ID'>
|
||||
<IdText id={stockAudit._id} type={'stockaudit'} longId={true} />
|
||||
<IdDisplay
|
||||
id={stockAudit._id}
|
||||
type={'stockaudit'}
|
||||
longId={true}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Status'>
|
||||
{getStatusTag(stockAudit.status)}
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import { PrintServerContext } from '../context/PrintServerContext'
|
||||
import IdText from '../common/IdText'
|
||||
import IdDisplay from '../common/IdDisplay'
|
||||
import TimeDisplay from '../common/TimeDisplay'
|
||||
import ReloadIcon from '../../Icons/ReloadIcon'
|
||||
import PlusMinusIcon from '../../Icons/PlusMinusIcon'
|
||||
@ -75,7 +75,7 @@ const StockEvents = () => {
|
||||
dataIndex: '_id',
|
||||
width: 170,
|
||||
render: (id) => {
|
||||
return <IdText id={id} longId={false} type={'stockevent'} />
|
||||
return <IdDisplay id={id} longId={false} type={'stockevent'} />
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -100,7 +100,7 @@ const StockEvents = () => {
|
||||
render: (record) => {
|
||||
if (record.filamentStock?._id) {
|
||||
return (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={record.filamentStock._id}
|
||||
longId={false}
|
||||
showHyperlink={true}
|
||||
@ -119,7 +119,7 @@ const StockEvents = () => {
|
||||
const ids = (
|
||||
<Flex gap={'small'} wrap>
|
||||
{record.job ? (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={record.job}
|
||||
longId={false}
|
||||
showHyperlink={true}
|
||||
@ -127,14 +127,14 @@ const StockEvents = () => {
|
||||
/>
|
||||
) : null}
|
||||
{record.subJob?.number ? (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={record.subJob.number.toString().padStart(6, '0')}
|
||||
longId={false}
|
||||
type={'subjob'}
|
||||
/>
|
||||
) : null}
|
||||
{record.stockAudit ? (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={record.stockAudit._id}
|
||||
longId={false}
|
||||
type={'stockaudit'}
|
||||
@ -228,7 +228,6 @@ const StockEvents = () => {
|
||||
if (printServer && !initialized) {
|
||||
setInitialized(true)
|
||||
printServer.on('notify_stockevent_update', (updateData) => {
|
||||
console.log('Received stock event update:', updateData)
|
||||
if (tableRef.current) {
|
||||
tableRef.current.updateData(updateData._id, updateData)
|
||||
}
|
||||
@ -237,7 +236,6 @@ const StockEvents = () => {
|
||||
|
||||
return () => {
|
||||
if (printServer && initialized) {
|
||||
console.log('Deregistering stock event update listener')
|
||||
printServer.off('notify_stockevent_update')
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
} from 'antd'
|
||||
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import IdText from '../common/IdText'
|
||||
import IdDisplay from '../common/IdDisplay'
|
||||
import ReloadIcon from '../../Icons/ReloadIcon'
|
||||
import useColumnVisibility from '../hooks/useColumnVisibility'
|
||||
import TimeDisplay from '../common/TimeDisplay'
|
||||
@ -66,7 +66,7 @@ const formatValue = (value, propertyName) => {
|
||||
|
||||
if (isObjectId(value)) {
|
||||
return (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={value}
|
||||
type={propertyName.toLowerCase().replaceAll('current', '')}
|
||||
longId={false}
|
||||
@ -101,7 +101,9 @@ const AuditLogs = () => {
|
||||
key: 'id',
|
||||
fixed: 'left',
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'auditlog'} longId={false} />,
|
||||
render: (text) => (
|
||||
<IdDisplay id={text} type={'auditlog'} longId={false} />
|
||||
),
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
@ -147,7 +149,7 @@ const AuditLogs = () => {
|
||||
key: 'owner',
|
||||
width: 180,
|
||||
render: (record) => (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={record.owner._id}
|
||||
type={record.ownerModel.toLowerCase()}
|
||||
longId={false}
|
||||
@ -160,7 +162,7 @@ const AuditLogs = () => {
|
||||
key: 'target',
|
||||
width: 180,
|
||||
render: (record) => (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={record.target}
|
||||
type={record.targetModel.toLowerCase()}
|
||||
longId={false}
|
||||
|
||||
@ -23,7 +23,7 @@ import { LoadingOutlined } from '@ant-design/icons'
|
||||
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import NewFilament from './Filaments/NewFilament'
|
||||
import IdText from '../common/IdText'
|
||||
import IdDisplay from '../common/IdDisplay'
|
||||
import FilamentIcon from '../../Icons/FilamentIcon'
|
||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||
import PlusIcon from '../../Icons/PlusIcon'
|
||||
@ -283,7 +283,9 @@ const Filaments = () => {
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'filament'} longId={false} />,
|
||||
render: (text) => (
|
||||
<IdDisplay id={text} type={'filament'} longId={false} />
|
||||
),
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
|
||||
@ -1,62 +1,30 @@
|
||||
import React, { useState, useEffect, useContext, useCallback } from 'react'
|
||||
import React from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import {
|
||||
Descriptions,
|
||||
Spin,
|
||||
Space,
|
||||
Button,
|
||||
message,
|
||||
Badge,
|
||||
Typography,
|
||||
Flex,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
ColorPicker,
|
||||
Select,
|
||||
Dropdown,
|
||||
Popover,
|
||||
Checkbox,
|
||||
Card,
|
||||
Tag
|
||||
} from 'antd'
|
||||
import { Space, Button, Flex, Dropdown, Card } from 'antd'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import loglevel from 'loglevel'
|
||||
import config from '../../../../config'
|
||||
import IdText from '../../common/IdText'
|
||||
import ReloadIcon from '../../../Icons/ReloadIcon'
|
||||
import EditIcon from '../../../Icons/EditIcon.jsx'
|
||||
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
|
||||
import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
||||
import TimeDisplay from '../../common/TimeDisplay.jsx'
|
||||
import VendorSelect from '../../common/VendorSelect'
|
||||
import useCollapseState from '../../hooks/useCollapseState'
|
||||
import AuditLogTable from '../../common/AuditLogTable'
|
||||
import DashboardNotes from '../../common/DashboardNotes'
|
||||
import InfoCollapse from '../../common/InfoCollapse'
|
||||
import ObjectInfo from '../../common/ObjectInfo'
|
||||
import ViewButton from '../../common/ViewButton'
|
||||
|
||||
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
|
||||
import NoteIcon from '../../../Icons/NoteIcon.jsx'
|
||||
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
|
||||
import LockIcon from '../../../Icons/LockIcon.jsx'
|
||||
import { ApiServerContext } from '../../context/ApiServerContext'
|
||||
import EditObjectForm from '../../common/EditObjectForm'
|
||||
import EditButtons from '../../common/EditButtons'
|
||||
import LockIndicator from './LockIndicator'
|
||||
|
||||
const log = loglevel.getLogger('FilamentInfo')
|
||||
log.setLevel(config.logLevel)
|
||||
|
||||
const { Link, Text } = Typography
|
||||
|
||||
const FilamentInfo = () => {
|
||||
const [filamentData, setFilamentData] = useState(null)
|
||||
const [fetchLoading, setFetchLoading] = useState(true)
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
const [lockUser, setLockUser] = useState(null)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const location = useLocation()
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const filamentId = new URLSearchParams(location.search).get('filamentId')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const [collapseState, updateCollapseState] = useCollapseState(
|
||||
'FilamentInfo',
|
||||
{
|
||||
@ -66,600 +34,217 @@ const FilamentInfo = () => {
|
||||
auditLogs: true
|
||||
}
|
||||
)
|
||||
const {
|
||||
apiServer,
|
||||
fetchObjectInfo,
|
||||
updateObjectInfo,
|
||||
lockObject,
|
||||
unlockObject,
|
||||
onLockEvent,
|
||||
onUpdateEvent,
|
||||
fetchObjectLock,
|
||||
showError
|
||||
} = useContext(ApiServerContext)
|
||||
|
||||
// Define the event handler function
|
||||
const lockEventHandler = useCallback((lockEvent) => {
|
||||
if (lockEvent.locked === true) {
|
||||
setLockUser(lockEvent.user)
|
||||
} else {
|
||||
setLockUser(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Cleanup effect for component unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (filamentId) {
|
||||
// Ensure any remaining locks are released when component unmounts
|
||||
unlockObject(filamentId, 'filament')
|
||||
}
|
||||
}
|
||||
}, [filamentId, unlockObject])
|
||||
|
||||
useEffect(() => {
|
||||
if (filamentData) {
|
||||
form.setFieldsValue({
|
||||
name: filamentData.name || '',
|
||||
brand: filamentData.brand || '',
|
||||
type: filamentData.type || '',
|
||||
cost: filamentData.cost || null,
|
||||
color: filamentData.color || '#000000',
|
||||
diameter: filamentData.diameter || null,
|
||||
density: filamentData.density || null,
|
||||
url: filamentData.url || '',
|
||||
barcode: filamentData.barcode || '',
|
||||
emptySpoolWeight: filamentData.emptySpoolWeight || ''
|
||||
})
|
||||
}
|
||||
}, [filamentData, form])
|
||||
|
||||
const fetchFilamentInfo = useCallback(async () => {
|
||||
try {
|
||||
setFetchLoading(true)
|
||||
const data = await fetchObjectInfo(filamentId, 'filament')
|
||||
const lockEvent = await fetchObjectLock(filamentId, 'filament')
|
||||
setLockUser(lockEvent?.user || null)
|
||||
setFilamentData(data)
|
||||
form.setFieldsValue(data)
|
||||
setFetchLoading(false)
|
||||
} catch (err) {
|
||||
messageApi.error('Failed to fetch filament info')
|
||||
// Show error modal with retry functionality
|
||||
showError(
|
||||
`Failed to fetch filament information. Message: ${err.message}. Code: ${err.code}`,
|
||||
fetchFilamentInfo
|
||||
)
|
||||
}
|
||||
}, [
|
||||
fetchObjectInfo,
|
||||
fetchObjectLock,
|
||||
filamentId,
|
||||
form,
|
||||
messageApi,
|
||||
showError
|
||||
])
|
||||
|
||||
const updateFilamentInfo = async () => {
|
||||
const values = form.getFieldsValue()
|
||||
const updateValue = {
|
||||
name: values.name,
|
||||
vendor: values.vendor,
|
||||
type: values.type,
|
||||
cost: values.cost,
|
||||
color: values.color,
|
||||
diameter: values.diameter,
|
||||
density: values.density,
|
||||
url: values.url,
|
||||
barcode: values.barcode,
|
||||
emptySpoolWeight: values.emptySpoolWeight
|
||||
}
|
||||
await updateObjectInfo(filamentId, 'filament', updateValue)
|
||||
}
|
||||
|
||||
// Define the update event handler function
|
||||
const updateEventHandler = useCallback(
|
||||
(updateEvent) => {
|
||||
log.debug('Update event received for filament:', updateEvent)
|
||||
// Refresh the filament data when an update is received
|
||||
fetchFilamentInfo()
|
||||
},
|
||||
[fetchFilamentInfo]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized == false && filamentId && apiServer?.connected === true) {
|
||||
setInitialized(true)
|
||||
fetchFilamentInfo()
|
||||
}
|
||||
}, [filamentId, apiServer?.connected, initialized, fetchFilamentInfo])
|
||||
|
||||
useEffect(() => {
|
||||
if (filamentId) {
|
||||
const cleanup = onLockEvent(filamentId, lockEventHandler)
|
||||
return cleanup
|
||||
}
|
||||
}, [filamentId, onLockEvent, lockEventHandler])
|
||||
|
||||
useEffect(() => {
|
||||
if (filamentId) {
|
||||
const cleanup = onUpdateEvent(filamentId, updateEventHandler)
|
||||
return cleanup
|
||||
}
|
||||
}, [filamentId, onUpdateEvent, updateEventHandler])
|
||||
|
||||
const startEditing = () => {
|
||||
updateCollapseState('info', true)
|
||||
setIsEditing(true)
|
||||
lockObject(filamentId, 'filament')
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
// Reset form values to original data
|
||||
if (filamentData) {
|
||||
form.setFieldsValue({
|
||||
name: filamentData.name || '',
|
||||
brand: filamentData.brand || '',
|
||||
type: filamentData.type || '',
|
||||
cost: filamentData.cost || null,
|
||||
color: filamentData.color || '#000000',
|
||||
diameter: filamentData.diameter || null,
|
||||
density: filamentData.density || null,
|
||||
url: filamentData.url || '',
|
||||
barcode: filamentData.barcode || '',
|
||||
emptySpoolWeight: filamentData.emptySpoolWeight || ''
|
||||
})
|
||||
}
|
||||
setIsEditing(false)
|
||||
unlockObject(filamentId, 'filament')
|
||||
}
|
||||
|
||||
const handleUpdateFilamentInfo = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setEditLoading(true)
|
||||
|
||||
await updateFilamentInfo()
|
||||
|
||||
// Update the local state with the new values
|
||||
setFilamentData({ ...filamentData, ...values })
|
||||
setIsEditing(false)
|
||||
messageApi.success('Filament information updated successfully')
|
||||
} catch (err) {
|
||||
if (err.errorFields) {
|
||||
// This is a form validation error
|
||||
return
|
||||
}
|
||||
console.error('Failed to update filament information:', err)
|
||||
messageApi.error('Failed to update filament information')
|
||||
// Show error modal with retry functionality
|
||||
showError(
|
||||
`Failed to update filament information. Message: ${err.message}. Code: ${err.code}`,
|
||||
() => handleUpdateFilamentInfo()
|
||||
)
|
||||
} finally {
|
||||
fetchFilamentInfo()
|
||||
setEditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const actionItems = {
|
||||
items: [
|
||||
{
|
||||
label: 'Reload Filament',
|
||||
key: 'reload',
|
||||
icon: <ReloadIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reload') {
|
||||
fetchFilamentInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getViewDropdownItems = () => {
|
||||
const sections = [
|
||||
{ key: 'info', label: 'Filament Information' },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||
]
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
|
||||
{sections.map((section) => (
|
||||
<Checkbox
|
||||
checked={collapseState[section.key]}
|
||||
key={section.key}
|
||||
onChange={(e) => {
|
||||
updateCollapseState(section.key, e.target.checked)
|
||||
}}
|
||||
>
|
||||
{section.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='middle'>
|
||||
<Space size='small'>
|
||||
<Dropdown menu={actionItems}>
|
||||
<Button disabled={fetchLoading}>Actions</Button>
|
||||
</Dropdown>
|
||||
<Popover
|
||||
content={getViewDropdownItems()}
|
||||
placement='bottomLeft'
|
||||
arrow={false}
|
||||
>
|
||||
<Button disabled={fetchLoading}>View</Button>
|
||||
</Popover>
|
||||
<EditObjectForm id={filamentId} type='filament' style={{ height: '100%' }}>
|
||||
{({
|
||||
loading,
|
||||
isEditing,
|
||||
startEditing,
|
||||
cancelEditing,
|
||||
handleUpdate,
|
||||
formValid,
|
||||
objectData,
|
||||
editLoading,
|
||||
lock,
|
||||
fetchObject
|
||||
}) => (
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='middle'>
|
||||
<Space size='small'>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
label: 'Reload Filament',
|
||||
key: 'reload',
|
||||
icon: <ReloadIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reload') {
|
||||
fetchObject()
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button disabled={loading}>Actions</Button>
|
||||
</Dropdown>
|
||||
<ViewButton
|
||||
loading={loading}
|
||||
sections={[
|
||||
{ key: 'info', label: 'Filament Information' },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||
]}
|
||||
collapseState={collapseState}
|
||||
updateCollapseState={updateCollapseState}
|
||||
/>
|
||||
</Space>
|
||||
<LockIndicator lock={lock} />
|
||||
</Space>
|
||||
{lockUser && (
|
||||
<Flex gap={'small'} align='center'>
|
||||
<Tag
|
||||
icon={<LockIcon />}
|
||||
style={{ margin: 0 }}
|
||||
color={'orange'}
|
||||
/>
|
||||
<IdText
|
||||
id={lockUser}
|
||||
type={'user'}
|
||||
longId={false}
|
||||
showCopy={false}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</Space>
|
||||
<Space>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
icon={<CheckIcon />}
|
||||
type='primary'
|
||||
onClick={handleUpdateFilamentInfo}
|
||||
loading={editLoading}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
<Button
|
||||
icon={<XMarkIcon />}
|
||||
onClick={cancelEditing}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
icon={<EditIcon />}
|
||||
onClick={startEditing}
|
||||
disabled={lockUser !== null || fetchLoading}
|
||||
<Space>
|
||||
<EditButtons
|
||||
isEditing={isEditing}
|
||||
handleUpdate={handleUpdate}
|
||||
cancelEditing={cancelEditing}
|
||||
startEditing={startEditing}
|
||||
editLoading={editLoading}
|
||||
formValid={formValid}
|
||||
disabled={lock?.locked || loading}
|
||||
loading={editLoading}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||
<Flex vertical gap={'large'}>
|
||||
<InfoCollapse
|
||||
title='Filament Information'
|
||||
icon={<InfoCircleIcon />}
|
||||
active={collapseState.info}
|
||||
onToggle={(expanded) => updateCollapseState('info', expanded)}
|
||||
className='no-t-padding-collapse'
|
||||
key='info'
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout='vertical'
|
||||
initialValues={{
|
||||
name: filamentData?.name || '',
|
||||
vendor: filamentData?.vendor || { id: null, name: '' },
|
||||
type: filamentData?.type || '',
|
||||
cost: filamentData?.cost || null,
|
||||
color: filamentData?.color || '#000000',
|
||||
diameter: filamentData?.diameter || null,
|
||||
density: filamentData?.density || null,
|
||||
url: filamentData?.url || '',
|
||||
barcode: filamentData?.barcode || ''
|
||||
}}
|
||||
>
|
||||
<Spin indicator={<LoadingOutlined />} spinning={fetchLoading}>
|
||||
<Descriptions
|
||||
bordered
|
||||
column={{
|
||||
xs: 1,
|
||||
sm: 1,
|
||||
md: 1,
|
||||
lg: 2,
|
||||
xl: 2,
|
||||
xxl: 2
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label='ID' span={1}>
|
||||
{filamentData?._id ? (
|
||||
<IdText id={filamentData._id} type={'filament'} />
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Created At'>
|
||||
{filamentData?.createdAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={filamentData.createdAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Name'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='name'
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a filament name'
|
||||
},
|
||||
{
|
||||
max: 100,
|
||||
message: 'Name cannot exceed 100 characters'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Input placeholder='Enter filament name' />
|
||||
</Form.Item>
|
||||
) : filamentData?.name ? (
|
||||
<Text>{filamentData.name}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Updated At'>
|
||||
{filamentData?.updatedAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={filamentData.updatedAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Vendor'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='vendor'
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a vendor'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<VendorSelect />
|
||||
</Form.Item>
|
||||
) : filamentData?.vendor?.name ? (
|
||||
<Text>{filamentData.vendor.name}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Vendor ID'>
|
||||
{filamentData?.vendor?.id ? (
|
||||
<IdText
|
||||
id={filamentData.vendor.id}
|
||||
type={'vendor'}
|
||||
showHyperlink={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Material'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='type'
|
||||
style={{ margin: 0 }}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please select a material'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value='PLA'>PLA</Select.Option>
|
||||
<Select.Option value='PETG'>PETG</Select.Option>
|
||||
<Select.Option value='ABS'>ABS</Select.Option>
|
||||
<Select.Option value='ASA'>ASA</Select.Option>
|
||||
<Select.Option value='HIPS'>HIPS</Select.Option>
|
||||
<Select.Option value='TPU'>TPU</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
) : filamentData?.type ? (
|
||||
<Text>{filamentData.type}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Cost'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='cost'
|
||||
style={{ margin: 0 }}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a cost'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
prefix='£'
|
||||
suffix='/kg'
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : filamentData?.cost ? (
|
||||
<Text>{`£${filamentData.cost}/kg`}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Color'>
|
||||
<Flex align={'center'} gap={'small'}>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='color'
|
||||
style={{ margin: 0 }}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please select a color'
|
||||
}
|
||||
]}
|
||||
getValueFromEvent={(color) => {
|
||||
return '#' + color.toHex()
|
||||
}}
|
||||
>
|
||||
<ColorPicker showText disabledAlpha />
|
||||
</Form.Item>
|
||||
) : filamentData?.color ? (
|
||||
<Badge
|
||||
color={filamentData.color}
|
||||
text={filamentData.color}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Diameter'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='diameter'
|
||||
style={{ margin: 0 }}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a diameter'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber suffix='mm' style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
) : filamentData?.diameter ? (
|
||||
<Text>{`${filamentData.diameter}mm`}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Density'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='density'
|
||||
style={{ margin: 0 }}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a density'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
suffix='g/cm³'
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : filamentData?.density ? (
|
||||
<Text>{`${filamentData.density}g/cm³`}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='URL'>
|
||||
{isEditing ? (
|
||||
<Form.Item name='url' style={{ margin: 0 }}>
|
||||
<Input placeholder='Enter URL' />
|
||||
</Form.Item>
|
||||
) : filamentData?.url ? (
|
||||
<Link href={filamentData.url} target='_blank'>
|
||||
{filamentData.url}
|
||||
</Link>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Barcode'>
|
||||
{isEditing ? (
|
||||
<Form.Item name='barcode' style={{ margin: 0 }}>
|
||||
<Input placeholder='Enter barcode' />
|
||||
</Form.Item>
|
||||
) : filamentData?.barcode ? (
|
||||
<Text>{filamentData.barcode}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Spin>
|
||||
</Form>
|
||||
</InfoCollapse>
|
||||
|
||||
<InfoCollapse
|
||||
title='Notes'
|
||||
icon={<NoteIcon />}
|
||||
active={collapseState.notes}
|
||||
onToggle={(expanded) => updateCollapseState('notes', expanded)}
|
||||
key='notes'
|
||||
>
|
||||
<Card>
|
||||
<DashboardNotes _id={filamentId} />
|
||||
</Card>
|
||||
</InfoCollapse>
|
||||
|
||||
<InfoCollapse
|
||||
title='Audit Logs'
|
||||
icon={<AuditLogIcon />}
|
||||
active={collapseState.auditLogs}
|
||||
onToggle={(expanded) =>
|
||||
updateCollapseState('auditLogs', expanded)
|
||||
}
|
||||
key='auditLogs'
|
||||
>
|
||||
<AuditLogTable
|
||||
items={filamentData?.auditLogs || []}
|
||||
loading={fetchLoading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</InfoCollapse>
|
||||
</Space>
|
||||
</Flex>
|
||||
</div>
|
||||
</Flex>
|
||||
</>
|
||||
|
||||
<div style={{ height: '100%', overflowY: 'scroll' }}>
|
||||
<Flex vertical gap={'large'}>
|
||||
<InfoCollapse
|
||||
title='Filament Information'
|
||||
icon={<InfoCircleIcon />}
|
||||
active={collapseState.info}
|
||||
onToggle={(expanded) => updateCollapseState('info', expanded)}
|
||||
key='info'
|
||||
>
|
||||
<ObjectInfo
|
||||
loading={loading}
|
||||
indicator={<LoadingOutlined />}
|
||||
isEditing={isEditing}
|
||||
items={[
|
||||
{
|
||||
name: 'id',
|
||||
label: 'ID',
|
||||
value: objectData?._id,
|
||||
type: 'id',
|
||||
objectType: 'filament',
|
||||
showCopy: true
|
||||
},
|
||||
{
|
||||
name: 'createdAt',
|
||||
label: 'Created At',
|
||||
value: objectData?.createdAt,
|
||||
type: 'dateTime',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
value: objectData?.name,
|
||||
required: true,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'updatedAt',
|
||||
label: 'Updated At',
|
||||
value: objectData?.updatedAt,
|
||||
type: 'dateTime',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: 'vendor',
|
||||
label: 'Vendor',
|
||||
value: objectData?.vendor,
|
||||
required: true,
|
||||
type: 'object',
|
||||
objectType: 'vendor'
|
||||
},
|
||||
{
|
||||
name: 'vendorId',
|
||||
label: 'Vendor ID',
|
||||
value: objectData?.vendor?.id,
|
||||
type: 'id',
|
||||
objectType: 'vendor',
|
||||
showCopy: true,
|
||||
showHyperlink: true
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
label: 'Material',
|
||||
value: objectData?.type,
|
||||
required: true,
|
||||
type: 'material'
|
||||
},
|
||||
{
|
||||
name: 'cost',
|
||||
label: 'Cost',
|
||||
value: objectData?.cost,
|
||||
required: true,
|
||||
type: 'currency'
|
||||
},
|
||||
{
|
||||
name: 'color',
|
||||
label: 'Color',
|
||||
value: objectData?.color,
|
||||
required: true,
|
||||
type: 'color'
|
||||
},
|
||||
{
|
||||
name: 'diameter',
|
||||
label: 'Diameter',
|
||||
value: objectData?.diameter,
|
||||
required: true,
|
||||
type: 'mm'
|
||||
},
|
||||
{
|
||||
name: 'density',
|
||||
label: 'Density',
|
||||
value: objectData?.density,
|
||||
required: true,
|
||||
type: 'density'
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
label: 'URL',
|
||||
value: objectData?.url,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'barcode',
|
||||
label: 'Barcode',
|
||||
value: objectData?.barcode,
|
||||
type: 'text'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</InfoCollapse>
|
||||
|
||||
<InfoCollapse
|
||||
title='Notes'
|
||||
icon={<NoteIcon />}
|
||||
active={collapseState.notes}
|
||||
onToggle={(expanded) => updateCollapseState('notes', expanded)}
|
||||
key='notes'
|
||||
>
|
||||
<Card>
|
||||
<DashboardNotes _id={filamentId} />
|
||||
</Card>
|
||||
</InfoCollapse>
|
||||
|
||||
<InfoCollapse
|
||||
title='Audit Logs'
|
||||
icon={<AuditLogIcon />}
|
||||
active={collapseState.auditLogs}
|
||||
onToggle={(expanded) =>
|
||||
updateCollapseState('auditLogs', expanded)
|
||||
}
|
||||
key='auditLogs'
|
||||
>
|
||||
<AuditLogTable
|
||||
items={objectData?.auditLogs || []}
|
||||
loading={loading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
</div>
|
||||
</Flex>
|
||||
)}
|
||||
</EditObjectForm>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
import React from 'react'
|
||||
import { Flex, Tag } from 'antd'
|
||||
import IdDisplay from '../../common/IdDisplay'
|
||||
import LockIcon from '../../../Icons/LockIcon'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const LockIndicator = ({ lock }) => {
|
||||
if (!lock?.locked || lock?.locked == false) {
|
||||
return
|
||||
}
|
||||
return (
|
||||
<Flex gap={'small'} align='center'>
|
||||
<Tag icon={<LockIcon />} style={{ margin: 0 }} color={'orange'} />
|
||||
<IdDisplay
|
||||
id={lock?.user}
|
||||
type={'user'}
|
||||
longId={false}
|
||||
showCopy={false}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
LockIndicator.propTypes = {
|
||||
lock: PropTypes.object
|
||||
}
|
||||
|
||||
export default LockIndicator
|
||||
@ -157,7 +157,6 @@ const NewFilament = ({ onOk, reset }) => {
|
||||
}
|
||||
|
||||
const handleImageUpload = async ({ file, fileList }) => {
|
||||
console.log(fileList)
|
||||
if (fileList.length === 0) {
|
||||
setImageList(fileList)
|
||||
newFilamentForm.setFieldsValue({ image: '' })
|
||||
|
||||
@ -19,7 +19,7 @@ import { LoadingOutlined } from '@ant-design/icons'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
|
||||
import NewMaterial from './Materials/NewMaterial'
|
||||
import IdText from '../common/IdText'
|
||||
import IdDisplay from '../common/IdDisplay'
|
||||
import MaterialIcon from '../../Icons/MaterialIcon'
|
||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||
import PlusIcon from '../../Icons/PlusIcon'
|
||||
@ -169,7 +169,7 @@ const Materials = () => {
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'material'} longId={false} />
|
||||
render: (text) => <IdDisplay id={text} type={'material'} longId={false} />
|
||||
},
|
||||
{
|
||||
title: 'Category',
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
Typography
|
||||
} from 'antd'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import IdText from '../common/IdText'
|
||||
import IdDisplay from '../common/IdDisplay'
|
||||
import NewNoteType from './NoteTypes/NewNoteType'
|
||||
import TimeDisplay from '../common/TimeDisplay'
|
||||
import DashboardTable from '../common/DashboardTable'
|
||||
@ -155,7 +155,9 @@ const NoteTypes = () => {
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'notetype'} longId={false} />,
|
||||
render: (text) => (
|
||||
<IdDisplay id={text} type={'notetype'} longId={false} />
|
||||
),
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
|
||||
@ -1,51 +1,22 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Descriptions,
|
||||
Spin,
|
||||
Space,
|
||||
Button,
|
||||
message,
|
||||
Typography,
|
||||
Flex,
|
||||
Form,
|
||||
Input,
|
||||
Collapse,
|
||||
Switch,
|
||||
ColorPicker,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
Popover,
|
||||
Badge
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||
import IdText from '../../common/IdText'
|
||||
import TimeDisplay from '../../common/TimeDisplay'
|
||||
import { Space, Button, Flex, Dropdown } from 'antd'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import ReloadIcon from '../../../Icons/ReloadIcon'
|
||||
import EditIcon from '../../../Icons/EditIcon.jsx'
|
||||
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
|
||||
import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
||||
import useCollapseState from '../../hooks/useCollapseState'
|
||||
import AuditLogTable from '../../common/AuditLogTable'
|
||||
|
||||
import config from '../../../../config.js'
|
||||
import BoolDisplay from '../../common/BoolDisplay.jsx'
|
||||
import InfoCollapse from '../../common/InfoCollapse'
|
||||
import ObjectInfo from '../../common/ObjectInfo'
|
||||
import ViewButton from '../../common/ViewButton'
|
||||
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
|
||||
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
import EditObjectForm from '../../common/EditObjectForm'
|
||||
import EditButtons from '../../common/EditButtons'
|
||||
import LockIndicator from '../Filaments/LockIndicator'
|
||||
|
||||
const NoteTypeInfo = () => {
|
||||
const [noteTypeData, setNoteTypeData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const location = useLocation()
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const noteTypeId = new URLSearchParams(location.search).get('noteTypeId')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const [colorEnabled, setColorEnabled] = useState(false)
|
||||
const [collapseState, updateCollapseState] = useCollapseState(
|
||||
'NoteTypeInfo',
|
||||
{
|
||||
@ -54,353 +25,156 @@ const NoteTypeInfo = () => {
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (noteTypeId) {
|
||||
fetchNoteTypeDetails()
|
||||
}
|
||||
}, [noteTypeId])
|
||||
|
||||
useEffect(() => {
|
||||
if (noteTypeData) {
|
||||
form.setFieldsValue({
|
||||
name: noteTypeData.name || '',
|
||||
color: noteTypeData.color || '#000000',
|
||||
active: noteTypeData.active ?? true
|
||||
})
|
||||
setColorEnabled(!!noteTypeData.color)
|
||||
}
|
||||
}, [noteTypeData, form])
|
||||
|
||||
const fetchNoteTypeDetails = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await axios.get(
|
||||
`${config.backendUrl}/noteTypes/${noteTypeId}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
setNoteTypeData(response.data)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError('Failed to fetch note type details')
|
||||
messageApi.error('Failed to fetch note type details')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startEditing = () => {
|
||||
updateCollapseState('info', true)
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
form.setFieldsValue({
|
||||
name: noteTypeData?.name || '',
|
||||
color: noteTypeData?.color || '#000000',
|
||||
active: noteTypeData?.active ?? true
|
||||
})
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const updateInfo = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setLoading(true)
|
||||
|
||||
await axios.put(`${config.backendUrl}/noteTypes/${noteTypeId}`, values, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
setNoteTypeData({ ...noteTypeData, ...values })
|
||||
setIsEditing(false)
|
||||
messageApi.success('Note type information updated successfully')
|
||||
} catch (err) {
|
||||
if (err.errorFields) {
|
||||
return
|
||||
}
|
||||
console.error('Failed to update note type information:', err)
|
||||
messageApi.error('Failed to update note type information')
|
||||
} finally {
|
||||
fetchNoteTypeDetails()
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getViewDropdownItems = () => {
|
||||
const sections = [
|
||||
{ key: 'info', label: 'Note Type Information' },
|
||||
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||
]
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
|
||||
{sections.map((section) => (
|
||||
<Checkbox
|
||||
checked={collapseState[section.key]}
|
||||
key={section.key}
|
||||
onChange={(e) => {
|
||||
updateCollapseState(section.key, e.target.checked)
|
||||
}}
|
||||
>
|
||||
{section.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
const actionItems = {
|
||||
items: [
|
||||
{
|
||||
label: 'Reload Note Type',
|
||||
key: 'reload',
|
||||
icon: <ReloadIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reload') {
|
||||
fetchNoteTypeDetails()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex justify='space-between'>
|
||||
<Space size='small'>
|
||||
<Dropdown menu={actionItems}>
|
||||
<Button>Actions</Button>
|
||||
</Dropdown>
|
||||
<Popover
|
||||
content={getViewDropdownItems()}
|
||||
placement='bottomLeft'
|
||||
arrow={false}
|
||||
>
|
||||
<Button>View</Button>
|
||||
</Popover>
|
||||
</Space>
|
||||
<Space size={'small'}>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
icon={<CheckIcon />}
|
||||
type='primary'
|
||||
onClick={updateInfo}
|
||||
loading={loading}
|
||||
/>
|
||||
<Button
|
||||
icon={<XMarkIcon />}
|
||||
onClick={cancelEditing}
|
||||
disabled={loading}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button icon={<EditIcon />} onClick={startEditing} />
|
||||
)}
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
{error ? (
|
||||
<Space
|
||||
direction='vertical'
|
||||
style={{ width: '100%', textAlign: 'center' }}
|
||||
<EditObjectForm
|
||||
id={noteTypeId}
|
||||
type='notetype'
|
||||
style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
|
||||
>
|
||||
{({
|
||||
loading,
|
||||
isEditing,
|
||||
startEditing,
|
||||
cancelEditing,
|
||||
handleUpdate,
|
||||
formValid,
|
||||
objectData,
|
||||
editLoading,
|
||||
lock,
|
||||
fetchObject
|
||||
}) => (
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<p>{error || 'Note type not found'}</p>
|
||||
<Button icon={<ReloadIcon />} onClick={fetchNoteTypeDetails}>
|
||||
Retry
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
{contextHolder}
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.info ? ['1'] : []}
|
||||
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='middle'>
|
||||
<Space size='small'>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
label: 'Reload Note Type',
|
||||
key: 'reload',
|
||||
icon: <ReloadIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reload') {
|
||||
fetchObject()
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button disabled={loading}>Actions</Button>
|
||||
</Dropdown>
|
||||
<ViewButton
|
||||
loading={loading}
|
||||
sections={[
|
||||
{ key: 'info', label: 'Note Type Information' },
|
||||
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||
]}
|
||||
collapseState={collapseState}
|
||||
updateCollapseState={updateCollapseState}
|
||||
/>
|
||||
)}
|
||||
className='no-h-padding-collapse no-t-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'small'}>
|
||||
<InfoCircleIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Note Type Information
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='1'
|
||||
</Space>
|
||||
<LockIndicator lock={lock} />
|
||||
</Space>
|
||||
<Space>
|
||||
<EditButtons
|
||||
isEditing={isEditing}
|
||||
handleUpdate={handleUpdate}
|
||||
cancelEditing={cancelEditing}
|
||||
startEditing={startEditing}
|
||||
editLoading={editLoading}
|
||||
formValid={formValid}
|
||||
disabled={lock?.locked || loading}
|
||||
loading={editLoading}
|
||||
/>
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||
<Flex vertical gap={'large'}>
|
||||
<InfoCollapse
|
||||
title='Note Type Information'
|
||||
icon={<InfoCircleIcon />}
|
||||
active={collapseState.info}
|
||||
onToggle={(expanded) => updateCollapseState('info', expanded)}
|
||||
key='info'
|
||||
>
|
||||
<Spin spinning={loading} indicator={<LoadingOutlined />}>
|
||||
<Form form={form} layout='vertical'>
|
||||
<Descriptions
|
||||
bordered
|
||||
column={{
|
||||
xs: 1,
|
||||
sm: 1,
|
||||
md: 1,
|
||||
lg: 2,
|
||||
xl: 2,
|
||||
xxl: 2
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label='ID'>
|
||||
<IdText id={noteTypeData?._id} type='notetype' />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Created At'>
|
||||
<TimeDisplay
|
||||
dateTime={noteTypeData?.createdAt}
|
||||
showSince={true}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Name'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='name'
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a note type name'
|
||||
},
|
||||
{
|
||||
max: 100,
|
||||
message: 'Name cannot exceed 100 characters'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : (
|
||||
noteTypeData?.name
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Updated At'>
|
||||
<TimeDisplay
|
||||
dateTime={noteTypeData?.updatedAt}
|
||||
showSince={true}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Color'>
|
||||
{isEditing ? (
|
||||
<Flex gap='middle' align='center'>
|
||||
<Form.Item
|
||||
name='color'
|
||||
style={{ margin: 0 }}
|
||||
getValueFromEvent={(color) => {
|
||||
if (color != null) {
|
||||
return '#' + color.toHex()
|
||||
}
|
||||
return null
|
||||
}}
|
||||
>
|
||||
<ColorPicker
|
||||
showText={true}
|
||||
disabled={!colorEnabled}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Checkbox
|
||||
checked={colorEnabled}
|
||||
onChange={(e) => {
|
||||
setColorEnabled(e.target.checked)
|
||||
if (!e.target.checked) {
|
||||
form.setFieldValue('color', null)
|
||||
} else if (e.target.checked) {
|
||||
form.setFieldValue('color', '#000000')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
) : noteTypeData?.color ? (
|
||||
<Badge
|
||||
color={noteTypeData.color}
|
||||
text={noteTypeData.color}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Active'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='active'
|
||||
valuePropName='checked'
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
) : noteTypeData ? (
|
||||
<BoolDisplay
|
||||
value={noteTypeData.active}
|
||||
yesNo={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Form>
|
||||
</Spin>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.auditLogs ? ['2'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('auditLogs', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
<ObjectInfo
|
||||
loading={loading}
|
||||
indicator={<LoadingOutlined />}
|
||||
isEditing={isEditing}
|
||||
type='notetype'
|
||||
items={[
|
||||
{
|
||||
name: 'id',
|
||||
label: 'ID',
|
||||
value: objectData?._id,
|
||||
type: 'id',
|
||||
objectType: 'notetype',
|
||||
showCopy: true
|
||||
},
|
||||
{
|
||||
name: 'createdAt',
|
||||
label: 'Created At',
|
||||
value: objectData?.createdAt,
|
||||
type: 'dateTime',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
value: objectData?.name,
|
||||
required: true,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'updatedAt',
|
||||
label: 'Updated At',
|
||||
value: objectData?.updatedAt,
|
||||
type: 'dateTime',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: 'color',
|
||||
label: 'Color',
|
||||
value: objectData?.color,
|
||||
type: 'color'
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
label: 'Active',
|
||||
value: objectData?.active,
|
||||
type: 'bool'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'small'}>
|
||||
<AuditLogIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Audit Logs
|
||||
</Title>
|
||||
</Flex>
|
||||
</InfoCollapse>
|
||||
|
||||
<InfoCollapse
|
||||
title='Audit Logs'
|
||||
icon={<AuditLogIcon />}
|
||||
active={collapseState.auditLogs}
|
||||
onToggle={(expanded) =>
|
||||
updateCollapseState('auditLogs', expanded)
|
||||
}
|
||||
key='2'
|
||||
key='auditLogs'
|
||||
>
|
||||
<AuditLogTable
|
||||
items={noteTypeData?.auditLogs || []}
|
||||
items={objectData?.auditLogs || []}
|
||||
loading={loading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Flex>
|
||||
</div>
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
</div>
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
</EditObjectForm>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ import {
|
||||
import { DownloadOutlined } from '@ant-design/icons'
|
||||
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import IdText from '../common/IdText'
|
||||
import IdDisplay from '../common/IdDisplay'
|
||||
import DashboardTable from '../common/DashboardTable'
|
||||
import NewProduct from './Products/NewProduct'
|
||||
import PartIcon from '../../Icons/PartIcon'
|
||||
@ -81,7 +81,7 @@ const Parts = () => {
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'part'} longId={false} />
|
||||
render: (text) => <IdDisplay id={text} type={'part'} longId={false} />
|
||||
},
|
||||
{
|
||||
title: 'Product Name',
|
||||
@ -109,7 +109,7 @@ const Parts = () => {
|
||||
key: 'productId',
|
||||
width: 180,
|
||||
render: (record) => (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={record?.product?._id}
|
||||
type={'product'}
|
||||
longId={false}
|
||||
|
||||
@ -21,7 +21,7 @@ import {
|
||||
Popover
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||
import IdText from '../../common/IdText.jsx'
|
||||
import IdDisplay from '../../common/IdDisplay.jsx'
|
||||
import { StlViewer } from 'react-stl-viewer'
|
||||
import ReloadIcon from '../../../Icons/ReloadIcon'
|
||||
import EditIcon from '../../../Icons/EditIcon.jsx'
|
||||
@ -38,6 +38,9 @@ import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
|
||||
import PartIcon from '../../../Icons/PartIcon.jsx'
|
||||
import NoteIcon from '../../../Icons/NoteIcon.jsx'
|
||||
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
|
||||
import loglevel from 'loglevel'
|
||||
const logger = loglevel.getLogger('PartInfo')
|
||||
logger.setLevel(config.logLevel)
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
@ -116,7 +119,7 @@ const PartInfo = () => {
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError('Failed to fetch part details')
|
||||
console.log(err)
|
||||
logger.debug(err)
|
||||
messageApi.error('Failed to fetch part details')
|
||||
} finally {
|
||||
setFetchLoading(false)
|
||||
@ -176,7 +179,7 @@ const PartInfo = () => {
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to fetch part content')
|
||||
console.log(err)
|
||||
logger.debug(err)
|
||||
messageApi.error('Failed to fetch part content')
|
||||
} finally {
|
||||
setFetchLoading(false)
|
||||
@ -398,7 +401,7 @@ const PartInfo = () => {
|
||||
>
|
||||
<Descriptions.Item label='ID' span={1}>
|
||||
{partData?.id ? (
|
||||
<IdText id={partData.id} type='part'></IdText>
|
||||
<IdDisplay id={partData.id} type='part'></IdDisplay>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
@ -459,7 +462,7 @@ const PartInfo = () => {
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Product ID' span={1}>
|
||||
{partData?.product?._id ? (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={partData.product._id}
|
||||
type={'product'}
|
||||
showHyperlink={true}
|
||||
|
||||
@ -17,7 +17,7 @@ import {
|
||||
import { DownloadOutlined } from '@ant-design/icons'
|
||||
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import IdText from '../common/IdText'
|
||||
import IdDisplay from '../common/IdDisplay'
|
||||
import TimeDisplay from '../common/TimeDisplay'
|
||||
import DashboardTable from '../common/DashboardTable'
|
||||
import NewProduct from './Products/NewProduct'
|
||||
@ -102,7 +102,7 @@ const Products = () => {
|
||||
key: 'id',
|
||||
fixed: 'left',
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'product'} longId={false} />,
|
||||
render: (text) => <IdDisplay id={text} type={'product'} longId={false} />,
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
|
||||
@ -1,56 +1,25 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Descriptions,
|
||||
Spin,
|
||||
Space,
|
||||
Button,
|
||||
message,
|
||||
Typography,
|
||||
Flex,
|
||||
Form,
|
||||
Input,
|
||||
Tag,
|
||||
Checkbox,
|
||||
InputNumber,
|
||||
Collapse,
|
||||
Dropdown,
|
||||
Popover,
|
||||
Card
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||
import IdText from '../../common/IdText.jsx'
|
||||
import VendorSelect from '../../common/VendorSelect.jsx'
|
||||
import PartsTable from '../../common/PartsTable.jsx'
|
||||
import useCollapseState from '../../hooks/useCollapseState'
|
||||
import PlusIcon from '../../../Icons/PlusIcon'
|
||||
import { Space, Button, Flex, Dropdown, Card } from 'antd'
|
||||
import ReloadIcon from '../../../Icons/ReloadIcon'
|
||||
import EditIcon from '../../../Icons/EditIcon.jsx'
|
||||
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
|
||||
import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
||||
import TimeDisplay from '../../common/TimeDisplay.jsx'
|
||||
import useCollapseState from '../../hooks/useCollapseState'
|
||||
import AuditLogTable from '../../common/AuditLogTable'
|
||||
import DashboardNotes from '../../common/DashboardNotes'
|
||||
|
||||
import config from '../../../../config.js'
|
||||
import InfoCollapse from '../../common/InfoCollapse'
|
||||
import ObjectInfo from '../../common/ObjectInfo'
|
||||
import ViewButton from '../../common/ViewButton'
|
||||
import EditObjectForm from '../../common/EditObjectForm'
|
||||
import EditButtons from '../../common/EditButtons'
|
||||
import LockIndicator from '../Filaments/LockIndicator'
|
||||
import PartsTable from '../../common/PartsTable'
|
||||
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
|
||||
import NoteIcon from '../../../Icons/NoteIcon.jsx'
|
||||
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
|
||||
import ProductIcon from '../../../Icons/ProductIcon.jsx'
|
||||
import NoteIcon from '../../../Icons/NoteIcon.jsx'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
const ProductInfo = () => {
|
||||
const [productData, setProductData] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const location = useLocation()
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const productId = new URLSearchParams(location.search).get('productId')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [fetchLoading, setFetchLoading] = useState(true)
|
||||
const [marginOrPrice, setMarginOrPrice] = useState(false)
|
||||
const [collapseState, updateCollapseState] = useCollapseState('ProductInfo', {
|
||||
info: true,
|
||||
parts: true,
|
||||
@ -58,600 +27,209 @@ const ProductInfo = () => {
|
||||
auditLogs: true
|
||||
})
|
||||
|
||||
const [productForm] = Form.useForm()
|
||||
const [productFormValues, setProductFormValues] = useState({})
|
||||
|
||||
const handleTagClose = (removedTag) => {
|
||||
const newTags = productData.tags.filter((tag) => tag !== removedTag)
|
||||
setProductData((prev) => ({ ...prev, tags: newTags }))
|
||||
}
|
||||
|
||||
const handleTagAdd = () => {
|
||||
const input = productForm.getFieldValue('newTag')
|
||||
if (input) {
|
||||
const newTag = input.trim()
|
||||
if (newTag && !productData.tags.includes(newTag)) {
|
||||
setProductData((prev) => ({ ...prev, tags: [...prev.tags, newTag] }))
|
||||
productForm.setFieldValue('newTag', '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setMarginOrPrice(productFormValues.marginOrPrice)
|
||||
}, [productFormValues])
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
console.log('hello')
|
||||
await fetchProductDetails()
|
||||
}
|
||||
if (productId) {
|
||||
fetchData()
|
||||
}
|
||||
}, [productId])
|
||||
|
||||
useEffect(() => {
|
||||
if (productData) {
|
||||
productForm.setFieldsValue({
|
||||
name: productData.name || '',
|
||||
vendor: productData.vendor || null,
|
||||
version: productData.version || '',
|
||||
tags: productData.tags || [],
|
||||
price: productData.price || null,
|
||||
margin: productData.margin || null,
|
||||
marginOrPrice: productData.marginOrPrice || false
|
||||
})
|
||||
setProductFormValues(productData)
|
||||
setMarginOrPrice(productData.marginOrPrice)
|
||||
}
|
||||
}, [productData, productForm])
|
||||
|
||||
const fetchProductDetails = async () => {
|
||||
try {
|
||||
setFetchLoading(true)
|
||||
const response = await axios.get(
|
||||
`${config.backendUrl}/products/${productId}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
setProductData(response.data)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError('Failed to fetch product details')
|
||||
console.log(err)
|
||||
messageApi.error('Failed to fetch product details')
|
||||
} finally {
|
||||
setFetchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startEditing = () => {
|
||||
updateCollapseState('info', true)
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
// Reset form values to original data
|
||||
if (productData) {
|
||||
productForm.setFieldsValue({
|
||||
name: productData.name || '',
|
||||
vendor: productData.vendor || { id: null, name: '' },
|
||||
version: productData.version || '',
|
||||
tags: productData.tags || [],
|
||||
cost: productData.cost || null,
|
||||
price: productData.price || null,
|
||||
margin: productData.margin || null,
|
||||
marginOrPrice: productData.marginOrPrice || null
|
||||
})
|
||||
setMarginOrPrice(productData.marginOrPrice)
|
||||
}
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const updateInfo = async () => {
|
||||
try {
|
||||
const values = await productForm.validateFields()
|
||||
setLoading(true)
|
||||
|
||||
await axios.put(`${config.backendUrl}/products/${productId}`, values, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
setProductData({
|
||||
...productData,
|
||||
...values
|
||||
})
|
||||
setIsEditing(false)
|
||||
messageApi.success('Product information updated successfully')
|
||||
} catch (err) {
|
||||
if (err.errorFields) {
|
||||
return
|
||||
}
|
||||
console.error('Failed to update product information:', err)
|
||||
messageApi.error('Failed to update product information')
|
||||
} finally {
|
||||
await fetchProductDetails()
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const actionItems = {
|
||||
items: [
|
||||
{
|
||||
label: 'Reload Product',
|
||||
key: 'reload',
|
||||
icon: <ReloadIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reload') {
|
||||
fetchProductDetails()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getViewDropdownItems = () => {
|
||||
const sections = [
|
||||
{ key: 'info', label: 'Product Information' },
|
||||
{ key: 'parts', label: 'Product Parts' },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||
]
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
|
||||
{sections.map((section) => (
|
||||
<Checkbox
|
||||
checked={collapseState[section.key]}
|
||||
key={section.key}
|
||||
onChange={(e) => {
|
||||
updateCollapseState(section.key, e.target.checked)
|
||||
}}
|
||||
>
|
||||
{section.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Space
|
||||
direction='vertical'
|
||||
style={{ width: '100%', textAlign: 'center' }}
|
||||
>
|
||||
<p>{error || 'Product not found'}</p>
|
||||
<Button icon={<ReloadIcon />} onClick={fetchProductDetails}>
|
||||
Retry
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Dropdown menu={actionItems}>
|
||||
<Button>Actions</Button>
|
||||
</Dropdown>
|
||||
<Popover
|
||||
content={getViewDropdownItems()}
|
||||
placement='bottomLeft'
|
||||
arrow={false}
|
||||
>
|
||||
<Button>View</Button>
|
||||
</Popover>
|
||||
</Space>
|
||||
<Space>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
icon={<CheckIcon />}
|
||||
type='primary'
|
||||
onClick={updateInfo}
|
||||
<EditObjectForm
|
||||
id={productId}
|
||||
type='product'
|
||||
style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
|
||||
>
|
||||
{({
|
||||
loading,
|
||||
isEditing,
|
||||
startEditing,
|
||||
cancelEditing,
|
||||
handleUpdate,
|
||||
formValid,
|
||||
objectData,
|
||||
editLoading,
|
||||
lock,
|
||||
fetchObject
|
||||
}) => (
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='middle'>
|
||||
<Space size='small'>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
label: 'Reload Product',
|
||||
key: 'reload',
|
||||
icon: <ReloadIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reload') {
|
||||
fetchObject()
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button disabled={loading}>Actions</Button>
|
||||
</Dropdown>
|
||||
<ViewButton
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
sections={[
|
||||
{ key: 'info', label: 'Product Information' },
|
||||
{ key: 'parts', label: 'Product Parts' },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||
]}
|
||||
collapseState={collapseState}
|
||||
updateCollapseState={updateCollapseState}
|
||||
/>
|
||||
<Button
|
||||
icon={<XMarkIcon />}
|
||||
onClick={cancelEditing}
|
||||
disabled={loading}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button icon={<EditIcon />} onClick={startEditing} />
|
||||
)}
|
||||
</Space>
|
||||
</Flex>
|
||||
</Space>
|
||||
<LockIndicator lock={lock} />
|
||||
</Space>
|
||||
<Space>
|
||||
<EditButtons
|
||||
isEditing={isEditing}
|
||||
handleUpdate={handleUpdate}
|
||||
cancelEditing={cancelEditing}
|
||||
startEditing={startEditing}
|
||||
editLoading={editLoading}
|
||||
formValid={formValid}
|
||||
disabled={lock?.locked || loading}
|
||||
loading={editLoading}
|
||||
/>
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
{error ? (
|
||||
<Space
|
||||
direction='vertical'
|
||||
style={{ width: '100%', textAlign: 'center' }}
|
||||
>
|
||||
<p>{error || 'Product not found'}</p>
|
||||
<Button icon={<ReloadIcon />} onClick={fetchProductDetails}>
|
||||
Retry
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.info ? ['1'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('info', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse no-t-padding-collapse'
|
||||
<InfoCollapse
|
||||
title='Product Information'
|
||||
icon={<InfoCircleIcon />}
|
||||
active={collapseState.info}
|
||||
onToggle={(expanded) => updateCollapseState('info', expanded)}
|
||||
key='info'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<InfoCircleIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Product Information
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='1'
|
||||
>
|
||||
<Form
|
||||
form={productForm}
|
||||
layout='vertical'
|
||||
onValuesChange={(changedValues) =>
|
||||
setProductFormValues((prevValues) => ({
|
||||
...prevValues,
|
||||
...changedValues
|
||||
}))
|
||||
<ObjectInfo
|
||||
loading={loading}
|
||||
isEditing={isEditing}
|
||||
indicator={null}
|
||||
type='product'
|
||||
items={[
|
||||
{
|
||||
name: '_id',
|
||||
label: 'ID',
|
||||
value: objectData?._id,
|
||||
type: 'id',
|
||||
objectType: 'product',
|
||||
showCopy: true,
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: 'createdAt',
|
||||
label: 'Created At',
|
||||
value: objectData?.createdAt,
|
||||
type: 'dateTime',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
value: objectData?.name,
|
||||
required: true,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'updatedAt',
|
||||
label: 'Updated At',
|
||||
value: objectData?.updatedAt,
|
||||
type: 'dateTime',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: 'vendor',
|
||||
label: 'Vendor',
|
||||
value: objectData?.vendor,
|
||||
required: true,
|
||||
type: 'object',
|
||||
objectType: 'vendor'
|
||||
},
|
||||
{
|
||||
name: 'version',
|
||||
label: 'Version',
|
||||
value: objectData?.version,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
label: 'Tags',
|
||||
value: objectData?.tags,
|
||||
type: 'tags'
|
||||
},
|
||||
{
|
||||
name: 'marginOrPrice',
|
||||
label: 'Price Mode',
|
||||
value: objectData?.marginOrPrice,
|
||||
type: 'bool'
|
||||
},
|
||||
{
|
||||
name: 'margin',
|
||||
label: 'Margin',
|
||||
value: objectData?.margin,
|
||||
type: 'number',
|
||||
formItemProps: { min: 0, max: 100, step: 0.01 }
|
||||
},
|
||||
{
|
||||
name: 'price',
|
||||
label: 'Price',
|
||||
value: objectData?.price,
|
||||
type: 'number',
|
||||
formItemProps: { min: 0, step: 0.01 }
|
||||
}
|
||||
initialValues={{
|
||||
name: productData?.name || '',
|
||||
vendor: productData?.vendor || { id: null, name: '' },
|
||||
version: productData?.version || '',
|
||||
tags: productData?.tags || []
|
||||
}}
|
||||
>
|
||||
<Spin
|
||||
indicator={<LoadingOutlined />}
|
||||
spinning={fetchLoading}
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
column={{
|
||||
xs: 1,
|
||||
sm: 1,
|
||||
md: 1,
|
||||
lg: 2,
|
||||
xl: 2,
|
||||
xxl: 2
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label='ID' span={1}>
|
||||
{productData?.id ? (
|
||||
<IdText id={productData.id} type='product'></IdText>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
]}
|
||||
/>
|
||||
</InfoCollapse>
|
||||
|
||||
<Descriptions.Item label='Created At'>
|
||||
{productData?.createdAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={productData.createdAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Name' span={1}>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='name'
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a product name'
|
||||
},
|
||||
{
|
||||
max: 100,
|
||||
message: 'Name cannot exceed 100 characters'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Input placeholder='Enter product name' />
|
||||
</Form.Item>
|
||||
) : productData?.name ? (
|
||||
<Text>{productData.name}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Updated At'>
|
||||
{productData?.updatedAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={productData.updatedAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Vendor'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='vendor'
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a vendor'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<VendorSelect />
|
||||
</Form.Item>
|
||||
) : productData?.vendor?.name ? (
|
||||
<Text>{productData.vendor.name}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Vendor ID'>
|
||||
{productData?.vendor?.id ? (
|
||||
<IdText
|
||||
id={productData.vendor.id}
|
||||
type={'vendor'}
|
||||
showHyperlink={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item
|
||||
label={!marginOrPrice ? 'Margin' : 'Price'}
|
||||
span={1}
|
||||
>
|
||||
{isEditing ? (
|
||||
<Flex gap='middle'>
|
||||
{marginOrPrice == false ? (
|
||||
<Form.Item
|
||||
name='margin'
|
||||
style={{ margin: 0, flexGrow: 1 }}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a margin.'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
controls={false}
|
||||
step={0.01}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter='%'
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item
|
||||
name='price'
|
||||
style={{ margin: 0, flexGrow: 1 }}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a price.'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
controls={false}
|
||||
step={0.01}
|
||||
style={{ width: '100%' }}
|
||||
addonBefore='£'
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
name='marginOrPrice'
|
||||
valuePropName='checked'
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Checkbox>Price</Checkbox>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
) : productData?.margin && marginOrPrice == false ? (
|
||||
<Text>{productData.margin + '%'}</Text>
|
||||
) : productData?.price && marginOrPrice == true ? (
|
||||
<Text>{'£' + productData.price}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Version' span={1}>
|
||||
{isEditing ? (
|
||||
<Form.Item name='version' style={{ margin: 0 }}>
|
||||
<Input placeholder='Enter version' />
|
||||
</Form.Item>
|
||||
) : productData?.version ? (
|
||||
<Tag>{productData.version}</Tag>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Tags' span={1}>
|
||||
{isEditing ? (
|
||||
<Form.Item name='tags' style={{ margin: 0 }}>
|
||||
<Space
|
||||
size={[0, 2]}
|
||||
wrap
|
||||
style={{ marginBottom: 4, maxWidth: '300px' }}
|
||||
>
|
||||
{productData?.tags?.map((tag) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
color='blue'
|
||||
closable
|
||||
onClose={() => handleTagClose(tag)}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
<Space.Compact block>
|
||||
<Form.Item name='newTag' noStyle>
|
||||
<Input placeholder='Add new tag' />
|
||||
</Form.Item>
|
||||
<Button
|
||||
onClick={handleTagAdd}
|
||||
icon={<PlusIcon />}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
) : productData?.tags?.length > 0 ? (
|
||||
<Space
|
||||
size={[0, 2]}
|
||||
wrap
|
||||
style={{ maxWidth: '300px' }}
|
||||
>
|
||||
{productData.tags.map((tag, index) => (
|
||||
<Tag key={index} color='blue'>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Spin>
|
||||
</Form>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.parts ? ['2'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('parts', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
<InfoCollapse
|
||||
title='Product Parts'
|
||||
icon={<ProductIcon />}
|
||||
active={collapseState.parts}
|
||||
onToggle={(expanded) => updateCollapseState('parts', expanded)}
|
||||
key='parts'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<ProductIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Product Parts
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='2'
|
||||
>
|
||||
<PartsTable data={productData?.parts || []} />
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<PartsTable data={objectData?.parts || []} />
|
||||
</InfoCollapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.notes ? ['notes'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('notes', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
<InfoCollapse
|
||||
title='Notes'
|
||||
icon={<NoteIcon />}
|
||||
active={collapseState.notes}
|
||||
onToggle={(expanded) => updateCollapseState('notes', expanded)}
|
||||
key='notes'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<NoteIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Notes
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='notes'
|
||||
>
|
||||
<Card>
|
||||
<DashboardNotes _id={productId} />
|
||||
</Card>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<Card>
|
||||
<DashboardNotes _id={productId} />
|
||||
</Card>
|
||||
</InfoCollapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('auditLogs', keys.length > 0)
|
||||
<InfoCollapse
|
||||
title='Audit Logs'
|
||||
icon={<AuditLogIcon />}
|
||||
active={collapseState.auditLogs}
|
||||
onToggle={(expanded) =>
|
||||
updateCollapseState('auditLogs', expanded)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
key='auditLogs'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<AuditLogIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Audit Logs
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='auditLogs'
|
||||
>
|
||||
<AuditLogTable
|
||||
items={productData?.auditLogs || []}
|
||||
loading={fetchLoading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<AuditLogTable
|
||||
items={objectData?.auditLogs || []}
|
||||
loading={loading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
</Flex>
|
||||
)}
|
||||
</EditObjectForm>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
} from 'antd'
|
||||
import { ExportOutlined } from '@ant-design/icons'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import IdText from '../common/IdText'
|
||||
import IdDisplay from '../common/IdDisplay'
|
||||
import TimeDisplay from '../common/TimeDisplay'
|
||||
import DashboardTable from '../common/DashboardTable'
|
||||
import PersonIcon from '../../Icons/PersonIcon'
|
||||
@ -248,7 +248,7 @@ const Users = () => {
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'user'} longId={false} />,
|
||||
render: (text) => <IdDisplay id={text} type={'user'} longId={false} />,
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
|
||||
@ -1,509 +1,207 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Descriptions,
|
||||
Spin,
|
||||
Space,
|
||||
Button,
|
||||
message,
|
||||
Typography,
|
||||
Flex,
|
||||
Form,
|
||||
Input,
|
||||
Collapse,
|
||||
Dropdown,
|
||||
Popover,
|
||||
Card,
|
||||
Checkbox
|
||||
} from 'antd'
|
||||
import {
|
||||
LoadingOutlined,
|
||||
ExportOutlined,
|
||||
CaretLeftOutlined
|
||||
} from '@ant-design/icons'
|
||||
import IdText from '../../common/IdText'
|
||||
import TimeDisplay from '../../common/TimeDisplay'
|
||||
import { Space, Button, Flex, Dropdown, Card } from 'antd'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import ReloadIcon from '../../../Icons/ReloadIcon'
|
||||
import EditIcon from '../../../Icons/EditIcon.jsx'
|
||||
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
|
||||
import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
||||
import useCollapseState from '../../hooks/useCollapseState'
|
||||
import AuditLogTable from '../../common/AuditLogTable'
|
||||
import DashboardNotes from '../../common/DashboardNotes'
|
||||
import InfoCollapse from '../../common/InfoCollapse'
|
||||
import ObjectInfo from '../../common/ObjectInfo'
|
||||
import ViewButton from '../../common/ViewButton'
|
||||
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
|
||||
import NoteIcon from '../../../Icons/NoteIcon.jsx'
|
||||
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
|
||||
|
||||
import config from '../../../../config.js'
|
||||
|
||||
const { Title, Link, Text } = Typography
|
||||
import EditObjectForm from '../../common/EditObjectForm'
|
||||
import EditButtons from '../../common/EditButtons'
|
||||
import LockIndicator from '../Filaments/LockIndicator'
|
||||
|
||||
const UserInfo = () => {
|
||||
const [userData, setUserData] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const location = useLocation()
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const userId = new URLSearchParams(location.search).get('userId')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const [fetchLoading, setFetchLoading] = useState(true)
|
||||
const [collapseState, updateCollapseState] = useCollapseState('UserInfo', {
|
||||
info: true,
|
||||
notes: true,
|
||||
auditLogs: true
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
fetchUserDetails()
|
||||
}
|
||||
}, [userId])
|
||||
|
||||
useEffect(() => {
|
||||
if (userData) {
|
||||
form.setFieldsValue({
|
||||
username: userData.username || '',
|
||||
name: userData.name || '',
|
||||
firstName: userData.firstName || '',
|
||||
lastName: userData.lastName || '',
|
||||
email: userData.email || ''
|
||||
})
|
||||
}
|
||||
}, [userData, form])
|
||||
|
||||
const fetchUserDetails = async () => {
|
||||
try {
|
||||
setFetchLoading(true)
|
||||
const response = await axios.get(`${config.backendUrl}/users/${userId}`, {
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
})
|
||||
setUserData(response.data)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError('Failed to fetch user details')
|
||||
messageApi.error('Failed to fetch user details')
|
||||
} finally {
|
||||
setFetchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startEditing = () => {
|
||||
updateCollapseState('info', true)
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
// Reset form values to original data
|
||||
if (userData) {
|
||||
form.setFieldsValue({
|
||||
username: userData.username || '',
|
||||
name: userData.name || '',
|
||||
firstName: userData.firstName || '',
|
||||
lastName: userData.lastName || '',
|
||||
email: userData.email || ''
|
||||
})
|
||||
}
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const updateInfo = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setLoading(true)
|
||||
|
||||
await axios.put(`${config.backendUrl}/users/${userId}`, values, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
setUserData({ ...userData, ...values })
|
||||
setIsEditing(false)
|
||||
messageApi.success('User information updated successfully')
|
||||
} catch (err) {
|
||||
if (err.errorFields) {
|
||||
return
|
||||
}
|
||||
console.error('Failed to update user information:', err)
|
||||
messageApi.error('Failed to update user information')
|
||||
} finally {
|
||||
fetchUserDetails()
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const actionItems = {
|
||||
items: [
|
||||
{
|
||||
label: 'Reload User',
|
||||
key: 'reload',
|
||||
icon: <ReloadIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reload') {
|
||||
fetchUserDetails()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getViewDropdownItems = () => {
|
||||
const sections = [
|
||||
{ key: 'info', label: 'User Information' },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||
]
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
|
||||
{sections.map((section) => (
|
||||
<Checkbox
|
||||
checked={collapseState[section.key]}
|
||||
key={section.key}
|
||||
onChange={(e) => {
|
||||
updateCollapseState(section.key, e.target.checked)
|
||||
}}
|
||||
>
|
||||
{section.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Space
|
||||
direction='vertical'
|
||||
style={{ width: '100%', textAlign: 'center' }}
|
||||
>
|
||||
<p>{error || 'User not found'}</p>
|
||||
<Button icon={<ReloadIcon />} onClick={fetchUserDetails}>
|
||||
Retry
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Dropdown menu={actionItems}>
|
||||
<Button>Actions</Button>
|
||||
</Dropdown>
|
||||
<Popover
|
||||
content={getViewDropdownItems()}
|
||||
placement='bottomLeft'
|
||||
arrow={false}
|
||||
>
|
||||
<Button>View</Button>
|
||||
</Popover>
|
||||
</Space>
|
||||
<Space>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
icon={<CheckIcon />}
|
||||
type='primary'
|
||||
onClick={updateInfo}
|
||||
<EditObjectForm
|
||||
id={userId}
|
||||
type='user'
|
||||
style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
|
||||
>
|
||||
{({
|
||||
loading,
|
||||
isEditing,
|
||||
startEditing,
|
||||
cancelEditing,
|
||||
handleUpdate,
|
||||
formValid,
|
||||
objectData,
|
||||
editLoading,
|
||||
lock,
|
||||
fetchObject
|
||||
}) => (
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='middle'>
|
||||
<Space size='small'>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
label: 'Reload User',
|
||||
key: 'reload',
|
||||
icon: <ReloadIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reload') {
|
||||
fetchObject()
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button disabled={loading}>Actions</Button>
|
||||
</Dropdown>
|
||||
<ViewButton
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
sections={[
|
||||
{ key: 'info', label: 'User Information' },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||
]}
|
||||
collapseState={collapseState}
|
||||
updateCollapseState={updateCollapseState}
|
||||
/>
|
||||
<Button
|
||||
icon={<XMarkIcon />}
|
||||
onClick={cancelEditing}
|
||||
disabled={loading}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button icon={<EditIcon />} onClick={startEditing} />
|
||||
)}
|
||||
</Space>
|
||||
</Flex>
|
||||
</Space>
|
||||
<LockIndicator lock={lock} />
|
||||
</Space>
|
||||
<Space>
|
||||
<EditButtons
|
||||
isEditing={isEditing}
|
||||
handleUpdate={handleUpdate}
|
||||
cancelEditing={cancelEditing}
|
||||
startEditing={startEditing}
|
||||
editLoading={editLoading}
|
||||
formValid={formValid}
|
||||
disabled={lock?.locked || loading || true}
|
||||
loading={editLoading}
|
||||
/>
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
{error ? (
|
||||
<Space
|
||||
direction='vertical'
|
||||
style={{ width: '100%', textAlign: 'center' }}
|
||||
>
|
||||
<p>{error || 'User not found'}</p>
|
||||
<Button icon={<ReloadIcon />} onClick={fetchUserDetails}>
|
||||
Retry
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.info ? ['1'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('info', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined
|
||||
rotate={isActive ? -90 : 0}
|
||||
style={{ paddingTop: '9px' }}
|
||||
/>
|
||||
)}
|
||||
className='no-h-padding-collapse no-t-padding-collapse'
|
||||
<InfoCollapse
|
||||
title='User Information'
|
||||
icon={<InfoCircleIcon />}
|
||||
active={collapseState.info}
|
||||
onToggle={(expanded) => updateCollapseState('info', expanded)}
|
||||
key='info'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<InfoCircleIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
User Information
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='1'
|
||||
>
|
||||
<Form form={form} layout='vertical'>
|
||||
<Spin
|
||||
indicator={<LoadingOutlined />}
|
||||
spinning={fetchLoading}
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
column={{
|
||||
xs: 1,
|
||||
sm: 1,
|
||||
md: 1,
|
||||
lg: 2,
|
||||
xl: 2,
|
||||
xxl: 2
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label='ID'>
|
||||
{userData?._id ? (
|
||||
<IdText id={userData._id} type='user' />
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<ObjectInfo
|
||||
loading={loading}
|
||||
indicator={<LoadingOutlined />}
|
||||
isEditing={isEditing}
|
||||
type='user'
|
||||
items={[
|
||||
{
|
||||
name: '_id',
|
||||
label: 'ID',
|
||||
value: objectData?._id,
|
||||
type: 'id',
|
||||
objectType: 'user',
|
||||
showCopy: true
|
||||
},
|
||||
{
|
||||
name: 'createdAt',
|
||||
label: 'Created At',
|
||||
value: objectData?.createdAt,
|
||||
type: 'dateTime',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
value: objectData?.name,
|
||||
required: true,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'updatedAt',
|
||||
label: 'Updated At',
|
||||
value: objectData?.updatedAt,
|
||||
type: 'dateTime',
|
||||
readOnly: true
|
||||
},
|
||||
|
||||
<Descriptions.Item label='Created At'>
|
||||
{userData?.createdAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={userData.createdAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
{
|
||||
name: 'firstName',
|
||||
label: 'First Name',
|
||||
value: objectData?.firstName,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'username',
|
||||
label: 'Username',
|
||||
value: objectData?.username,
|
||||
required: true,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'lastName',
|
||||
label: 'Last Name',
|
||||
value: objectData?.lastName,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
value: objectData?.email,
|
||||
type: 'email'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</InfoCollapse>
|
||||
|
||||
<Descriptions.Item label='Name'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='name'
|
||||
rules={[
|
||||
{
|
||||
max: 100,
|
||||
message: 'Name cannot exceed 100 characters'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : userData?.name ? (
|
||||
<Text>{userData.name}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Updated At'>
|
||||
{userData?.updatedAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={userData.updatedAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Username'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='username'
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a username'
|
||||
},
|
||||
{
|
||||
max: 50,
|
||||
message:
|
||||
'Username cannot exceed 50 characters'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : userData?.username ? (
|
||||
<Text>{userData.username}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='First Name'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='firstName'
|
||||
rules={[
|
||||
{
|
||||
max: 50,
|
||||
message:
|
||||
'First name cannot exceed 50 characters'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : userData?.firstName ? (
|
||||
<Text>{userData.firstName}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Email'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='email'
|
||||
rules={[
|
||||
{
|
||||
type: 'email',
|
||||
message: 'Please enter a valid email'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : userData?.email ? (
|
||||
<Link href={`mailto:${userData.email}`}>
|
||||
{userData.email + ' '}
|
||||
<ExportOutlined />
|
||||
</Link>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Last Name'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='lastName'
|
||||
rules={[
|
||||
{
|
||||
max: 50,
|
||||
message:
|
||||
'Last name cannot exceed 50 characters'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : userData?.lastName ? (
|
||||
<Text>{userData.lastName}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Spin>
|
||||
</Form>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.notes ? ['notes'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('notes', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
<InfoCollapse
|
||||
title='Notes'
|
||||
icon={<NoteIcon />}
|
||||
active={collapseState.notes}
|
||||
onToggle={(expanded) => updateCollapseState('notes', expanded)}
|
||||
key='notes'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<NoteIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Notes
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='notes'
|
||||
>
|
||||
<Card>
|
||||
<DashboardNotes _id={userId} />
|
||||
</Card>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<Card>
|
||||
<DashboardNotes _id={userId} />
|
||||
</Card>
|
||||
</InfoCollapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('auditLogs', keys.length > 0)
|
||||
<InfoCollapse
|
||||
title='Audit Logs'
|
||||
icon={<AuditLogIcon />}
|
||||
active={collapseState.auditLogs}
|
||||
onToggle={(expanded) =>
|
||||
updateCollapseState('auditLogs', expanded)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
key='auditLogs'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<AuditLogIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Audit Logs
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='auditLogs'
|
||||
>
|
||||
<AuditLogTable
|
||||
items={userData?.auditLogs || []}
|
||||
loading={fetchLoading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<AuditLogTable
|
||||
items={objectData?.auditLogs || []}
|
||||
loading={loading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
</Flex>
|
||||
)}
|
||||
</EditObjectForm>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
} from 'antd'
|
||||
import { ExportOutlined } from '@ant-design/icons'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import IdText from '../common/IdText'
|
||||
import IdDisplay from '../common/IdDisplay'
|
||||
import NewVendor from './Vendors/NewVendor'
|
||||
import CountryDisplay from '../common/CountryDisplay'
|
||||
import TimeDisplay from '../common/TimeDisplay'
|
||||
@ -155,7 +155,7 @@ const Vendors = () => {
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'vendor'} longId={false} />,
|
||||
render: (text) => <IdDisplay id={text} type={'vendor'} longId={false} />,
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
|
||||
@ -1,539 +1,216 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import React from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Descriptions,
|
||||
Spin,
|
||||
Space,
|
||||
Button,
|
||||
message,
|
||||
Typography,
|
||||
Flex,
|
||||
Form,
|
||||
Input,
|
||||
Collapse,
|
||||
Dropdown,
|
||||
Popover,
|
||||
Card,
|
||||
Checkbox
|
||||
} from 'antd'
|
||||
import {
|
||||
LoadingOutlined,
|
||||
ExportOutlined,
|
||||
CaretLeftOutlined
|
||||
} from '@ant-design/icons'
|
||||
import IdText from '../../common/IdText'
|
||||
import CountrySelect from '../../common/CountrySelect'
|
||||
import CountryDisplay from '../../common/CountryDisplay'
|
||||
import TimeDisplay from '../../common/TimeDisplay'
|
||||
import { Space, Button, Flex, Dropdown, Card } from 'antd'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import loglevel from 'loglevel'
|
||||
import config from '../../../../config'
|
||||
import ReloadIcon from '../../../Icons/ReloadIcon'
|
||||
import EditIcon from '../../../Icons/EditIcon.jsx'
|
||||
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
|
||||
import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
||||
import useCollapseState from '../../hooks/useCollapseState'
|
||||
import AuditLogTable from '../../common/AuditLogTable'
|
||||
import DashboardNotes from '../../common/DashboardNotes'
|
||||
import InfoCollapse from '../../common/InfoCollapse'
|
||||
import ObjectInfo from '../../common/ObjectInfo'
|
||||
import ViewButton from '../../common/ViewButton'
|
||||
|
||||
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
|
||||
import NoteIcon from '../../../Icons/NoteIcon.jsx'
|
||||
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
|
||||
import EditObjectForm from '../../common/EditObjectForm'
|
||||
import EditButtons from '../../common/EditButtons'
|
||||
import LockIndicator from '../Filaments/LockIndicator'
|
||||
|
||||
import config from '../../../../config.js'
|
||||
|
||||
const { Title, Link, Text } = Typography
|
||||
const log = loglevel.getLogger('VendorInfo')
|
||||
log.setLevel(config.logLevel)
|
||||
|
||||
const VendorInfo = () => {
|
||||
const [vendorData, setVendorData] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const location = useLocation()
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const vendorId = new URLSearchParams(location.search).get('vendorId')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const [fetchLoading, setFetchLoading] = useState(true)
|
||||
const [collapseState, updateCollapseState] = useCollapseState('VendorInfo', {
|
||||
info: true,
|
||||
notes: true,
|
||||
auditLogs: true
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (vendorId) {
|
||||
fetchVendorDetails()
|
||||
}
|
||||
}, [vendorId, fetchVendorDetails])
|
||||
|
||||
useEffect(() => {
|
||||
if (vendorData) {
|
||||
form.setFieldsValue({
|
||||
name: vendorData.name || '',
|
||||
website: vendorData.website || '',
|
||||
contact: vendorData.contact || '',
|
||||
country: vendorData.country || '',
|
||||
phone: vendorData.phone || '',
|
||||
email: vendorData.email || ''
|
||||
})
|
||||
}
|
||||
}, [vendorData, form])
|
||||
|
||||
const fetchVendorDetails = useCallback(async () => {
|
||||
try {
|
||||
setFetchLoading(true)
|
||||
const response = await axios.get(
|
||||
`${config.backendUrl}/vendors/${vendorId}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
setVendorData(response.data)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError('Failed to fetch vendor details')
|
||||
messageApi.error('Failed to fetch vendor details')
|
||||
} finally {
|
||||
setFetchLoading(false)
|
||||
}
|
||||
}, [messageApi, vendorId])
|
||||
|
||||
const startEditing = () => {
|
||||
updateCollapseState('info', true)
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
// Reset form values to original data
|
||||
if (vendorData) {
|
||||
form.setFieldsValue({
|
||||
name: vendorData.name || '',
|
||||
website: vendorData.website || '',
|
||||
contact: vendorData.contact || '',
|
||||
country: vendorData.country || '',
|
||||
phone: vendorData.phone || '',
|
||||
email: vendorData.email || ''
|
||||
})
|
||||
}
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const updateInfo = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setLoading(true)
|
||||
|
||||
await axios.put(`${config.backendUrl}/vendors/${vendorId}`, values, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
setVendorData({ ...vendorData, ...values })
|
||||
setIsEditing(false)
|
||||
messageApi.success('Vendor information updated successfully')
|
||||
} catch (err) {
|
||||
if (err.errorFields) {
|
||||
return
|
||||
}
|
||||
messageApi.error('Failed to update vendor information')
|
||||
} finally {
|
||||
fetchVendorDetails()
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const actionItems = {
|
||||
items: [
|
||||
{
|
||||
label: 'Reload Vendor',
|
||||
key: 'reload',
|
||||
icon: <ReloadIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reload') {
|
||||
fetchVendorDetails()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getViewDropdownItems = () => {
|
||||
const sections = [
|
||||
{ key: 'info', label: 'Vendor Information' },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||
]
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
|
||||
{sections.map((section) => (
|
||||
<Checkbox
|
||||
checked={collapseState[section.key]}
|
||||
key={section.key}
|
||||
onChange={(e) => {
|
||||
updateCollapseState(section.key, e.target.checked)
|
||||
}}
|
||||
>
|
||||
{section.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Space
|
||||
direction='vertical'
|
||||
style={{ width: '100%', textAlign: 'center' }}
|
||||
>
|
||||
<p>{error || 'Vendor not found'}</p>
|
||||
<Button icon={<ReloadIcon />} onClick={fetchVendorDetails}>
|
||||
Retry
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Dropdown menu={actionItems}>
|
||||
<Button>Actions</Button>
|
||||
</Dropdown>
|
||||
<Popover
|
||||
content={getViewDropdownItems()}
|
||||
placement='bottomLeft'
|
||||
arrow={false}
|
||||
>
|
||||
<Button>View</Button>
|
||||
</Popover>
|
||||
</Space>
|
||||
<Space>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
icon={<CheckIcon />}
|
||||
type='primary'
|
||||
onClick={updateInfo}
|
||||
<EditObjectForm
|
||||
id={vendorId}
|
||||
type='vendor'
|
||||
style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
|
||||
>
|
||||
{({
|
||||
loading,
|
||||
isEditing,
|
||||
startEditing,
|
||||
cancelEditing,
|
||||
handleUpdate,
|
||||
formValid,
|
||||
objectData,
|
||||
editLoading,
|
||||
lock,
|
||||
fetchObject
|
||||
}) => (
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='middle'>
|
||||
<Space size='small'>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
label: 'Reload Vendor',
|
||||
key: 'reload',
|
||||
icon: <ReloadIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reload') {
|
||||
fetchObject()
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button disabled={loading}>Actions</Button>
|
||||
</Dropdown>
|
||||
<ViewButton
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
sections={[
|
||||
{ key: 'info', label: 'Vendor Information' },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||
]}
|
||||
collapseState={collapseState}
|
||||
updateCollapseState={updateCollapseState}
|
||||
/>
|
||||
<Button
|
||||
icon={<XMarkIcon />}
|
||||
onClick={cancelEditing}
|
||||
disabled={loading}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button icon={<EditIcon />} onClick={startEditing} />
|
||||
)}
|
||||
</Space>
|
||||
</Flex>
|
||||
</Space>
|
||||
<LockIndicator lock={lock} />
|
||||
</Space>
|
||||
<Space>
|
||||
<EditButtons
|
||||
isEditing={isEditing}
|
||||
handleUpdate={handleUpdate}
|
||||
cancelEditing={cancelEditing}
|
||||
startEditing={startEditing}
|
||||
editLoading={editLoading}
|
||||
formValid={formValid}
|
||||
disabled={lock?.locked || loading}
|
||||
loading={editLoading}
|
||||
/>
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
{error ? (
|
||||
<Space
|
||||
direction='vertical'
|
||||
style={{ width: '100%', textAlign: 'center' }}
|
||||
>
|
||||
<p>{error || 'Vendor not found'}</p>
|
||||
<Button icon={<ReloadIcon />} onClick={fetchVendorDetails}>
|
||||
Retry
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.info ? ['1'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('info', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined
|
||||
rotate={isActive ? -90 : 0}
|
||||
style={{ paddingTop: '9px' }}
|
||||
/>
|
||||
)}
|
||||
className='no-h-padding-collapse no-t-padding-collapse'
|
||||
<InfoCollapse
|
||||
title='Vendor Information'
|
||||
icon={<InfoCircleIcon />}
|
||||
active={collapseState.info}
|
||||
onToggle={(expanded) => updateCollapseState('info', expanded)}
|
||||
key='info'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<InfoCircleIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Vendor Information
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='1'
|
||||
>
|
||||
<Form form={form} layout='vertical'>
|
||||
<Spin
|
||||
indicator={<LoadingOutlined />}
|
||||
spinning={fetchLoading}
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
column={{
|
||||
xs: 1,
|
||||
sm: 1,
|
||||
md: 1,
|
||||
lg: 2,
|
||||
xl: 2,
|
||||
xxl: 2
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label='ID'>
|
||||
{vendorData?._id ? (
|
||||
<IdText id={vendorData._id} type='vendor' />
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Created At'>
|
||||
{vendorData?.createdAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={vendorData.createdAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<ObjectInfo
|
||||
loading={loading}
|
||||
indicator={<LoadingOutlined />}
|
||||
isEditing={isEditing}
|
||||
items={[
|
||||
{
|
||||
name: 'id',
|
||||
label: 'ID',
|
||||
value: objectData?._id,
|
||||
type: 'id',
|
||||
objectType: 'vendor',
|
||||
showCopy: true
|
||||
},
|
||||
{
|
||||
name: 'createdAt',
|
||||
label: 'Created At',
|
||||
value: objectData?.createdAt,
|
||||
type: 'dateTime',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
value: objectData?.name,
|
||||
required: true,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'updatedAt',
|
||||
label: 'Updated At',
|
||||
value: objectData?.updatedAt,
|
||||
type: 'dateTime',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: 'website',
|
||||
label: 'Website',
|
||||
value: objectData?.website,
|
||||
type: 'url'
|
||||
},
|
||||
{
|
||||
name: 'country',
|
||||
label: 'Country',
|
||||
value: objectData?.country,
|
||||
type: 'country'
|
||||
},
|
||||
{
|
||||
name: 'contact',
|
||||
label: 'Contact',
|
||||
value: objectData?.contact,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
label: 'Phone',
|
||||
value: objectData?.phone,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
value: objectData?.email,
|
||||
type: 'email'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</InfoCollapse>
|
||||
|
||||
<Descriptions.Item label='Name'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='name'
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a vendor name'
|
||||
},
|
||||
{
|
||||
max: 100,
|
||||
message: 'Name cannot exceed 100 characters'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : vendorData?.name ? (
|
||||
<Text>{vendorData.name}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Updated At'>
|
||||
{vendorData?.updatedAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={vendorData.updatedAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Website'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='website'
|
||||
rules={[
|
||||
{
|
||||
type: 'url',
|
||||
message: 'Please enter a valid URL'
|
||||
},
|
||||
{
|
||||
max: 200,
|
||||
message:
|
||||
'Website URL cannot exceed 200 characters'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : vendorData?.website ? (
|
||||
<Link
|
||||
href={vendorData.website}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
{new URL(vendorData.website).hostname + ' '}
|
||||
<ExportOutlined />
|
||||
</Link>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Country'>
|
||||
{isEditing ? (
|
||||
<Form.Item name='country' style={{ margin: 0 }}>
|
||||
<CountrySelect
|
||||
countryCode={vendorData?.country}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : vendorData?.country ? (
|
||||
<CountryDisplay countryCode={vendorData.country} />
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Contact'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='contact'
|
||||
rules={[
|
||||
{
|
||||
max: 200,
|
||||
message:
|
||||
'Contact info cannot exceed 200 characters'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : vendorData?.contact ? (
|
||||
<Text>{vendorData.contact}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Phone'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='phone'
|
||||
rules={[
|
||||
{
|
||||
type: 'phone',
|
||||
message: 'Please enter a valid phone number'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : vendorData?.phone ? (
|
||||
<Text>{vendorData.phone}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Email'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='email'
|
||||
rules={[
|
||||
{
|
||||
type: 'email',
|
||||
message: 'Please enter a valid email'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : vendorData?.email ? (
|
||||
<Link href={`mailto:${vendorData.email}`}>
|
||||
{vendorData.email + ' '}
|
||||
<ExportOutlined />
|
||||
</Link>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Spin>
|
||||
</Form>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.notes ? ['notes'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('notes', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
<InfoCollapse
|
||||
title='Notes'
|
||||
icon={<NoteIcon />}
|
||||
active={collapseState.notes}
|
||||
onToggle={(expanded) => updateCollapseState('notes', expanded)}
|
||||
key='notes'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<NoteIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Notes
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='notes'
|
||||
>
|
||||
<Card>
|
||||
<DashboardNotes _id={vendorId} />
|
||||
</Card>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<Card>
|
||||
<DashboardNotes _id={vendorId} />
|
||||
</Card>
|
||||
</InfoCollapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('auditLogs', keys.length > 0)
|
||||
<InfoCollapse
|
||||
title='Audit Logs'
|
||||
icon={<AuditLogIcon />}
|
||||
active={collapseState.auditLogs}
|
||||
onToggle={(expanded) =>
|
||||
updateCollapseState('auditLogs', expanded)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
key='auditLogs'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<AuditLogIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Audit Logs
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='auditLogs'
|
||||
>
|
||||
<AuditLogTable
|
||||
items={vendorData?.auditLogs || []}
|
||||
loading={fetchLoading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<AuditLogTable
|
||||
items={objectData?.auditLogs || []}
|
||||
loading={loading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
</Flex>
|
||||
)}
|
||||
</EditObjectForm>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ import { DownloadOutlined } from '@ant-design/icons'
|
||||
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import NewGCodeFile from './GCodeFiles/NewGCodeFile'
|
||||
import IdText from '../common/IdText'
|
||||
import IdDisplay from '../common/IdDisplay'
|
||||
import GCodeFileIcon from '../../Icons/GCodeFileIcon'
|
||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||
import useColumnVisibility from '../hooks/useColumnVisibility'
|
||||
@ -120,7 +120,9 @@ const GCodeFiles = () => {
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'gcodefile'} longId={false} />
|
||||
render: (text) => (
|
||||
<IdDisplay id={text} type={'gcodefile'} longId={false} />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Filament',
|
||||
|
||||
@ -1,596 +1,295 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Descriptions,
|
||||
Spin,
|
||||
Space,
|
||||
Button,
|
||||
message,
|
||||
Badge,
|
||||
Form,
|
||||
Typography,
|
||||
Flex,
|
||||
Input,
|
||||
Card,
|
||||
Collapse,
|
||||
Dropdown,
|
||||
Popover,
|
||||
Checkbox
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||
import IdText from '../../common/IdText.jsx'
|
||||
import { capitalizeFirstLetter } from '../../utils/Utils.js'
|
||||
import FilamentSelect from '../../common/FilamentSelect'
|
||||
import useCollapseState from '../../hooks/useCollapseState'
|
||||
import FilamentIcon from '../../../Icons/FilamentIcon'
|
||||
import TimeDisplay from '../../common/TimeDisplay.jsx'
|
||||
import { Space, Button, Flex, Dropdown, Card, Typography } from 'antd'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import ReloadIcon from '../../../Icons/ReloadIcon'
|
||||
import EditIcon from '../../../Icons/EditIcon.jsx'
|
||||
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
|
||||
import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
||||
|
||||
import config from '../../../../config.js'
|
||||
import AuditLogTable from '../../common/AuditLogTable.jsx'
|
||||
import DashboardNotes from '../../common/DashboardNotes.jsx'
|
||||
import BinIcon from '../../../Icons/BinIcon.jsx'
|
||||
import useCollapseState from '../../hooks/useCollapseState'
|
||||
import AuditLogTable from '../../common/AuditLogTable'
|
||||
import DashboardNotes from '../../common/DashboardNotes'
|
||||
import InfoCollapse from '../../common/InfoCollapse'
|
||||
import ObjectInfo from '../../common/ObjectInfo'
|
||||
import ViewButton from '../../common/ViewButton'
|
||||
import EditObjectForm from '../../common/EditObjectForm'
|
||||
import EditButtons from '../../common/EditButtons'
|
||||
import LockIndicator from '../../Management/Filaments/LockIndicator'
|
||||
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
|
||||
import GCodeFileIcon from '../../../Icons/GCodeFileIcon.jsx'
|
||||
import NoteIcon from '../../../Icons/NoteIcon.jsx'
|
||||
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
|
||||
import GCodeFileIcon from '../../../Icons/GCodeFileIcon.jsx'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
const { Text } = Typography
|
||||
|
||||
const GCodeFileInfo = () => {
|
||||
const [gcodeFileData, setGCodeFileData] = useState(null)
|
||||
const [editLoading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const location = useLocation()
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const gcodeFileId = new URLSearchParams(location.search).get('gcodeFileId')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const [fetchLoading, setFetchLoading] = useState(true)
|
||||
const [collapseState, updateCollapseState] = useCollapseState(
|
||||
'GCodeFileInfo',
|
||||
{
|
||||
info: true,
|
||||
preview: true
|
||||
preview: true,
|
||||
notes: true,
|
||||
auditLogs: true
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (gcodeFileId) {
|
||||
fetchGCodeFileDetails()
|
||||
}
|
||||
}, [gcodeFileId])
|
||||
|
||||
useEffect(() => {
|
||||
if (gcodeFileData) {
|
||||
form.setFieldsValue({
|
||||
name: gcodeFileData.name || '',
|
||||
filament: gcodeFileData.filament || { id: null, name: '' }
|
||||
})
|
||||
}
|
||||
}, [gcodeFileData, form])
|
||||
|
||||
const fetchGCodeFileDetails = async () => {
|
||||
try {
|
||||
setFetchLoading(true)
|
||||
const response = await axios.get(
|
||||
`${config.backendUrl}/gcodefiles/${gcodeFileId}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
setGCodeFileData(response.data)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError('Failed to fetch GCodeFile details')
|
||||
messageApi.error('Failed to fetch GCodeFile details')
|
||||
} finally {
|
||||
setFetchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startEditing = () => {
|
||||
setIsEditing(true)
|
||||
updateCollapseState('info', true)
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
form.setFieldsValue({
|
||||
name: gcodeFileData?.name || '',
|
||||
filament: gcodeFileData?.filament || { id: null, name: '' }
|
||||
})
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const updateGCodeFileInfo = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setLoading(true)
|
||||
|
||||
await axios.put(
|
||||
`${config.backendUrl}/gcodefiles/${gcodeFileId}`,
|
||||
values,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
setGCodeFileData({ ...gcodeFileData, ...values })
|
||||
setIsEditing(false)
|
||||
messageApi.success('GCode File information updated successfully')
|
||||
} catch (err) {
|
||||
if (err.errorFields) {
|
||||
return
|
||||
}
|
||||
console.error('Failed to update gcode file information:', err)
|
||||
messageApi.error('Failed to update gcode file information')
|
||||
} finally {
|
||||
fetchGCodeFileDetails()
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const actionItems = {
|
||||
items: [
|
||||
{
|
||||
label: 'Edit GCode File',
|
||||
key: 'edit',
|
||||
icon: <EditIcon />
|
||||
},
|
||||
{
|
||||
label: 'Delete GCode File',
|
||||
key: 'delete',
|
||||
icon: <BinIcon />,
|
||||
danger: true
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
label: 'Reload GCode File',
|
||||
key: 'reload',
|
||||
icon: <ReloadIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reload') {
|
||||
fetchGCodeFileDetails()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getViewDropdownItems = () => {
|
||||
const sections = [
|
||||
{ key: 'info', label: 'GCode File Information' },
|
||||
{ key: 'preview', label: 'GCode File Preview' },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||
]
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
|
||||
{sections.map((section) => (
|
||||
<Checkbox
|
||||
checked={collapseState[section.key]}
|
||||
key={section.key}
|
||||
onChange={(e) => {
|
||||
updateCollapseState(section.key, e.target.checked)
|
||||
}}
|
||||
>
|
||||
{section.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Dropdown menu={actionItems}>
|
||||
<Button>Actions</Button>
|
||||
</Dropdown>
|
||||
<Popover
|
||||
content={getViewDropdownItems()}
|
||||
placement='bottomLeft'
|
||||
arrow={false}
|
||||
>
|
||||
<Button>View</Button>
|
||||
</Popover>
|
||||
</Space>
|
||||
<Space>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
icon={<CheckIcon />}
|
||||
type='primary'
|
||||
onClick={updateGCodeFileInfo}
|
||||
loading={editLoading}
|
||||
disabled={editLoading}
|
||||
<EditObjectForm
|
||||
id={gcodeFileId}
|
||||
type='gcodefile'
|
||||
style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
|
||||
>
|
||||
{({
|
||||
loading,
|
||||
isEditing,
|
||||
startEditing,
|
||||
cancelEditing,
|
||||
handleUpdate,
|
||||
formValid,
|
||||
objectData,
|
||||
editLoading,
|
||||
lock,
|
||||
fetchObject
|
||||
}) => (
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='middle'>
|
||||
<Space size='small'>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
label: 'Reload GCode File',
|
||||
key: 'reload',
|
||||
icon: <ReloadIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reload') {
|
||||
fetchObject()
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button disabled={loading}>Actions</Button>
|
||||
</Dropdown>
|
||||
<ViewButton
|
||||
loading={loading}
|
||||
sections={[
|
||||
{ key: 'info', label: 'GCode File Information' },
|
||||
{ key: 'preview', label: 'GCode File Preview' },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||
]}
|
||||
collapseState={collapseState}
|
||||
updateCollapseState={updateCollapseState}
|
||||
/>
|
||||
<Button
|
||||
icon={<XMarkIcon />}
|
||||
onClick={cancelEditing}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button icon={<EditIcon />} onClick={startEditing} />
|
||||
)}
|
||||
</Space>
|
||||
</Flex>
|
||||
</Space>
|
||||
<LockIndicator lock={lock} />
|
||||
</Space>
|
||||
<Space>
|
||||
<EditButtons
|
||||
isEditing={isEditing}
|
||||
handleUpdate={handleUpdate}
|
||||
cancelEditing={cancelEditing}
|
||||
startEditing={startEditing}
|
||||
editLoading={editLoading}
|
||||
formValid={formValid}
|
||||
disabled={lock?.locked || loading}
|
||||
loading={editLoading}
|
||||
/>
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
{error ? (
|
||||
<Space
|
||||
direction='vertical'
|
||||
style={{ width: '100%', textAlign: 'center' }}
|
||||
>
|
||||
<p>{error || 'Print job not found'}</p>
|
||||
<Button icon={<ReloadIcon />} onClick={fetchGCodeFileDetails}>
|
||||
Retry
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.info ? ['info'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('info', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse no-t-padding-collapse'
|
||||
<InfoCollapse
|
||||
title='GCode File Information'
|
||||
icon={<InfoCircleIcon />}
|
||||
active={collapseState.info}
|
||||
onToggle={(expanded) => updateCollapseState('info', expanded)}
|
||||
key='info'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<InfoCircleIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
GCode File Information
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='info'
|
||||
>
|
||||
<Form form={form} layout='vertical'>
|
||||
<Spin
|
||||
spinning={fetchLoading}
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
column={{
|
||||
xs: 1,
|
||||
sm: 1,
|
||||
md: 1,
|
||||
lg: 2,
|
||||
xl: 2,
|
||||
xxl: 2
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label='ID' span={1}>
|
||||
{gcodeFileData?._id ? (
|
||||
<IdText
|
||||
id={gcodeFileData._id}
|
||||
type='gcodefile'
|
||||
></IdText>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Created At'>
|
||||
{gcodeFileData?.createdAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={gcodeFileData.createdAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<ObjectInfo
|
||||
loading={loading}
|
||||
indicator={<LoadingOutlined />}
|
||||
isEditing={isEditing}
|
||||
items={[
|
||||
{
|
||||
name: '_id',
|
||||
label: 'ID',
|
||||
type: 'id',
|
||||
objectType: 'gcodefile',
|
||||
value: objectData?._id,
|
||||
showCopy: true
|
||||
},
|
||||
{
|
||||
name: 'createdAt',
|
||||
label: 'Created At',
|
||||
type: 'dateTime',
|
||||
value: objectData?.createdAt,
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
type: 'text',
|
||||
value: objectData?.name,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'updatedAt',
|
||||
label: 'Updated At',
|
||||
type: 'dateTime',
|
||||
value: objectData?.updatedAt,
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: 'filament',
|
||||
label: 'Filament',
|
||||
type: 'object',
|
||||
value: objectData?.filament,
|
||||
objectType: 'filament',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'cost',
|
||||
label: 'Cost',
|
||||
type: 'currency',
|
||||
value: objectData?.cost,
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: [
|
||||
'gcodeFileInfo',
|
||||
'estimatedPrintingTimeNormalMode'
|
||||
],
|
||||
label: 'Est Print Time',
|
||||
value:
|
||||
objectData?.gcodeFileInfo
|
||||
?.estimatedPrintingTimeNormalMode,
|
||||
type: 'text',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: ['gcodeFileInfo', 'sparseInfillDensity'],
|
||||
label: 'Infill Density',
|
||||
value: objectData?.gcodeFileInfo?.sparseInfillDensity,
|
||||
type: 'number',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: ['gcodeFileInfo', 'sparseInfillPattern'],
|
||||
label: 'Infill Pattern',
|
||||
value: objectData?.gcodeFileInfo?.sparseInfillPattern,
|
||||
type: 'text',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: ['gcodeFileInfo', 'filamentUsedMm'],
|
||||
label: 'Filament Used (mm)',
|
||||
value: objectData?.gcodeFileInfo?.filamentUsedMm,
|
||||
type: 'mm',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: ['gcodeFileInfo', 'filamentUsedG'],
|
||||
label: 'Filament Used (g)',
|
||||
value: objectData?.gcodeFileInfo?.filamentUsedG,
|
||||
type: 'weight',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: ['gcodeFileInfo', 'nozzleTemperature'],
|
||||
label: 'Hotend Temperature',
|
||||
value: objectData?.gcodeFileInfo?.nozzleTemperature,
|
||||
type: 'number',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: ['gcodeFileInfo', 'hotPlateTemp'],
|
||||
label: 'Bed Temperature',
|
||||
value: objectData?.gcodeFileInfo?.hotPlateTemp,
|
||||
type: 'number',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: ['gcodeFileInfo', 'filamentSettingsId'],
|
||||
label: 'Filament Profile',
|
||||
value: objectData?.gcodeFileInfo?.filamentSettingsId,
|
||||
type: 'text',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: ['gcodeFileInfo', 'printSettingsId'],
|
||||
label: 'Print Profile',
|
||||
value: objectData?.gcodeFileInfo?.printSettingsId,
|
||||
type: 'text',
|
||||
readOnly: true
|
||||
}
|
||||
]}
|
||||
objectData={objectData}
|
||||
type='gcodefile'
|
||||
/>
|
||||
</InfoCollapse>
|
||||
|
||||
<Descriptions.Item label='Name'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='name'
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a vendor name'
|
||||
},
|
||||
{
|
||||
max: 100,
|
||||
message: 'Name cannot exceed 100 characters'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : gcodeFileData?.name ? (
|
||||
<Text>{gcodeFileData.name}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Updated At'>
|
||||
{gcodeFileData?.updatedAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={gcodeFileData.updatedAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Filament Name'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='filament'
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a filament'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<FilamentSelect />
|
||||
</Form.Item>
|
||||
) : gcodeFileData?.filament ? (
|
||||
<Space>
|
||||
<FilamentIcon />
|
||||
<Badge
|
||||
color={gcodeFileData.filament.color}
|
||||
text={gcodeFileData.filament.name}
|
||||
/>
|
||||
</Space>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Filament ID'>
|
||||
{gcodeFileData?.filament ? (
|
||||
<IdText
|
||||
id={gcodeFileData.filament.id}
|
||||
type={'filament'}
|
||||
showHyperlink={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Est Print Time'>
|
||||
{gcodeFileData?.gcodeFileInfo
|
||||
?.estimatedPrintingTimeNormalMode ? (
|
||||
<Text>
|
||||
{
|
||||
gcodeFileData.gcodeFileInfo
|
||||
.estimatedPrintingTimeNormalMode
|
||||
}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Cost'>
|
||||
{gcodeFileData?.cost ? (
|
||||
<Text>{'£' + gcodeFileData.cost.toFixed(2)}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Infill Density'>
|
||||
{gcodeFileData?.gcodeFileInfo?.sparseInfillDensity ? (
|
||||
<Text>
|
||||
{gcodeFileData.gcodeFileInfo.sparseInfillDensity}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Infill Pattern'>
|
||||
{gcodeFileData?.gcodeFileInfo?.sparseInfillPattern ? (
|
||||
<Text>
|
||||
{capitalizeFirstLetter(
|
||||
gcodeFileData.gcodeFileInfo.sparseInfillPattern
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Filament Used (mm)'>
|
||||
{gcodeFileData?.gcodeFileInfo?.filamentUsedMm ? (
|
||||
<Text>
|
||||
{gcodeFileData.gcodeFileInfo.filamentUsedMm}mm
|
||||
</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Filament Used (g)'>
|
||||
{gcodeFileData?.gcodeFileInfo?.filamentUsedG ? (
|
||||
<Text>
|
||||
{gcodeFileData.gcodeFileInfo.filamentUsedG}g
|
||||
</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Hotend Temperature'>
|
||||
{gcodeFileData?.gcodeFileInfo?.nozzleTemperature ? (
|
||||
<Text>
|
||||
{gcodeFileData.gcodeFileInfo.nozzleTemperature}°
|
||||
</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Bed Temperature'>
|
||||
{gcodeFileData?.gcodeFileInfo?.hotPlateTemp ? (
|
||||
<Text>
|
||||
{gcodeFileData.gcodeFileInfo.hotPlateTemp}°
|
||||
</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Filament Profile'>
|
||||
{gcodeFileData?.gcodeFileInfo?.filamentSettingsId ? (
|
||||
<Text>
|
||||
{gcodeFileData.gcodeFileInfo.filamentSettingsId.replaceAll(
|
||||
'"',
|
||||
''
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Print Profile'>
|
||||
{gcodeFileData?.gcodeFileInfo?.printSettingsId ? (
|
||||
<Text>
|
||||
{gcodeFileData.gcodeFileInfo.printSettingsId.replaceAll(
|
||||
'"',
|
||||
''
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Spin>
|
||||
</Form>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.preview ? ['preview'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('preview', keys.length > 0)
|
||||
<InfoCollapse
|
||||
title='GCode File Preview'
|
||||
icon={<GCodeFileIcon />}
|
||||
active={collapseState.preview}
|
||||
onToggle={(expanded) =>
|
||||
updateCollapseState('preview', expanded)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
key='preview'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<GCodeFileIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
GCode File Preview
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='preview'
|
||||
>
|
||||
<Spin spinning={fetchLoading} indicator={<LoadingOutlined />}>
|
||||
<Card styles={{ body: { padding: '10px' } }}>
|
||||
{gcodeFileData?.gcodeFileInfo?.thumbnail ? (
|
||||
<img
|
||||
src={`data:image/png;base64,${gcodeFileData.gcodeFileInfo.thumbnail.data}`}
|
||||
alt='GCodeFile'
|
||||
style={{ maxWidth: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Card>
|
||||
</Spin>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<Card>
|
||||
{objectData?.gcodeFileInfo?.thumbnail ? (
|
||||
<img
|
||||
src={`data:image/png;base64,${objectData.gcodeFileInfo.thumbnail.data}`}
|
||||
alt='GCodeFile'
|
||||
style={{ maxWidth: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Card>
|
||||
</InfoCollapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.notes ? ['notes'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('notes', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
<InfoCollapse
|
||||
title='Notes'
|
||||
icon={<NoteIcon />}
|
||||
active={collapseState.notes}
|
||||
onToggle={(expanded) => updateCollapseState('notes', expanded)}
|
||||
key='notes'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<NoteIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Notes
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='notes'
|
||||
>
|
||||
<Card>
|
||||
<DashboardNotes _id={gcodeFileId} />
|
||||
</Card>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<Card>
|
||||
<DashboardNotes _id={gcodeFileId} />
|
||||
</Card>
|
||||
</InfoCollapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('auditLogs', keys.length > 0)
|
||||
<InfoCollapse
|
||||
title='Audit Logs'
|
||||
icon={<AuditLogIcon />}
|
||||
active={collapseState.auditLogs}
|
||||
onToggle={(expanded) =>
|
||||
updateCollapseState('auditLogs', expanded)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
key='auditLogs'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<AuditLogIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Audit Log
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='auditLogs'
|
||||
>
|
||||
<AuditLogTable
|
||||
items={gcodeFileData?.auditLogs || []}
|
||||
loading={fetchLoading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<AuditLogTable
|
||||
items={objectData?.auditLogs || []}
|
||||
loading={loading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
</Flex>
|
||||
)}
|
||||
</EditObjectForm>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -218,7 +218,6 @@ const NewGCodeFile = ({ onOk, reset }) => {
|
||||
newGCodeFileFormValues?.gcodeFileInfo?.filamentUsedG
|
||||
if (filamentCost && gcodeFilamentUsed) {
|
||||
const cost = (filamentCost / 1000) * gcodeFilamentUsed
|
||||
console.log('Setting cost')
|
||||
setNewGCodeFileFormValues((prev) => ({ ...prev, cost: cost.toFixed(2) }))
|
||||
newGCodeFileForm.setFieldValue('cost', cost.toFixed(2))
|
||||
}
|
||||
@ -304,8 +303,6 @@ const NewGCodeFile = ({ onOk, reset }) => {
|
||||
gcodeFileInfo: parsedConfig
|
||||
})
|
||||
|
||||
console.log(parsedConfig)
|
||||
|
||||
// Update filter settings if filament info is available
|
||||
if (parsedConfig.filament_type && parsedConfig.filament_diameter) {
|
||||
setFilamentSelectFilter({
|
||||
@ -525,7 +522,6 @@ const NewGCodeFile = ({ onOk, reset }) => {
|
||||
onClick={() => {
|
||||
setCurrentStep(currentStep + 1)
|
||||
setNextEnabled(false)
|
||||
console.log(newGCodeFileFormValues)
|
||||
}}
|
||||
>
|
||||
Next
|
||||
|
||||
@ -22,7 +22,7 @@ import NewJob from './Jobs/NewJob.jsx'
|
||||
import JobState from '../common/JobState.jsx'
|
||||
import SubJobCounter from '../common/SubJobCounter.jsx'
|
||||
import TimeDisplay from '../common/TimeDisplay.jsx'
|
||||
import IdText from '../common/IdText.jsx'
|
||||
import IdDisplay from '../common/IdDisplay.jsx'
|
||||
import useColumnVisibility from '../hooks/useColumnVisibility.js'
|
||||
import JobIcon from '../../Icons/JobIcon.jsx'
|
||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon.jsx'
|
||||
@ -126,7 +126,7 @@ const Jobs = () => {
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'job'} longId={false} />,
|
||||
render: (text) => <IdDisplay id={text} type={'job'} longId={false} />,
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
|
||||
@ -1,48 +1,26 @@
|
||||
import React, { useState, useEffect, useContext } from 'react'
|
||||
import React from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Descriptions,
|
||||
Spin,
|
||||
Space,
|
||||
Button,
|
||||
message,
|
||||
Progress,
|
||||
Typography,
|
||||
Collapse,
|
||||
Flex,
|
||||
Dropdown,
|
||||
Popover,
|
||||
Checkbox,
|
||||
Card
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||
import TimeDisplay from '../../common/TimeDisplay'
|
||||
import JobState from '../../common/JobState'
|
||||
import IdText from '../../common/IdText'
|
||||
import SubJobsTree from '../../common/SubJobsTree'
|
||||
import { PrintServerContext } from '../../context/PrintServerContext'
|
||||
import GCodeFileIcon from '../../../Icons/GCodeFileIcon'
|
||||
import ReloadIcon from '../../../Icons/ReloadIcon'
|
||||
import { Space, Button, Flex, Dropdown, Card } from 'antd'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import useCollapseState from '../../hooks/useCollapseState'
|
||||
import config from '../../../../config'
|
||||
import AuditLogTable from '../../common/AuditLogTable'
|
||||
import DashboardNotes from '../../common/DashboardNotes'
|
||||
import InfoCollapse from '../../common/InfoCollapse'
|
||||
import ObjectInfo from '../../common/ObjectInfo'
|
||||
import ViewButton from '../../common/ViewButton'
|
||||
import EditObjectForm from '../../common/EditObjectForm'
|
||||
import EditButtons from '../../common/EditButtons'
|
||||
import LockIndicator from '../../Management/Filaments/LockIndicator'
|
||||
import SubJobsTree from '../../common/SubJobsTree'
|
||||
import InfoCircleIcon from '../../../Icons/InfoCircleIcon'
|
||||
import JobIcon from '../../../Icons/JobIcon'
|
||||
import AuditLogIcon from '../../../Icons/AuditLogIcon'
|
||||
import NoteIcon from '../../../Icons/NoteIcon'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
import GCodeFileIcon from '../../../Icons/GCodeFileIcon'
|
||||
|
||||
const JobInfo = () => {
|
||||
const [jobData, setJobData] = useState(null)
|
||||
const [fetchLoading, setFetchLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const location = useLocation()
|
||||
const [messageApi] = message.useMessage()
|
||||
const jobId = new URLSearchParams(location.search).get('jobId')
|
||||
const { printServer } = useContext(PrintServerContext)
|
||||
const [collapseState, updateCollapseState] = useCollapseState('JobInfo', {
|
||||
info: true,
|
||||
subJobs: true,
|
||||
@ -50,352 +28,205 @@ const JobInfo = () => {
|
||||
auditLogs: true
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (jobId) {
|
||||
fetchJobDetails()
|
||||
}
|
||||
}, [jobId])
|
||||
|
||||
useEffect(() => {
|
||||
if (printServer && jobId) {
|
||||
printServer.on('notify_job_update', (updateData) => {
|
||||
if (updateData._id === jobId) {
|
||||
setJobData((prevData) => {
|
||||
if (!prevData) return prevData
|
||||
return {
|
||||
...prevData,
|
||||
state: updateData.state,
|
||||
...updateData
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (printServer) {
|
||||
printServer.off('notify_job_update')
|
||||
}
|
||||
}
|
||||
}, [printServer, jobId])
|
||||
|
||||
const fetchJobDetails = async () => {
|
||||
try {
|
||||
setFetchLoading(true)
|
||||
const response = await axios.get(`${config.backendUrl}/jobs/${jobId}`, {
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true // Important for including cookies
|
||||
})
|
||||
setJobData(response.data)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError('Failed to fetch print job details')
|
||||
messageApi.error('Failed to fetch print job details')
|
||||
} finally {
|
||||
setFetchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getViewDropdownItems = () => {
|
||||
const sections = [
|
||||
{ key: 'info', label: 'Job Information' },
|
||||
{ key: 'subJobs', label: 'Sub Jobs' },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||
]
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
|
||||
{sections.map((section) => (
|
||||
<Checkbox
|
||||
checked={collapseState[section.key]}
|
||||
key={section.key}
|
||||
onChange={(e) => {
|
||||
updateCollapseState(section.key, e.target.checked)
|
||||
}}
|
||||
>
|
||||
{section.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
const actionItems = {
|
||||
items: [
|
||||
{
|
||||
label: 'Reload Job',
|
||||
key: 'reload',
|
||||
icon: <ReloadIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'edit') {
|
||||
// TODO: Implement edit functionality
|
||||
messageApi.info('Edit functionality coming soon')
|
||||
} else if (key === 'reload') {
|
||||
fetchJobDetails()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Dropdown menu={actionItems}>
|
||||
<Button>Actions</Button>
|
||||
</Dropdown>
|
||||
<Popover
|
||||
content={getViewDropdownItems()}
|
||||
placement='bottomLeft'
|
||||
arrow={false}
|
||||
>
|
||||
<Button>View</Button>
|
||||
</Popover>
|
||||
</Space>
|
||||
</Flex>
|
||||
<EditObjectForm
|
||||
id={jobId}
|
||||
type='job'
|
||||
style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
|
||||
>
|
||||
{({
|
||||
loading,
|
||||
isEditing,
|
||||
startEditing,
|
||||
cancelEditing,
|
||||
handleUpdate,
|
||||
formValid,
|
||||
objectData,
|
||||
editLoading,
|
||||
lock,
|
||||
fetchObject
|
||||
}) => (
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='middle'>
|
||||
<Space size='small'>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
label: 'Reload Job',
|
||||
key: 'reload',
|
||||
icon: <GCodeFileIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reload') {
|
||||
fetchObject()
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button disabled={loading}>Actions</Button>
|
||||
</Dropdown>
|
||||
<ViewButton
|
||||
loading={loading}
|
||||
sections={[
|
||||
{ key: 'info', label: 'Job Information' },
|
||||
{ key: 'subJobs', label: 'Sub Jobs' },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||
]}
|
||||
collapseState={collapseState}
|
||||
updateCollapseState={updateCollapseState}
|
||||
/>
|
||||
</Space>
|
||||
<LockIndicator lock={lock} />
|
||||
</Space>
|
||||
<Space>
|
||||
<EditButtons
|
||||
isEditing={isEditing}
|
||||
handleUpdate={handleUpdate}
|
||||
cancelEditing={cancelEditing}
|
||||
startEditing={startEditing}
|
||||
editLoading={editLoading}
|
||||
formValid={formValid}
|
||||
disabled={lock?.locked || loading || true}
|
||||
loading={editLoading}
|
||||
/>
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
{error ? (
|
||||
<Space
|
||||
direction='vertical'
|
||||
style={{ width: '100%', textAlign: 'center' }}
|
||||
>
|
||||
<p>{error || 'Print job not found'}</p>
|
||||
<Button icon={<ReloadIcon />} onClick={fetchJobDetails}>
|
||||
Retry
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||
<Flex vertical style={{ flexGrow: 1 }} gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.info ? ['info'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('info', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse no-t-padding-collapse'
|
||||
<Flex vertical gap={'large'}>
|
||||
<InfoCollapse
|
||||
title='Job Information'
|
||||
icon={<InfoCircleIcon />}
|
||||
active={collapseState.info}
|
||||
onToggle={(expanded) => updateCollapseState('info', expanded)}
|
||||
key='info'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<InfoCircleIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Job Information
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='info'
|
||||
>
|
||||
<Spin spinning={fetchLoading} indicator={<LoadingOutlined />}>
|
||||
<Descriptions
|
||||
bordered
|
||||
column={{
|
||||
xs: 1,
|
||||
sm: 1,
|
||||
md: 1,
|
||||
lg: 2,
|
||||
xl: 2,
|
||||
xxl: 2
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label='ID'>
|
||||
{jobData?._id ? (
|
||||
<IdText id={jobData._id} type={'job'} />
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Status'>
|
||||
{jobData?.state ? (
|
||||
<JobState
|
||||
job={jobData}
|
||||
showProgress={false}
|
||||
showQuantity={false}
|
||||
showId={false}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='GCode File Name'>
|
||||
{jobData?.gcodeFile ? (
|
||||
<Space>
|
||||
<GCodeFileIcon />
|
||||
<Text>
|
||||
{jobData.gcodeFile.name || 'Not specified'}
|
||||
</Text>
|
||||
</Space>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='GCode File ID'>
|
||||
{jobData?.gcodeFile?._id ? (
|
||||
<IdText
|
||||
id={jobData.gcodeFile._id}
|
||||
type={'gcodefile'}
|
||||
showHyperlink={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Quantity'>
|
||||
{jobData?.quantity ? (
|
||||
<Text>{jobData.quantity}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Created At'>
|
||||
{jobData?.createdAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={jobData.createdAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Started At'>
|
||||
{jobData?.startedAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={jobData.startedAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
{jobData?.state?.type === 'printing' && (
|
||||
<Descriptions.Item label='Progress'>
|
||||
<Progress
|
||||
percent={Math.round(
|
||||
(jobData.state.progress || 0) * 100
|
||||
)}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label='Assigned Printers'>
|
||||
{jobData?.printers ? (
|
||||
<Text>
|
||||
{jobData.printers.length} printers assigned
|
||||
</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Spin>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<ObjectInfo
|
||||
loading={loading}
|
||||
indicator={<LoadingOutlined />}
|
||||
isEditing={isEditing}
|
||||
type='job'
|
||||
items={[
|
||||
{
|
||||
name: '_id',
|
||||
label: 'ID',
|
||||
value: objectData?._id,
|
||||
type: 'id',
|
||||
objectType: 'job',
|
||||
showCopy: true
|
||||
},
|
||||
{
|
||||
name: 'state',
|
||||
label: 'Status',
|
||||
value: objectData,
|
||||
type: 'state',
|
||||
objectType: 'job',
|
||||
showStatus: true,
|
||||
showProgress: true,
|
||||
showId: false,
|
||||
showQuantity: false,
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: 'gcodeFile',
|
||||
label: 'GCode File',
|
||||
value: objectData?.gcodeFile,
|
||||
type: 'object',
|
||||
objectType: 'gcodeFile',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: 'gcodeFileId',
|
||||
label: 'GCode File ID',
|
||||
value: objectData?.gcodeFile?._id,
|
||||
type: 'id',
|
||||
objectType: 'gcodefile',
|
||||
showHyperlink: true
|
||||
},
|
||||
{
|
||||
name: 'quantity',
|
||||
label: 'Quantity',
|
||||
value: objectData?.quantity,
|
||||
type: 'number',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: 'createdAt',
|
||||
label: 'Created At',
|
||||
value: objectData?.createdAt,
|
||||
type: 'dateTime',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: 'startedAt',
|
||||
label: 'Started At',
|
||||
value: objectData?.startedAt,
|
||||
type: 'dateTime',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: 'assignedPrinters',
|
||||
label: 'Assigned Printers',
|
||||
value: objectData?.printers?.length,
|
||||
type: 'number',
|
||||
readOnly: true
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</InfoCollapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.subJobs ? ['2'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('subJobs', keys.length > 0)
|
||||
<InfoCollapse
|
||||
title='Sub Jobs'
|
||||
icon={<JobIcon />}
|
||||
active={collapseState.subJobs}
|
||||
onToggle={(expanded) =>
|
||||
updateCollapseState('subJobs', expanded)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
key='subJobs'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<JobIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Sub Job Information
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='2'
|
||||
>
|
||||
<SubJobsTree jobData={jobData} loading={fetchLoading} />
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<SubJobsTree jobData={objectData} loading={loading} />
|
||||
</InfoCollapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.notes ? ['notes'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('notes', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
<InfoCollapse
|
||||
title='Notes'
|
||||
icon={<NoteIcon />}
|
||||
active={collapseState.notes}
|
||||
onToggle={(expanded) => updateCollapseState('notes', expanded)}
|
||||
key='notes'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<NoteIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Notes
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='notes'
|
||||
>
|
||||
<Card>
|
||||
<DashboardNotes _id={jobId} />
|
||||
</Card>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<Card>
|
||||
<DashboardNotes _id={jobId} />
|
||||
</Card>
|
||||
</InfoCollapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('auditLogs', keys.length > 0)
|
||||
<InfoCollapse
|
||||
title='Audit Logs'
|
||||
icon={<AuditLogIcon />}
|
||||
active={collapseState.auditLogs}
|
||||
onToggle={(expanded) =>
|
||||
updateCollapseState('auditLogs', expanded)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
key='auditLogs'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<AuditLogIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Audit Logs
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='auditLogs'
|
||||
>
|
||||
<AuditLogTable
|
||||
items={jobData?.auditLogs || []}
|
||||
loading={fetchLoading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<AuditLogTable
|
||||
items={objectData?.auditLogs || []}
|
||||
loading={loading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
</Flex>
|
||||
)}
|
||||
</EditObjectForm>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import PrinterState from '../common/PrinterState'
|
||||
import NewPrinter from './Printers/NewPrinter'
|
||||
import IdText from '../common/IdText'
|
||||
import IdDisplay from '../common/IdDisplay'
|
||||
import PrinterIcon from '../../Icons/PrinterIcon'
|
||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||
import ControlIcon from '../../Icons/ControlIcon'
|
||||
@ -79,7 +79,7 @@ const Printers = () => {
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type='printer' longId={false} />
|
||||
render: (text) => <IdDisplay id={text} type='printer' longId={false} />
|
||||
},
|
||||
{
|
||||
title: 'State',
|
||||
@ -89,7 +89,7 @@ const Printers = () => {
|
||||
return (
|
||||
<PrinterState
|
||||
printer={record}
|
||||
showPrinterName={false}
|
||||
showName={false}
|
||||
showControls={false}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -31,7 +31,7 @@ import PrinterMiscPanel from '../../common/PrinterMiscPanel'
|
||||
import PrinterState from '../../common/PrinterState'
|
||||
import { AuthContext } from '../../context/AuthContext'
|
||||
import PrinterSubJobsTree from '../../common/PrinterJobsTree'
|
||||
import IdText from '../../common/IdText'
|
||||
import IdDisplay from '../../common/IdDisplay'
|
||||
|
||||
import FilamentIcon from '../../../Icons/FilamentIcon'
|
||||
import FilamentStockIcon from '../../../Icons/FilamentStockIcon'
|
||||
@ -179,7 +179,6 @@ const ControlPrinter = () => {
|
||||
}
|
||||
return () => {
|
||||
if (printServer && initialized) {
|
||||
console.log('Deregistering')
|
||||
printServer.off('notify_printer_update')
|
||||
printServer.off('notify_filamentstock_update')
|
||||
}
|
||||
@ -187,7 +186,6 @@ const ControlPrinter = () => {
|
||||
}, [printServer, initialized, printerId])
|
||||
|
||||
function handleEmergencyStop() {
|
||||
console.log('Emergency stop button clicked')
|
||||
printServer.emit('printer.emergency_stop', { printerId })
|
||||
}
|
||||
|
||||
@ -438,7 +436,7 @@ const ControlPrinter = () => {
|
||||
<PrinterState
|
||||
printer={printerData}
|
||||
showProgress={false}
|
||||
showPrinterName={false}
|
||||
showName={false}
|
||||
showControls={false}
|
||||
/>
|
||||
) : (
|
||||
@ -548,7 +546,7 @@ const ControlPrinter = () => {
|
||||
|
||||
<Descriptions.Item label='Printer ID'>
|
||||
{printerData?._id ? (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={printerData._id}
|
||||
type='printer'
|
||||
longId={false}
|
||||
@ -573,7 +571,7 @@ const ControlPrinter = () => {
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='GCode File ID'>
|
||||
{printerData?.currentJob?.gcodeFile ? (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={printerData.currentJob.gcodeFile.id}
|
||||
type='gcodeFile'
|
||||
longId={false}
|
||||
@ -586,7 +584,7 @@ const ControlPrinter = () => {
|
||||
|
||||
<Descriptions.Item label='Print Job ID'>
|
||||
{printerData?.currentJob?.id ? (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={printerData.currentJob.id}
|
||||
type='job'
|
||||
longId={false}
|
||||
@ -599,7 +597,7 @@ const ControlPrinter = () => {
|
||||
|
||||
<Descriptions.Item label='Sub Job ID'>
|
||||
{printerData?.currentSubJob?.id ? (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={printerData.currentSubJob.number
|
||||
.toString()
|
||||
.padStart(6, '0')}
|
||||
@ -719,7 +717,7 @@ const ControlPrinter = () => {
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Filament Stock ID'>
|
||||
{printerData?.currentFilamentStock?._id ? (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={printerData.currentFilamentStock._id}
|
||||
type='filamentstock'
|
||||
longId={false}
|
||||
@ -748,7 +746,7 @@ const ControlPrinter = () => {
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Filament ID'>
|
||||
{printerData?.currentFilamentStock?.filament ? (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={printerData.currentFilamentStock.filament._id}
|
||||
type='filament'
|
||||
longId={false}
|
||||
|
||||
@ -1,689 +1,262 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Descriptions,
|
||||
Spin,
|
||||
Space,
|
||||
Button,
|
||||
message,
|
||||
Tag,
|
||||
Typography,
|
||||
Flex,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Collapse,
|
||||
Dropdown,
|
||||
Popover,
|
||||
Checkbox,
|
||||
Card
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||
import PrinterState from '../../common/PrinterState'
|
||||
import TimeDisplay from '../../common/TimeDisplay'
|
||||
import IdText from '../../common/IdText'
|
||||
import PrinterSubJobsList from '../../common/PrinterJobsTree'
|
||||
import VendorSelect from '../../common/VendorSelect'
|
||||
import VendorIcon from '../../../Icons/VendorIcon'
|
||||
import PlusIcon from '../../../Icons/PlusIcon'
|
||||
import ReloadIcon from '../../../Icons/ReloadIcon'
|
||||
import EditIcon from '../../../Icons/EditIcon.jsx'
|
||||
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
|
||||
import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
||||
import { Space, Button, Flex, Dropdown, Card } from 'antd'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import useCollapseState from '../../hooks/useCollapseState'
|
||||
|
||||
import config from '../../../../config.js'
|
||||
import AuditLogTable from '../../common/AuditLogTable.jsx'
|
||||
import DashboardNotes from '../../common/DashboardNotes.jsx'
|
||||
import AuditLogTable from '../../common/AuditLogTable'
|
||||
import DashboardNotes from '../../common/DashboardNotes'
|
||||
import InfoCollapse from '../../common/InfoCollapse'
|
||||
import ObjectInfo from '../../common/ObjectInfo'
|
||||
import ViewButton from '../../common/ViewButton'
|
||||
import EditObjectForm from '../../common/EditObjectForm'
|
||||
import EditButtons from '../../common/EditButtons'
|
||||
import LockIndicator from '../../Management/Filaments/LockIndicator'
|
||||
import PrinterJobsTree from '../../common/PrinterJobsTree'
|
||||
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
|
||||
import NoteIcon from '../../../Icons/NoteIcon.jsx'
|
||||
import PrinterIcon from '../../../Icons/PrinterIcon.jsx'
|
||||
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
const PrinterInfo = () => {
|
||||
const [printerData, setPrinterData] = useState(null)
|
||||
const [fetchLoading, setFetchLoading] = useState(true)
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const location = useLocation()
|
||||
const printerId = new URLSearchParams(location.search).get('printerId')
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const [collapseState, updateCollapseState] = useCollapseState('PrinterInfo', {
|
||||
info: true,
|
||||
jobs: true,
|
||||
notes: true,
|
||||
auditLogs: true
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (printerId) {
|
||||
fetchPrinterDetails()
|
||||
}
|
||||
}, [printerId])
|
||||
|
||||
useEffect(() => {
|
||||
if (printerData) {
|
||||
form.setFieldsValue({
|
||||
name: printerData.name || '',
|
||||
vendor: printerData.vendor || { id: null, name: '' },
|
||||
moonraker: {
|
||||
host: printerData.moonraker?.host || '',
|
||||
port: printerData.moonraker?.port || null,
|
||||
protocol: printerData.moonraker?.protocol || 'ws',
|
||||
apiKey: printerData.moonraker?.apiKey || ''
|
||||
},
|
||||
tags: printerData.tags || []
|
||||
})
|
||||
}
|
||||
}, [printerData, form])
|
||||
|
||||
const fetchPrinterDetails = async () => {
|
||||
try {
|
||||
setFetchLoading(true)
|
||||
const response = await axios.get(
|
||||
`${config.backendUrl}/printers/${printerId}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
setPrinterData(response.data)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError('Failed to fetch printer details')
|
||||
messageApi.error('Failed to fetch printer details')
|
||||
} finally {
|
||||
setFetchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startEditing = () => {
|
||||
updateCollapseState('info', true)
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
// Reset form values to original data
|
||||
if (printerData) {
|
||||
form.setFieldsValue({
|
||||
name: printerData.name || '',
|
||||
vendor: printerData.vendor || { id: null, name: '' },
|
||||
moonraker: {
|
||||
host: printerData.moonraker?.host || '',
|
||||
port: printerData.moonraker?.port || null,
|
||||
protocol: printerData.moonraker?.protocol || 'ws',
|
||||
apiKey: printerData.moonraker?.apiKey || ''
|
||||
},
|
||||
tags: printerData.tags || []
|
||||
})
|
||||
}
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const updatePrinterInfo = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setEditLoading(true)
|
||||
|
||||
await axios.put(
|
||||
`${config.backendUrl}/printers/${printerId}`,
|
||||
{
|
||||
name: values.name,
|
||||
vendor: values.vendor,
|
||||
moonraker: {
|
||||
host: values.moonraker.host,
|
||||
port: values.moonraker.port,
|
||||
protocol: values.moonraker.protocol,
|
||||
apiKey: values.moonraker.apiKey
|
||||
},
|
||||
tags: values.tags
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
// Update the local state with the new values
|
||||
setPrinterData({ ...printerData, ...values })
|
||||
setIsEditing(false)
|
||||
messageApi.success('Printer information updated successfully')
|
||||
} catch (err) {
|
||||
if (err.errorFields) {
|
||||
// This is a form validation error
|
||||
return
|
||||
}
|
||||
console.error('Failed to update printer information:', err)
|
||||
messageApi.error('Failed to update printer information')
|
||||
} finally {
|
||||
setEditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTagClose = (removedTag) => {
|
||||
const newTags = printerData.tags.filter((tag) => tag !== removedTag)
|
||||
setPrinterData((prev) => ({ ...prev, tags: newTags }))
|
||||
}
|
||||
|
||||
const handleTagAdd = () => {
|
||||
const input = form.getFieldValue('newTag')
|
||||
if (input) {
|
||||
const newTag = input.trim()
|
||||
if (newTag && !printerData.tags.includes(newTag)) {
|
||||
setPrinterData((prev) => ({ ...prev, tags: [...prev.tags, newTag] }))
|
||||
form.setFieldValue('newTag', '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const actionItems = {
|
||||
items: [
|
||||
{
|
||||
label: 'Reload Printer',
|
||||
key: 'reload',
|
||||
icon: <ReloadIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reload') {
|
||||
fetchPrinterDetails()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getViewDropdownItems = () => {
|
||||
const sections = [
|
||||
{ key: 'info', label: 'Printer Information' },
|
||||
{ key: 'jobs', label: 'Printer Jobs' },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||
]
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
|
||||
{sections.map((section) => (
|
||||
<Checkbox
|
||||
checked={collapseState[section.key]}
|
||||
key={section.key}
|
||||
onChange={(e) => {
|
||||
updateCollapseState(section.key, e.target.checked)
|
||||
}}
|
||||
>
|
||||
{section.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Dropdown menu={actionItems}>
|
||||
<Button>Actions</Button>
|
||||
</Dropdown>
|
||||
<Popover
|
||||
content={getViewDropdownItems()}
|
||||
placement='bottomLeft'
|
||||
arrow={false}
|
||||
>
|
||||
<Button>View</Button>
|
||||
</Popover>
|
||||
</Space>
|
||||
<Space>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
icon={<CheckIcon />}
|
||||
type='primary'
|
||||
onClick={updatePrinterInfo}
|
||||
loading={editLoading}
|
||||
disabled={editLoading}
|
||||
<EditObjectForm
|
||||
id={printerId}
|
||||
type='printer'
|
||||
style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
|
||||
>
|
||||
{({
|
||||
loading,
|
||||
isEditing,
|
||||
startEditing,
|
||||
cancelEditing,
|
||||
handleUpdate,
|
||||
formValid,
|
||||
objectData,
|
||||
editLoading,
|
||||
lock,
|
||||
fetchObject
|
||||
}) => (
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='middle'>
|
||||
<Space size='small'>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
label: 'Reload Printer',
|
||||
key: 'reload',
|
||||
icon: <AuditLogIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reload') {
|
||||
fetchObject()
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button disabled={loading}>Actions</Button>
|
||||
</Dropdown>
|
||||
<ViewButton
|
||||
loading={loading}
|
||||
sections={[
|
||||
{ key: 'info', label: 'Printer Information' },
|
||||
{ key: 'jobs', label: 'Printer Jobs' },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||
]}
|
||||
collapseState={collapseState}
|
||||
updateCollapseState={updateCollapseState}
|
||||
/>
|
||||
<Button
|
||||
icon={<XMarkIcon />}
|
||||
onClick={cancelEditing}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button icon={<EditIcon />} onClick={startEditing} />
|
||||
)}
|
||||
</Space>
|
||||
</Flex>
|
||||
</Space>
|
||||
<LockIndicator lock={lock} />
|
||||
</Space>
|
||||
<Space>
|
||||
<EditButtons
|
||||
isEditing={isEditing}
|
||||
handleUpdate={handleUpdate}
|
||||
cancelEditing={cancelEditing}
|
||||
startEditing={startEditing}
|
||||
editLoading={editLoading}
|
||||
formValid={formValid}
|
||||
disabled={lock?.locked || loading}
|
||||
loading={editLoading}
|
||||
/>
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
{error ? (
|
||||
<Space
|
||||
direction='vertical'
|
||||
style={{ width: '100%', textAlign: 'center' }}
|
||||
>
|
||||
<p>{error || 'Print job not found'}</p>
|
||||
<Button icon={<ReloadIcon />} onClick={fetchPrinterDetails}>
|
||||
Retry
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.info ? ['info'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('info', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse no-t-padding-collapse'
|
||||
<InfoCollapse
|
||||
title='Printer Information'
|
||||
icon={<InfoCircleIcon />}
|
||||
active={collapseState.info}
|
||||
onToggle={(expanded) => updateCollapseState('info', expanded)}
|
||||
key='info'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<InfoCircleIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Printer Information
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='info'
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout='vertical'
|
||||
initialValues={{
|
||||
name: printerData?.name || '',
|
||||
vendor: printerData?.vendor || { id: null, name: '' },
|
||||
moonraker: {
|
||||
host: printerData?.moonraker?.host || '',
|
||||
port: printerData?.moonraker?.port || null,
|
||||
protocol: printerData?.moonraker?.protocol || 'ws',
|
||||
apiKey: printerData?.moonraker?.apiKey || ''
|
||||
},
|
||||
tags: printerData?.tags || []
|
||||
}}
|
||||
>
|
||||
<Spin
|
||||
spinning={fetchLoading}
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
column={{
|
||||
xs: 1,
|
||||
sm: 1,
|
||||
md: 1,
|
||||
lg: 2,
|
||||
xl: 2,
|
||||
xxl: 2
|
||||
}}
|
||||
>
|
||||
{/* Read-only fields */}
|
||||
<Descriptions.Item label='ID'>
|
||||
{printerData?._id ? (
|
||||
<IdText id={printerData._id} type={'printer'} />
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Connected At'>
|
||||
{printerData?.connectedAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={printerData.connectedAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<ObjectInfo
|
||||
loading={loading}
|
||||
indicator={<LoadingOutlined />}
|
||||
isEditing={isEditing}
|
||||
type='printer'
|
||||
items={[
|
||||
{
|
||||
name: '_id',
|
||||
label: 'ID',
|
||||
value: objectData?._id,
|
||||
type: 'id',
|
||||
objectType: 'printer',
|
||||
showCopy: true
|
||||
},
|
||||
{
|
||||
name: 'connectedAt',
|
||||
label: 'Connected At',
|
||||
value: objectData?.connectedAt,
|
||||
type: 'dateTime',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
value: objectData?.name,
|
||||
required: true,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'state',
|
||||
label: 'Status',
|
||||
value: objectData,
|
||||
type: 'state',
|
||||
objectType: 'printer',
|
||||
showName: false,
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
name: 'vendor',
|
||||
label: 'Vendor',
|
||||
value: objectData?.vendor,
|
||||
type: 'object',
|
||||
objectType: 'vendor',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: ['moonraker', 'host'],
|
||||
label: 'Host',
|
||||
value: objectData?.moonraker?.host,
|
||||
type: 'text',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'vendorId',
|
||||
label: 'Vendor ID',
|
||||
value: objectData?.vendor?.id,
|
||||
type: 'id',
|
||||
objectType: 'vendor',
|
||||
showHyperlink: true,
|
||||
readOnly: true
|
||||
},
|
||||
|
||||
{/* Editable fields */}
|
||||
<Descriptions.Item label='Name'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='name'
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a printer name'
|
||||
},
|
||||
{
|
||||
max: 100,
|
||||
message: 'Name cannot exceed 100 characters'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Input placeholder='Enter printer name' />
|
||||
</Form.Item>
|
||||
) : printerData?.name ? (
|
||||
<Text>{printerData.name}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
{
|
||||
name: ['moonraker', 'port'],
|
||||
label: 'Port',
|
||||
value: objectData?.moonraker?.port,
|
||||
type: 'number',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: ['moonraker', 'apiKey'],
|
||||
label: 'API Key',
|
||||
value: objectData?.moonraker?.apiKey,
|
||||
type: 'secret',
|
||||
reveal: true,
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: ['moonraker', 'protocol'],
|
||||
label: 'Protocol',
|
||||
value: objectData?.moonraker?.protocol,
|
||||
type: 'wsprotocol',
|
||||
required: true
|
||||
},
|
||||
|
||||
<Descriptions.Item label='Host'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name={['moonraker', 'host']}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a host'
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/^[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$|\b(?:\d{1,3}\.){3}\d{1,3}\b/,
|
||||
message:
|
||||
'Please enter a valid hostname or IP address'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Input placeholder='Enter host (e.g., 192.168.1.100)' />
|
||||
</Form.Item>
|
||||
) : printerData?.moonraker?.host ? (
|
||||
<Text>{printerData.moonraker.host}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
{
|
||||
name: 'tags',
|
||||
label: 'Tags',
|
||||
value: objectData?.tags,
|
||||
type: 'tags',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: 'firmware',
|
||||
label: 'Firmware Version',
|
||||
value: objectData?.firmware,
|
||||
type: 'text',
|
||||
required: false,
|
||||
readOnly: true
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</InfoCollapse>
|
||||
|
||||
<Descriptions.Item label='Vendor'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='vendor'
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a vendor'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<VendorSelect />
|
||||
</Form.Item>
|
||||
) : printerData?.vendor?.name ? (
|
||||
<Space>
|
||||
<VendorIcon />
|
||||
{printerData?.vendor?.name || 'n/a'}
|
||||
</Space>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Vendor ID'>
|
||||
{printerData?.vendor ? (
|
||||
<IdText
|
||||
id={printerData?.vendor?.id}
|
||||
type={'vendor'}
|
||||
showHyperlink={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Port'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name={['moonraker', 'port']}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a port number'
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 65535,
|
||||
message: 'Port must be between 1 and 65535'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={65535}
|
||||
placeholder='Enter port'
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : printerData?.moonraker?.port ? (
|
||||
<Text>{printerData.moonraker.port}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Protocol'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name={['moonraker', 'protocol']}
|
||||
rules={[
|
||||
{ required: true, message: 'Port is required' }
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Select
|
||||
defaultValue='ws'
|
||||
options={[
|
||||
{ value: 'ws', label: 'Websocket' },
|
||||
{ value: 'wss', label: 'Websocket Secure' }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : printerData?.moonraker?.protocol == 'ws' ? (
|
||||
<Text>Websocket</Text>
|
||||
) : printerData?.moonraker?.protocol == 'wss' ? (
|
||||
<Text>Websocket Secure</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='API Key'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name={['moonraker', 'apiKey']}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Input.Password placeholder='Enter API key' />
|
||||
</Form.Item>
|
||||
) : printerData?.moonraker?.apiKey ? (
|
||||
<Text>Configured</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Status'>
|
||||
{printerData?.state ? (
|
||||
<PrinterState
|
||||
printer={printerData}
|
||||
showPrinterName={false}
|
||||
showControls={false}
|
||||
showProgress={false}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Tags'>
|
||||
{isEditing ? (
|
||||
<Form.Item name='tags' style={{ margin: 0 }}>
|
||||
<Space
|
||||
size={[0, 2]}
|
||||
wrap
|
||||
style={{ marginBottom: 4, maxWidth: '300px' }}
|
||||
>
|
||||
{printerData.tags.map((tag) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
color='blue'
|
||||
closable
|
||||
onClose={() => handleTagClose(tag)}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
<Space.Compact block>
|
||||
<Form.Item name='newTag' noStyle>
|
||||
<Input placeholder='Add new tag' />
|
||||
</Form.Item>
|
||||
<Button
|
||||
onClick={handleTagAdd}
|
||||
icon={<PlusIcon />}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
) : printerData?.tags?.length > 0 ? (
|
||||
<Space
|
||||
size={[0, 2]}
|
||||
wrap
|
||||
style={{ marginBottom: 4, maxWidth: '300px' }}
|
||||
>
|
||||
{printerData.tags.map((tag, index) => (
|
||||
<Tag key={index} color='blue'>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Firmware Version'>
|
||||
{printerData?.firmware ? (
|
||||
<Text>{printerData.firmware}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Spin>
|
||||
</Form>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.jobs ? ['jobs'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('jobs', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
<InfoCollapse
|
||||
title='Printer Jobs'
|
||||
icon={<PrinterIcon />}
|
||||
active={collapseState.jobs}
|
||||
onToggle={(expanded) => updateCollapseState('jobs', expanded)}
|
||||
key='jobs'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<PrinterIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Printer Jobs
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='jobs'
|
||||
>
|
||||
<PrinterSubJobsList
|
||||
subJobs={printerData?.subJobs}
|
||||
loading={fetchLoading}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<PrinterJobsTree
|
||||
subJobs={objectData?.subJobs}
|
||||
loading={loading}
|
||||
/>
|
||||
</InfoCollapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.notes ? ['notes'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('notes', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
<InfoCollapse
|
||||
title='Notes'
|
||||
icon={<NoteIcon />}
|
||||
active={collapseState.notes}
|
||||
onToggle={(expanded) => updateCollapseState('notes', expanded)}
|
||||
key='notes'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<NoteIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Notes
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='notes'
|
||||
>
|
||||
<Card>
|
||||
<DashboardNotes _id={printerId} />
|
||||
</Card>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<Card>
|
||||
<DashboardNotes _id={printerId} />
|
||||
</Card>
|
||||
</InfoCollapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('auditLogs', keys.length > 0)
|
||||
<InfoCollapse
|
||||
title='Audit Logs'
|
||||
icon={<AuditLogIcon />}
|
||||
active={collapseState.auditLogs}
|
||||
onToggle={(expanded) =>
|
||||
updateCollapseState('auditLogs', expanded)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
key='auditLogs'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<AuditLogIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Audit Log
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='auditLogs'
|
||||
>
|
||||
<AuditLogTable
|
||||
items={printerData?.auditLogs || []}
|
||||
loading={fetchLoading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<AuditLogTable
|
||||
items={objectData?.auditLogs || []}
|
||||
loading={loading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
</Flex>
|
||||
)}
|
||||
</EditObjectForm>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -61,7 +61,6 @@ const ProductionOverview = () => {
|
||||
await fetchPrinterStats()
|
||||
await fetchJobstats()
|
||||
await fetchChartData()
|
||||
console.log(stats)
|
||||
}, [])
|
||||
|
||||
const fetchPrinterStats = async () => {
|
||||
@ -74,7 +73,6 @@ const ProductionOverview = () => {
|
||||
withCredentials: true
|
||||
})
|
||||
const printStats = response.data
|
||||
console.log(printStats)
|
||||
setStats((prev) => ({ ...prev, printers: printStats }))
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { forwardRef, useState } from 'react'
|
||||
import { Typography, Space, Descriptions, Badge, Table } from 'antd'
|
||||
import PropTypes from 'prop-types'
|
||||
import IdText from './IdText'
|
||||
import IdDisplay from './IdDisplay'
|
||||
import { AuditOutlined, LoadingOutlined } from '@ant-design/icons'
|
||||
import TimeDisplay from '../common/TimeDisplay'
|
||||
import BoolDisplay from './BoolDisplay'
|
||||
@ -51,7 +51,7 @@ const formatValue = (value, propertyName) => {
|
||||
|
||||
if (isObjectId(value)) {
|
||||
return (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={value}
|
||||
type={propertyName.toLowerCase().replaceAll('current', '')}
|
||||
longId={false}
|
||||
@ -90,7 +90,9 @@ const AuditLogTable = forwardRef(
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'auditlog'} longId={false} />,
|
||||
render: (text) => (
|
||||
<IdDisplay id={text} type={'auditlog'} longId={false} />
|
||||
),
|
||||
sorter: (a, b) => a._id.localeCompare(b._id)
|
||||
}
|
||||
]
|
||||
@ -110,7 +112,7 @@ const AuditLogTable = forwardRef(
|
||||
key: 'owner',
|
||||
width: 180,
|
||||
render: (record) => (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={record.owner._id}
|
||||
type={record.ownerModel.toLowerCase()}
|
||||
longId={false}
|
||||
@ -127,7 +129,7 @@ const AuditLogTable = forwardRef(
|
||||
key: 'target',
|
||||
width: 180,
|
||||
render: (record) => (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={record.target}
|
||||
type={record.targetModel.toLowerCase()}
|
||||
longId={false}
|
||||
|
||||
55
src/components/Dashboard/common/ColorSelector.jsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { ColorPicker, Checkbox, Flex } from 'antd'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const ColorSelector = ({ value, onChange, disabled, required = false }) => {
|
||||
// Determine initial enabled state based on value
|
||||
const [colorEnabled, setColorEnabled] = useState(!!value)
|
||||
|
||||
useEffect(() => {
|
||||
setColorEnabled(!!value)
|
||||
}, [value])
|
||||
|
||||
const handleCheckboxChange = (e) => {
|
||||
const checked = e.target.checked
|
||||
setColorEnabled(checked)
|
||||
if (!checked) {
|
||||
onChange(null)
|
||||
} else if (checked && !value) {
|
||||
onChange('#000000')
|
||||
}
|
||||
}
|
||||
|
||||
const handleColorChange = (color) => {
|
||||
onChange('#' + color.toHex())
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex gap={'middle'}>
|
||||
<ColorPicker
|
||||
showText
|
||||
disabledAlpha
|
||||
style={{ width: '100%', justifyContent: 'start' }}
|
||||
disabled={!colorEnabled || disabled}
|
||||
value={value || '#000000'}
|
||||
onChange={handleColorChange}
|
||||
/>
|
||||
{!required && (
|
||||
<Checkbox
|
||||
checked={colorEnabled}
|
||||
onChange={handleCheckboxChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
ColorSelector.propTypes = {
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
required: PropTypes.bool
|
||||
}
|
||||
|
||||
export default ColorSelector
|
||||
73
src/components/Dashboard/common/CopyButton.jsx
Normal file
@ -0,0 +1,73 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Button, Tooltip, message } from 'antd'
|
||||
import CopyIcon from '../../Icons/CopyIcon'
|
||||
|
||||
const CopyButton = ({
|
||||
text,
|
||||
style = {},
|
||||
iconStyle = {},
|
||||
tooltip = 'Copy',
|
||||
size = 'small'
|
||||
}) => {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
|
||||
const doCopy = (copyText) => {
|
||||
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(copyText)
|
||||
.then(() => {
|
||||
messageApi.success('Copied to clipboard')
|
||||
})
|
||||
.catch(() => {
|
||||
messageApi.error('Failed to copy')
|
||||
})
|
||||
} else if (
|
||||
document.queryCommandSupported &&
|
||||
document.queryCommandSupported('copy')
|
||||
) {
|
||||
// Legacy fallback
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = copyText
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'absolute'
|
||||
textarea.style.left = '-9999px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
messageApi.success('Copied to clipboard')
|
||||
} catch (err) {
|
||||
messageApi.error('Failed to copy')
|
||||
}
|
||||
document.body.removeChild(textarea)
|
||||
} else {
|
||||
messageApi.error('Copy not supported in this browser')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<Tooltip title={tooltip} arrow={false}>
|
||||
<Button
|
||||
icon={<CopyIcon style={{ fontSize: '14px', ...iconStyle }} />}
|
||||
type='text'
|
||||
size={size}
|
||||
style={{ height: '22px', ...style }}
|
||||
onClick={() => doCopy(text)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
CopyButton.propTypes = {
|
||||
text: PropTypes.string.isRequired,
|
||||
style: PropTypes.object,
|
||||
iconStyle: PropTypes.object,
|
||||
tooltip: PropTypes.string,
|
||||
size: PropTypes.string
|
||||
}
|
||||
|
||||
export default CopyButton
|
||||
@ -28,7 +28,7 @@ import config from '../../../config'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||
import NoteTypeSelect from './NoteTypeSelect'
|
||||
import IdText from './IdText'
|
||||
import IdDisplay from './IdDisplay'
|
||||
import ReloadIcon from '../../Icons/ReloadIcon'
|
||||
import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon'
|
||||
|
||||
@ -177,7 +177,7 @@ const NoteItem = ({
|
||||
</Space>
|
||||
<Space size={'small'} style={{ marginRight: 8 }}>
|
||||
<Text type='secondary'>User ID:</Text>
|
||||
<IdText
|
||||
<IdDisplay
|
||||
longId={false}
|
||||
id={note.user._id}
|
||||
type={'user'}
|
||||
|
||||
@ -21,6 +21,10 @@ import { LoadingOutlined } from '@ant-design/icons'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import axios from 'axios'
|
||||
import config from '../../../config'
|
||||
import loglevel from 'loglevel'
|
||||
const logger = loglevel.getLogger('DasboardTable')
|
||||
logger.setLevel(config.logLevel)
|
||||
|
||||
const DashboardTable = forwardRef(
|
||||
(
|
||||
@ -96,7 +100,7 @@ const DashboardTable = forwardRef(
|
||||
const existingPageIndex = prev.findIndex(
|
||||
(p) => p.pageNum === pageNum
|
||||
)
|
||||
console.log(prev.map((p) => p.pageNum))
|
||||
logger.debug(prev.map((p) => p.pageNum))
|
||||
if (existingPageIndex !== -1) {
|
||||
// Update existing page
|
||||
const newPages = [...prev]
|
||||
@ -132,7 +136,7 @@ const DashboardTable = forwardRef(
|
||||
const loadNextPage = useCallback(() => {
|
||||
const highestPage = Math.max(...pages.map((p) => p.pageNum))
|
||||
const nextPage = highestPage + 1
|
||||
console.log('Next page', nextPage)
|
||||
logger.debug('Next page', nextPage)
|
||||
|
||||
if (hasMore) {
|
||||
setPages((prev) => {
|
||||
@ -185,7 +189,7 @@ const DashboardTable = forwardRef(
|
||||
const lowestPage = Math.min(...pages.map((p) => p.pageNum))
|
||||
const prevPage = lowestPage - 1
|
||||
|
||||
console.log(
|
||||
logger.debug(
|
||||
'Down',
|
||||
scrollHeight - scrollTop - clientHeight < 100,
|
||||
lazyLoading
|
||||
@ -197,7 +201,7 @@ const DashboardTable = forwardRef(
|
||||
target.scrollTop = scrollHeight / 2
|
||||
}, 0)
|
||||
setLazyLoading(true)
|
||||
console.log('Loading next page...')
|
||||
logger.debug('Loading next page...')
|
||||
loadNextPage()
|
||||
}
|
||||
|
||||
@ -207,7 +211,7 @@ const DashboardTable = forwardRef(
|
||||
target.scrollTop = scrollHeight / 2
|
||||
}, 0)
|
||||
setLazyLoading(true)
|
||||
console.log('Loading previous page...')
|
||||
logger.debug('Loading previous page...')
|
||||
loadPreviousPage()
|
||||
}
|
||||
},
|
||||
|
||||
52
src/components/Dashboard/common/EditButtons.jsx
Normal file
@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { Button, Space } from 'antd'
|
||||
import CheckIcon from '../../Icons/CheckIcon.jsx'
|
||||
import XMarkIcon from '../../Icons/XMarkIcon.jsx'
|
||||
import EditIcon from '../../Icons/EditIcon.jsx'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const EditButtons = ({
|
||||
isEditing,
|
||||
handleUpdate,
|
||||
cancelEditing,
|
||||
startEditing,
|
||||
formValid,
|
||||
disabled,
|
||||
loading
|
||||
}) => {
|
||||
return isEditing ? (
|
||||
<Space size='small'>
|
||||
<Button
|
||||
icon={<CheckIcon />}
|
||||
type='primary'
|
||||
onClick={handleUpdate}
|
||||
loading={loading}
|
||||
disabled={loading || !formValid || disabled}
|
||||
/>
|
||||
<Button
|
||||
icon={<XMarkIcon />}
|
||||
onClick={cancelEditing}
|
||||
disabled={loading || disabled}
|
||||
/>
|
||||
</Space>
|
||||
) : (
|
||||
<Button
|
||||
icon={<EditIcon />}
|
||||
onClick={startEditing}
|
||||
loading={loading}
|
||||
disabled={disabled || loading}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
EditButtons.propTypes = {
|
||||
isEditing: PropTypes.bool.isRequired,
|
||||
handleUpdate: PropTypes.func.isRequired,
|
||||
cancelEditing: PropTypes.func.isRequired,
|
||||
startEditing: PropTypes.func.isRequired,
|
||||
formValid: PropTypes.bool.isRequired,
|
||||
disabled: PropTypes.any,
|
||||
loading: PropTypes.bool.isRequired
|
||||
}
|
||||
|
||||
export default EditButtons
|
||||
182
src/components/Dashboard/common/EditObjectForm.jsx
Normal file
@ -0,0 +1,182 @@
|
||||
import React, { useState, useEffect, useContext, useCallback } from 'react'
|
||||
import { Form, message } from 'antd'
|
||||
import { ApiServerContext } from '../context/ApiServerContext'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
/**
|
||||
* EditObjectForm is a reusable form component for editing any object type.
|
||||
* It handles fetching, updating, locking, unlocking, and validation logic.
|
||||
*
|
||||
* Props:
|
||||
* - id: string (required)
|
||||
* - type: string (required)
|
||||
* - formItems: array (for ObjectInfo/ObjectProperty items)
|
||||
* - children: function({
|
||||
* loading, isEditing, startEditing, cancelEditing, handleUpdate, form, formValid, objectData, setIsEditing, setObjectData
|
||||
* }) => ReactNode
|
||||
*/
|
||||
const EditObjectForm = ({ id, type, style, children }) => {
|
||||
const [objectData, setObjectData] = useState(null)
|
||||
const [fetchLoading, setFetchLoading] = useState(true)
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
const [lock, setLock] = useState({})
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [formValid, setFormValid] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const formUpdateValues = Form.useWatch([], form)
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const {
|
||||
fetchObjectInfo,
|
||||
updateObjectInfo,
|
||||
lockObject,
|
||||
unlockObject,
|
||||
onLockEvent,
|
||||
onUpdateEvent,
|
||||
fetchObjectLock,
|
||||
showError
|
||||
} = useContext(ApiServerContext)
|
||||
|
||||
// Validate form on change
|
||||
useEffect(() => {
|
||||
form
|
||||
.validateFields({ validateOnly: true })
|
||||
.then(() => setFormValid(true))
|
||||
.catch(() => setFormValid(false))
|
||||
}, [form, formUpdateValues])
|
||||
|
||||
// Lock event handler
|
||||
const lockEventHandler = useCallback((lockEvent) => {
|
||||
setLock(lockEvent)
|
||||
}, [])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (id) {
|
||||
unlockObject(id, type)
|
||||
}
|
||||
}
|
||||
}, [id, type, unlockObject])
|
||||
|
||||
useEffect(() => {
|
||||
if (objectData) {
|
||||
form.setFieldsValue(objectData)
|
||||
}
|
||||
}, [objectData, form])
|
||||
|
||||
const fetchObject = useCallback(async () => {
|
||||
try {
|
||||
setFetchLoading(true)
|
||||
const data = await fetchObjectInfo(id, type)
|
||||
const lockEvent = await fetchObjectLock(id, type)
|
||||
setLock(lockEvent)
|
||||
setObjectData(data)
|
||||
form.setFieldsValue(data)
|
||||
setFetchLoading(false)
|
||||
} catch (err) {
|
||||
messageApi.error('Failed to fetch object info')
|
||||
showError(
|
||||
`Failed to fetch object information. Message: ${err.message}. Code: ${err.code}`,
|
||||
fetchObject
|
||||
)
|
||||
}
|
||||
}, [fetchObjectInfo, fetchObjectLock, id, type, form, messageApi, showError])
|
||||
|
||||
const updateObject = async () => {
|
||||
const values = form.getFieldsValue()
|
||||
await updateObjectInfo(id, type, values)
|
||||
}
|
||||
|
||||
// Update event handler
|
||||
const updateEventHandler = useCallback(() => {
|
||||
fetchObject()
|
||||
}, [fetchObject])
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized && id) {
|
||||
setInitialized(true)
|
||||
fetchObject()
|
||||
}
|
||||
}, [id, initialized, fetchObject])
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
const cleanup = onLockEvent(id, lockEventHandler)
|
||||
return cleanup
|
||||
}
|
||||
}, [id, onLockEvent, lockEventHandler])
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
const cleanup = onUpdateEvent(id, updateEventHandler)
|
||||
return cleanup
|
||||
}
|
||||
}, [id, onUpdateEvent, updateEventHandler])
|
||||
|
||||
const startEditing = () => {
|
||||
setIsEditing(true)
|
||||
lockObject(id, type)
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
if (objectData) {
|
||||
form.setFieldsValue(objectData)
|
||||
}
|
||||
setIsEditing(false)
|
||||
unlockObject(id, type)
|
||||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setEditLoading(true)
|
||||
await updateObject()
|
||||
setObjectData({ ...objectData, ...values })
|
||||
setIsEditing(false)
|
||||
messageApi.success('Information updated successfully')
|
||||
} catch (err) {
|
||||
if (err.errorFields) {
|
||||
return
|
||||
}
|
||||
messageApi.error('Failed to update information')
|
||||
showError(
|
||||
`Failed to update information. Message: ${err.message}. Code: ${err.code}`,
|
||||
() => handleUpdate()
|
||||
)
|
||||
} finally {
|
||||
fetchObject()
|
||||
setEditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form form={form} layout='vertical' style={style}>
|
||||
{contextHolder}
|
||||
{children({
|
||||
loading: fetchLoading,
|
||||
isEditing,
|
||||
startEditing,
|
||||
cancelEditing,
|
||||
handleUpdate,
|
||||
form,
|
||||
formValid,
|
||||
objectData,
|
||||
setIsEditing,
|
||||
setObjectData,
|
||||
editLoading,
|
||||
lock,
|
||||
fetchObject
|
||||
})}
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
EditObjectForm.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
children: PropTypes.func.isRequired,
|
||||
style: PropTypes.object
|
||||
}
|
||||
|
||||
export default EditObjectForm
|
||||
@ -3,7 +3,7 @@ import { Flex, Typography, Badge } from 'antd'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import FilamentStockIcon from '../../Icons/FilamentStockIcon'
|
||||
import IdText from './IdText'
|
||||
import IdDisplay from './IdDisplay'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@ -37,7 +37,7 @@ const FilamentStockDisplay = ({
|
||||
{showColor && <Badge color={filamentStock.filament.color} />}
|
||||
<Text>{`${filamentStock.filament?.name} (${filamentStock.currentNetWeight}g)`}</Text>
|
||||
{showId && (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={filamentStock._id}
|
||||
longId={longId}
|
||||
type={'filamentstock'}
|
||||
|
||||
@ -1,24 +1,16 @@
|
||||
// PrinterSelect.js
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import {
|
||||
Flex,
|
||||
Typography,
|
||||
Button,
|
||||
Tooltip,
|
||||
message,
|
||||
Space,
|
||||
Popover
|
||||
} from 'antd'
|
||||
import { Flex, Typography, Space, Popover } from 'antd'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import CopyIcon from '../../Icons/CopyIcon'
|
||||
import CopyButton from './CopyButton'
|
||||
import SpotlightTooltip from './SpotlightTooltip'
|
||||
import { getTypeMeta } from '../utils/Utils'
|
||||
|
||||
const { Text, Link } = Typography
|
||||
|
||||
const IdText = ({
|
||||
const IdDisplay = ({
|
||||
id,
|
||||
type,
|
||||
showCopy = true,
|
||||
@ -26,7 +18,6 @@ const IdText = ({
|
||||
showHyperlink = false,
|
||||
showSpotlight = true
|
||||
}) => {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
@ -36,6 +27,10 @@ const IdText = ({
|
||||
const IconComponent = meta.icon
|
||||
const icon = <IconComponent style={{ paddingTop: '4px' }} />
|
||||
|
||||
if (!id) {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
|
||||
id = typeof id === 'string' ? id.toString().toUpperCase() : 'XXXXXX'
|
||||
var displayId = prefix + ':' + id
|
||||
var copyId = prefix + ':' + id
|
||||
@ -45,9 +40,7 @@ const IdText = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex align={'center'} gap={'small'} className='idtext'>
|
||||
{contextHolder}
|
||||
|
||||
<Flex align={'center'} className='iddisplay'>
|
||||
{showHyperlink &&
|
||||
(showSpotlight ? (
|
||||
<Popover
|
||||
@ -67,6 +60,7 @@ const IdText = ({
|
||||
navigate(hyperlink)
|
||||
}
|
||||
}}
|
||||
style={{ marginRight: 6 }}
|
||||
>
|
||||
<Text code ellipsis>
|
||||
<Space size={4}>
|
||||
@ -105,7 +99,7 @@ const IdText = ({
|
||||
placement='topLeft'
|
||||
arrow={false}
|
||||
>
|
||||
<Text code ellipsis>
|
||||
<Text code ellipsis style={{ marginRight: 6 }}>
|
||||
<Space size={4}>
|
||||
{icon}
|
||||
{displayId}
|
||||
@ -121,59 +115,18 @@ const IdText = ({
|
||||
</Text>
|
||||
))}
|
||||
{showCopy && (
|
||||
<Tooltip title='Copy ID' arrow={false}>
|
||||
<Button
|
||||
icon={<CopyIcon style={{ fontSize: '14px' }} />}
|
||||
type='text'
|
||||
style={{ height: '22px' }}
|
||||
onClick={() => {
|
||||
const doCopy = (text) => {
|
||||
if (
|
||||
navigator &&
|
||||
navigator.clipboard &&
|
||||
navigator.clipboard.writeText
|
||||
) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
messageApi.success('ID copied to clipboard')
|
||||
})
|
||||
.catch(() => {
|
||||
messageApi.error('Failed to copy ID')
|
||||
})
|
||||
} else if (
|
||||
document.queryCommandSupported &&
|
||||
document.queryCommandSupported('copy')
|
||||
) {
|
||||
// Legacy fallback
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'absolute'
|
||||
textarea.style.left = '-9999px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
messageApi.success('ID copied to clipboard')
|
||||
} catch (err) {
|
||||
messageApi.error('Failed to copy ID')
|
||||
}
|
||||
document.body.removeChild(textarea)
|
||||
} else {
|
||||
messageApi.error('Copy not supported in this browser')
|
||||
}
|
||||
}
|
||||
doCopy(copyId)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<CopyButton
|
||||
text={copyId}
|
||||
tooltip='Copy ID'
|
||||
style={{ marginLeft: 0 }}
|
||||
iconStyle={{ fontSize: '14px' }}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
IdText.propTypes = {
|
||||
IdDisplay.propTypes = {
|
||||
id: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
showCopy: PropTypes.bool,
|
||||
@ -182,4 +135,4 @@ IdText.propTypes = {
|
||||
showSpotlight: PropTypes.bool
|
||||
}
|
||||
|
||||
export default IdText
|
||||
export default IdDisplay
|
||||
@ -2,7 +2,7 @@ import PropTypes from 'prop-types'
|
||||
import { Progress, Flex, Typography, Space } from 'antd'
|
||||
import React, { useState, useContext, useEffect } from 'react'
|
||||
import { PrintServerContext } from '../context/PrintServerContext'
|
||||
import IdText from './IdText'
|
||||
import IdDisplay from './IdDisplay'
|
||||
import StateTag from './StateTag'
|
||||
|
||||
const JobState = ({
|
||||
@ -38,7 +38,7 @@ const JobState = ({
|
||||
return (
|
||||
<Flex gap='small' align={'center'}>
|
||||
{showId && (
|
||||
<IdText id={job._id} showCopy={false} type='job' longId={false} />
|
||||
<IdDisplay id={job._id} showCopy={false} type='job' longId={false} />
|
||||
)}
|
||||
{showQuantity && <Text>({job.quantity})</Text>}
|
||||
{showStatus && (
|
||||
|
||||
50
src/components/Dashboard/common/ObjectInfo.jsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React from 'react'
|
||||
import { Spin, Descriptions } from 'antd'
|
||||
import PropTypes from 'prop-types'
|
||||
import ObjectProperty from './ObjectProperty'
|
||||
|
||||
const ObjectInfo = ({
|
||||
loading = false,
|
||||
indicator = null,
|
||||
bordered = true,
|
||||
isEditing = false,
|
||||
items = []
|
||||
}) => {
|
||||
// Map items to Descriptions 'items' prop format
|
||||
const descriptionItems = items.map((item, idx) => {
|
||||
const key = item.name || item.label || idx
|
||||
return {
|
||||
key,
|
||||
label: item.label,
|
||||
children: <ObjectProperty {...item} isEditing={isEditing} />
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Spin spinning={loading} indicator={indicator}>
|
||||
<Descriptions
|
||||
bordered={bordered}
|
||||
column={{
|
||||
xs: 1,
|
||||
sm: 1,
|
||||
md: 1,
|
||||
lg: 2,
|
||||
xl: 2,
|
||||
xxl: 2
|
||||
}}
|
||||
items={descriptionItems}
|
||||
/>
|
||||
</Spin>
|
||||
)
|
||||
}
|
||||
|
||||
ObjectInfo.propTypes = {
|
||||
loading: PropTypes.bool,
|
||||
indicator: PropTypes.node,
|
||||
bordered: PropTypes.bool,
|
||||
items: PropTypes.arrayOf(PropTypes.object),
|
||||
isEditing: PropTypes.bool,
|
||||
type: PropTypes.string.isRequired
|
||||
}
|
||||
|
||||
export default ObjectInfo
|
||||
465
src/components/Dashboard/common/ObjectProperty.jsx
Normal file
@ -0,0 +1,465 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import {
|
||||
Typography,
|
||||
Badge,
|
||||
Input,
|
||||
InputNumber,
|
||||
Form,
|
||||
Select,
|
||||
DatePicker,
|
||||
Switch
|
||||
} from 'antd'
|
||||
import VendorSelect from './VendorSelect'
|
||||
import FilamentSelect from './FilamentSelect'
|
||||
import IdDisplay from './IdDisplay'
|
||||
import TimeDisplay from './TimeDisplay'
|
||||
import dayjs from 'dayjs'
|
||||
import PrinterSelect from './PrinterSelect'
|
||||
import GCodeFileSelect from './GCodeFileSelect'
|
||||
import PartSelect from './PartSelect'
|
||||
import EmailDisplay from '../../Icons/EmailDisplay'
|
||||
import UrlDisplay from '../../Icons/UrlDisplay'
|
||||
import CountryDisplay from './CountryDisplay'
|
||||
import CountrySelect from './CountrySelect'
|
||||
import TagsDisplay from './TagsDisplay'
|
||||
import TagsInput from './TagsInput'
|
||||
import BoolDisplay from './BoolDisplay'
|
||||
import PrinterState from './PrinterState'
|
||||
import SubJobState from './SubJobState'
|
||||
import JobState from './JobState'
|
||||
import ColorSelector from './ColorSelector'
|
||||
import SecretDisplay from './SecretDisplay'
|
||||
import EyeIcon from '../../Icons/EyeIcon'
|
||||
import EyeSlashIcon from '../../Icons/EyeSlashIcon'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const MATERIAL_OPTIONS = [
|
||||
{ value: 'PLA', label: 'PLA' },
|
||||
{ value: 'PETG', label: 'PETG' },
|
||||
{ value: 'ABS', label: 'ABS' },
|
||||
{ value: 'ASA', label: 'ASA' },
|
||||
{ value: 'HIPS', label: 'HIPS' },
|
||||
{ value: 'TPU', label: 'TPU' }
|
||||
]
|
||||
|
||||
const ObjectProperty = ({
|
||||
type = 'text',
|
||||
value,
|
||||
isEditing = false,
|
||||
formItemProps = {},
|
||||
required = false,
|
||||
name,
|
||||
label,
|
||||
showLabel = false,
|
||||
objectType = 'unknown',
|
||||
readOnly = false,
|
||||
...rest
|
||||
}) => {
|
||||
const renderProperty = () => {
|
||||
console.log('Rendering')
|
||||
if (!isEditing || readOnly) {
|
||||
switch (type) {
|
||||
case 'secret':
|
||||
if (value != null) {
|
||||
return <SecretDisplay value={value} {...rest} />
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
case 'wsprotocol':
|
||||
switch (value) {
|
||||
case 'ws':
|
||||
return <Text>Websocket</Text>
|
||||
case 'wss':
|
||||
return <Text>Websocket Secure</Text>
|
||||
default:
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
case 'bool': {
|
||||
if (value != null) {
|
||||
return <BoolDisplay value={value} yesNo={true} {...rest} />
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
}
|
||||
case 'dateTime': {
|
||||
if (value != null) {
|
||||
return <TimeDisplay dateTime={value} {...rest} />
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
}
|
||||
case 'currency': {
|
||||
if (value != null) {
|
||||
return <Text>{`£${value}/kg`}</Text>
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
}
|
||||
case 'country': {
|
||||
if (value != null) {
|
||||
return <CountryDisplay countryCode={value} />
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
}
|
||||
case 'color': {
|
||||
if (value) {
|
||||
return <Badge color={value} text={value} />
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
}
|
||||
case 'weight': {
|
||||
if (value != null) {
|
||||
return <Text>{`${value}g`}</Text>
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
}
|
||||
case 'number': {
|
||||
if (value != null) {
|
||||
return <Text>{value}</Text>
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
}
|
||||
case 'text':
|
||||
if (value != null && value != '') {
|
||||
return <Text>{value}</Text>
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
case 'email':
|
||||
if (value != null && value != '') {
|
||||
return <EmailDisplay email={value} />
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
case 'url':
|
||||
if (value != null && value != '') {
|
||||
return <UrlDisplay url={value} />
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
case 'object': {
|
||||
if (value && value.name) {
|
||||
return <Text>{value.name}</Text>
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
}
|
||||
case 'state': {
|
||||
if (value && value?.state) {
|
||||
switch (objectType) {
|
||||
case 'printer':
|
||||
return <PrinterState printer={value} {...rest} />
|
||||
case 'job':
|
||||
return <JobState job={value} {...rest} />
|
||||
case 'subjob':
|
||||
return <SubJobState subJob={value} {...rest} />
|
||||
|
||||
default:
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
}
|
||||
case 'material': {
|
||||
if (value) {
|
||||
return <Text>{value}</Text>
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
}
|
||||
case 'id': {
|
||||
if (value) {
|
||||
return <IdDisplay id={value} type={objectType} {...rest} />
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
}
|
||||
case 'density': {
|
||||
if (value != null) {
|
||||
return <Text>{`${value} g/cm³`}</Text>
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
}
|
||||
case 'mm': {
|
||||
if (value != null) {
|
||||
return <Text>{`${value} mm`}</Text>
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
}
|
||||
case 'tags': {
|
||||
if (value != null || value?.length != 0) {
|
||||
return <TagsDisplay tags={value} />
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
}
|
||||
case 'version': {
|
||||
if (value != null) {
|
||||
return <Text>{`${value} mm`}</Text>
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
}
|
||||
default: {
|
||||
if (value) {
|
||||
return <Text>{value}</Text>
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Editable mode: wrap in Form.Item
|
||||
// Merge required rule if needed
|
||||
let mergedFormItemProps = { ...formItemProps }
|
||||
if (required) {
|
||||
let rules
|
||||
if (mergedFormItemProps.rules) {
|
||||
rules = [...mergedFormItemProps.rules]
|
||||
} else {
|
||||
rules = []
|
||||
}
|
||||
const hasRequiredRule = rules.some((rule) => rule && rule.required)
|
||||
if (!hasRequiredRule) {
|
||||
rules.push({ required: true, message: 'This field is required' })
|
||||
}
|
||||
mergedFormItemProps.rules = rules
|
||||
}
|
||||
// Remove name from mergedFormItemProps if present
|
||||
if (mergedFormItemProps.name) {
|
||||
delete mergedFormItemProps.name
|
||||
}
|
||||
// If label is provided, set it on Form.Item
|
||||
if (label && showLabel == true) {
|
||||
mergedFormItemProps.label = label
|
||||
}
|
||||
// Always apply style: { margin: 0 } unless overridden
|
||||
mergedFormItemProps.style = {
|
||||
margin: 0,
|
||||
...(mergedFormItemProps.style || {})
|
||||
}
|
||||
switch (type) {
|
||||
case 'secret':
|
||||
return (
|
||||
<Form.Item name={name} {...mergedFormItemProps}>
|
||||
<Input.Password
|
||||
placeholder={label}
|
||||
{...mergedFormItemProps}
|
||||
iconRender={(visible) =>
|
||||
visible ? <EyeSlashIcon /> : <EyeIcon />
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
case 'wsprotocol':
|
||||
return (
|
||||
<Form.Item name={name} {...mergedFormItemProps}>
|
||||
<Select
|
||||
defaultValue='ws'
|
||||
options={[
|
||||
{ value: 'ws', label: 'Websocket' },
|
||||
{ value: 'wss', label: 'Websocket Secure' }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
case 'bool':
|
||||
return (
|
||||
<Form.Item
|
||||
name={name}
|
||||
{...mergedFormItemProps}
|
||||
valuePropName='checked'
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
)
|
||||
case 'dateTime':
|
||||
return (
|
||||
<Form.Item
|
||||
name={name}
|
||||
{...mergedFormItemProps}
|
||||
getValueProps={(v) => ({ value: v ? dayjs(v) : null })}
|
||||
>
|
||||
<DatePicker showTime style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
)
|
||||
case 'currency':
|
||||
return (
|
||||
<Form.Item name={name} {...mergedFormItemProps}>
|
||||
<InputNumber
|
||||
prefix='£'
|
||||
suffix='/kg'
|
||||
style={{ width: '100%' }}
|
||||
placeholder={label}
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
case 'country':
|
||||
return (
|
||||
<Form.Item name={name} {...mergedFormItemProps}>
|
||||
<CountrySelect />
|
||||
</Form.Item>
|
||||
)
|
||||
case 'color':
|
||||
return (
|
||||
<Form.Item
|
||||
name={name}
|
||||
{...mergedFormItemProps}
|
||||
valuePropName='value'
|
||||
getValueFromEvent={(v) => v}
|
||||
>
|
||||
<ColorSelector required={required} />
|
||||
</Form.Item>
|
||||
)
|
||||
case 'weight':
|
||||
return (
|
||||
<Form.Item name={name} {...mergedFormItemProps}>
|
||||
<InputNumber
|
||||
suffix='g'
|
||||
style={{ width: '100%' }}
|
||||
placeholder={label}
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
case 'number':
|
||||
return (
|
||||
<Form.Item name={name} {...mergedFormItemProps}>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder={label}
|
||||
{...mergedFormItemProps}
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
case 'text':
|
||||
return (
|
||||
<Form.Item name={name} {...mergedFormItemProps}>
|
||||
<Input placeholder={label} {...mergedFormItemProps} />
|
||||
</Form.Item>
|
||||
)
|
||||
case 'material':
|
||||
return (
|
||||
<Form.Item name={name} {...mergedFormItemProps}>
|
||||
<Select options={MATERIAL_OPTIONS} placeholder={label} />
|
||||
</Form.Item>
|
||||
)
|
||||
case 'id':
|
||||
// id is not editable, just show view mode
|
||||
if (value) {
|
||||
return <IdDisplay id={value} type={objectType} {...rest} />
|
||||
} else {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
case 'object':
|
||||
switch (objectType) {
|
||||
case 'vendor':
|
||||
return (
|
||||
<Form.Item name={name} {...mergedFormItemProps}>
|
||||
<VendorSelect placeholder={label} />
|
||||
</Form.Item>
|
||||
)
|
||||
case 'printer':
|
||||
return (
|
||||
<Form.Item name={name} {...mergedFormItemProps}>
|
||||
<PrinterSelect placeholder={label} />
|
||||
</Form.Item>
|
||||
)
|
||||
case 'gcodefile':
|
||||
return (
|
||||
<Form.Item name={name} {...mergedFormItemProps}>
|
||||
<GCodeFileSelect placeholder={label} />
|
||||
</Form.Item>
|
||||
)
|
||||
case 'filament':
|
||||
return (
|
||||
<Form.Item name={name} {...mergedFormItemProps}>
|
||||
<FilamentSelect placeholder={label} />
|
||||
</Form.Item>
|
||||
)
|
||||
case 'part':
|
||||
return (
|
||||
<Form.Item name={name} {...mergedFormItemProps}>
|
||||
<PartSelect placeholder={label} />
|
||||
</Form.Item>
|
||||
)
|
||||
default:
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
|
||||
case 'density':
|
||||
return (
|
||||
<Form.Item name={name} {...mergedFormItemProps}>
|
||||
<InputNumber
|
||||
suffix='g/cm³'
|
||||
style={{ width: '100%' }}
|
||||
placeholder={label}
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
case 'mm':
|
||||
return (
|
||||
<Form.Item name={name} {...mergedFormItemProps}>
|
||||
<InputNumber
|
||||
suffix='mm'
|
||||
style={{ width: '100%' }}
|
||||
placeholder={label}
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
case 'tags':
|
||||
return (
|
||||
<Form.Item name={name} {...mergedFormItemProps}>
|
||||
<TagsInput />
|
||||
</Form.Item>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<Form.Item name={name} {...mergedFormItemProps}>
|
||||
<Input placeholder={label} {...mergedFormItemProps} />
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const property = renderProperty()
|
||||
// Render the property directly (remove useDescriptions functionality)
|
||||
return property
|
||||
}
|
||||
|
||||
ObjectProperty.propTypes = {
|
||||
type: PropTypes.oneOf([
|
||||
'text',
|
||||
'number',
|
||||
'currency',
|
||||
'color',
|
||||
'weight',
|
||||
'vendor',
|
||||
'material',
|
||||
'id',
|
||||
'density',
|
||||
'mm'
|
||||
]),
|
||||
value: PropTypes.any,
|
||||
isEditing: PropTypes.bool,
|
||||
formItemProps: PropTypes.object,
|
||||
required: PropTypes.bool,
|
||||
name: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
showLabel: PropTypes.bool,
|
||||
objectType: PropTypes.string.isRequired,
|
||||
readOnly: PropTypes.bool
|
||||
}
|
||||
|
||||
ObjectProperty.defaultProps = {}
|
||||
|
||||
export default ObjectProperty
|
||||
@ -1,10 +1,11 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { TreeSelect, Typography, Flex, Badge } from 'antd'
|
||||
import { TreeSelect, Typography, Flex, Badge, Space, Button, Input } from 'antd'
|
||||
import axios from 'axios'
|
||||
import { getTypeMeta } from '../utils/Utils'
|
||||
import IdText from './IdText'
|
||||
import IdDisplay from './IdDisplay'
|
||||
import CountryDisplay from './CountryDisplay'
|
||||
import ReloadIcon from '../../Icons/ReloadIcon'
|
||||
const { Text } = Typography
|
||||
const { SHOW_CHILD } = TreeSelect
|
||||
/**
|
||||
@ -121,7 +122,7 @@ const ObjectSelect = ({
|
||||
{Icon && <Icon />}
|
||||
{item?.color && <Badge color={item.color}></Badge>}
|
||||
<Text ellipsis>{item.name || type.title}</Text>
|
||||
<IdText id={item._id} longId={false} type={type} />
|
||||
<IdDisplay id={item._id} longId={false} type={type} />
|
||||
</Flex>
|
||||
)
|
||||
},
|
||||
@ -139,7 +140,6 @@ const ObjectSelect = ({
|
||||
// Build category nodes for each property level and load all available options
|
||||
for (let i = 0; i < propertyOrder.length; i++) {
|
||||
const propertyName = propertyOrder[i]
|
||||
console.log('propname', propertyName)
|
||||
let propertyValue
|
||||
|
||||
// Handle nested property access (e.g., 'filament.diameter')
|
||||
@ -342,9 +342,6 @@ const ObjectSelect = ({
|
||||
value = item
|
||||
}
|
||||
const title = renderTitle({ ...item, value }, isLeaf)
|
||||
console.log('propname', propertyName)
|
||||
console.log('value', value)
|
||||
console.log(item)
|
||||
return {
|
||||
id: value,
|
||||
pId: node.id,
|
||||
@ -401,7 +398,6 @@ const ObjectSelect = ({
|
||||
onChange(node ? node.raw : val, selectedOptions)
|
||||
}
|
||||
}
|
||||
console.log('val', val)
|
||||
setDefaultValue(val)
|
||||
}
|
||||
|
||||
@ -486,17 +482,18 @@ const ObjectSelect = ({
|
||||
])
|
||||
|
||||
return error ? (
|
||||
<div style={{ color: 'red', padding: 8 }}>
|
||||
Failed to load data.{' '}
|
||||
<button
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input value='Failed to load data.' status='error' disabled />
|
||||
|
||||
<Button
|
||||
icon={<ReloadIcon />}
|
||||
onClick={() => {
|
||||
setError(false)
|
||||
setTreeData([])
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
danger
|
||||
/>
|
||||
</Space.Compact>
|
||||
) : (
|
||||
<TreeSelect
|
||||
treeDataSimpleMode
|
||||
@ -505,7 +502,7 @@ const ObjectSelect = ({
|
||||
treeData={treeData}
|
||||
onChange={handleOnChange}
|
||||
loading={loading}
|
||||
value={defaultValue}
|
||||
value={loading ? 'Loading...' : defaultValue}
|
||||
showSearch={showSearch}
|
||||
onSearch={showSearch ? handleSearch : undefined}
|
||||
treeCheckable={treeCheckable}
|
||||
|
||||
@ -2,7 +2,7 @@ import React from 'react'
|
||||
import { Table } from 'antd'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
|
||||
import IdText from './IdText'
|
||||
import IdDisplay from './IdDisplay'
|
||||
import PartIcon from '../../Icons/PartIcon'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
@ -28,7 +28,9 @@ const PartsTable = ({ data = [], loading = false, showHeader = false }) => {
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'part'} showHyperlink={true} />
|
||||
render: (text) => (
|
||||
<IdDisplay id={text} type={'part'} showHyperlink={true} />
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@ -41,7 +41,7 @@ const PrinterMovementPanel = ({ printerId }) => {
|
||||
|
||||
const handleHomeAxisClick = (axis) => {
|
||||
if (printServer) {
|
||||
console.log('Homeing Axis:', axis)
|
||||
logger.debug('Homeing Axis:', axis)
|
||||
printServer.emit('printer.gcode.script', {
|
||||
printerId,
|
||||
script: `G28 ${axis}`
|
||||
@ -52,7 +52,7 @@ const PrinterMovementPanel = ({ printerId }) => {
|
||||
const handleMoveAxisClick = (axis, minus) => {
|
||||
const distanceValue = !minus ? posValue * -1 : posValue
|
||||
if (printServer) {
|
||||
console.log('Moving Axis:', axis, distanceValue)
|
||||
logger.debug('Moving Axis:', axis, distanceValue)
|
||||
printServer.emit('printer.gcode.script', {
|
||||
printerId,
|
||||
script: `_CLIENT_LINEAR_MOVE ${axis}=${distanceValue} F=${rateValue}`
|
||||
|
||||
@ -12,7 +12,7 @@ const PrinterState = ({
|
||||
printer,
|
||||
showProgress = true,
|
||||
showStatus = true,
|
||||
showPrinterName = true,
|
||||
showName = true,
|
||||
showControls = true
|
||||
}) => {
|
||||
const { printServer } = useContext(PrintServerContext)
|
||||
@ -43,7 +43,7 @@ const PrinterState = ({
|
||||
|
||||
return (
|
||||
<Flex gap='small' align={'center'}>
|
||||
{showPrinterName && <Text>{printer.name}</Text>}
|
||||
{showName && <Text>{printer.name}</Text>}
|
||||
{showStatus && (
|
||||
<Space>
|
||||
<StateTag state={currentState.type} />
|
||||
@ -122,7 +122,7 @@ PrinterState.propTypes = {
|
||||
}),
|
||||
showProgress: PropTypes.bool,
|
||||
showStatus: PropTypes.bool,
|
||||
showPrinterName: PropTypes.bool,
|
||||
showName: PropTypes.bool,
|
||||
showControls: PropTypes.bool
|
||||
}
|
||||
|
||||
|
||||
@ -97,14 +97,12 @@ const PrinterTemperaturePanel = ({
|
||||
}
|
||||
}
|
||||
if (printServer?.connected == true) {
|
||||
console.log('Printer Temperature Panel is subscribing...')
|
||||
printServer.emit('printer.objects.subscribe', params)
|
||||
printServer.emit('printer.objects.query', params)
|
||||
printServer.on('notify_status_update', notifyTemperatureStatusUpdate)
|
||||
}
|
||||
return () => {
|
||||
if (printServer && shouldUnsubscribe == true) {
|
||||
console.log('Printer Temperature Panel is unsubscribing...')
|
||||
printServer.off('notify_status_update', notifyTemperatureStatusUpdate)
|
||||
printServer.emit('printer.objects.unsubscribe', params)
|
||||
}
|
||||
@ -113,7 +111,6 @@ const PrinterTemperaturePanel = ({
|
||||
|
||||
const handleSetTemperatureClick = (target, value) => {
|
||||
if (printServer) {
|
||||
console.log('printer.gcode.script', target, value)
|
||||
printServer.emit('printer.gcode.script', {
|
||||
printerId,
|
||||
script: `SET_HEATER_TEMPERATURE HEATER=${target} TARGET=${value}`
|
||||
|
||||
50
src/components/Dashboard/common/SecretDisplay.jsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React, { useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Typography, Tooltip, Button } from 'antd'
|
||||
import CopyButton from './CopyButton'
|
||||
import EyeIcon from '../../Icons/EyeIcon'
|
||||
import EyeSlashIcon from '../../Icons/EyeSlashIcon'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const SecretDisplay = ({ value, reveal = false }) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
if (!value) {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
|
||||
const masked = '•'.repeat(Math.max(8, value.length))
|
||||
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<Text code>{reveal && visible ? value : masked}</Text>
|
||||
{reveal && (
|
||||
<Tooltip title={visible ? 'Hide' : 'Show'} arrow={false}>
|
||||
<Button
|
||||
type='text'
|
||||
icon={visible ? <EyeSlashIcon /> : <EyeIcon />}
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
size='small'
|
||||
aria-label={visible ? 'Hide secret' : 'Show secret'}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{reveal && value && (
|
||||
<CopyButton
|
||||
text={value}
|
||||
tooltip='Copy Secret'
|
||||
style={{ marginLeft: 0 }}
|
||||
iconStyle={{ fontSize: '14px' }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
SecretDisplay.propTypes = {
|
||||
value: PropTypes.string,
|
||||
reveal: PropTypes.bool
|
||||
}
|
||||
|
||||
export default SecretDisplay
|
||||
@ -15,7 +15,7 @@ import React, { useEffect, useState, useContext, useCallback } from 'react'
|
||||
import axios from 'axios'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import config from '../../../config'
|
||||
import IdText from './IdText'
|
||||
import IdDisplay from './IdDisplay'
|
||||
import TimeDisplay from './TimeDisplay'
|
||||
import { Tag } from 'antd'
|
||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||
@ -93,7 +93,7 @@ const SpotlightTooltip = ({ query, type }) => {
|
||||
const renderValue = (key, value) => {
|
||||
if (key === '_id' || key === 'id') {
|
||||
return (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={value}
|
||||
type={type}
|
||||
showCopy={true}
|
||||
@ -108,7 +108,7 @@ const SpotlightTooltip = ({ query, type }) => {
|
||||
<PrinterState
|
||||
printer={spotlightData}
|
||||
showControls={false}
|
||||
showPrinterName={false}
|
||||
showName={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useContext, useState } from 'react'
|
||||
import { Table, Typography } from 'antd'
|
||||
import PropTypes from 'prop-types'
|
||||
import IdText from './IdText'
|
||||
import IdDisplay from './IdDisplay'
|
||||
import { AuditOutlined } from '@ant-design/icons'
|
||||
import { PrintServerContext } from '../context/PrintServerContext'
|
||||
import moment from 'moment'
|
||||
@ -22,7 +22,6 @@ const StockEventTable = ({ stockEvents }) => {
|
||||
if (printServer && !initialized) {
|
||||
setInitialized(true)
|
||||
printServer.on('notify_stockevent_update', (updateData) => {
|
||||
console.log('Received stock event update:', updateData)
|
||||
setStockEventsData((prevData) => {
|
||||
return prevData.map((stockEvent) => {
|
||||
if (stockEvent?._id) {
|
||||
@ -42,7 +41,6 @@ const StockEventTable = ({ stockEvents }) => {
|
||||
|
||||
return () => {
|
||||
if (printServer && initialized) {
|
||||
console.log('Deregistering stock event update listener')
|
||||
printServer.off('notify_stockevent_update')
|
||||
}
|
||||
}
|
||||
@ -138,7 +136,7 @@ const StockEventTable = ({ stockEvents }) => {
|
||||
render: (record) => {
|
||||
if (record.subJob) {
|
||||
return (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={record.subJob.number.toString().padStart(6, '0')}
|
||||
longId={false}
|
||||
type={'subjob'}
|
||||
@ -147,7 +145,7 @@ const StockEventTable = ({ stockEvents }) => {
|
||||
}
|
||||
if (record.stockAudit) {
|
||||
return (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={record.stockAudit._id}
|
||||
longId={false}
|
||||
type={'stockaudit'}
|
||||
@ -164,7 +162,7 @@ const StockEventTable = ({ stockEvents }) => {
|
||||
render: (record) => {
|
||||
if (record.subJob) {
|
||||
return (
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={record.job._id}
|
||||
longId={false}
|
||||
type={'job'}
|
||||
|
||||
@ -23,11 +23,9 @@ const SubJobCounter = ({
|
||||
useEffect(() => {
|
||||
if (printServer && !initialized && job?.id) {
|
||||
setInitialized(true)
|
||||
console.log('on notify_subjob_update')
|
||||
printServer.on('notify_subjob_update', (statusUpdate) => {
|
||||
for (const subJob of job.subJobs) {
|
||||
if (statusUpdate?._id === subJob.id && statusUpdate?.state) {
|
||||
console.log('statusUpdate', statusUpdate)
|
||||
setSubJobs((prev) => [...prev, statusUpdate])
|
||||
}
|
||||
}
|
||||
@ -35,7 +33,6 @@ const SubJobCounter = ({
|
||||
}
|
||||
return () => {
|
||||
if (printServer && initialized) {
|
||||
console.log('off notify_subjob_update')
|
||||
printServer.off('notify_subjob_update')
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,11 +3,15 @@ import { Progress, Flex, Button, Space, Tooltip } from 'antd' // eslint-disable-
|
||||
import { CaretLeftOutlined } from '@ant-design/icons' // eslint-disable-line
|
||||
import React, { useState, useContext, useEffect } from 'react'
|
||||
import { PrintServerContext } from '../context/PrintServerContext'
|
||||
import IdText from './IdText'
|
||||
import IdDisplay from './IdDisplay'
|
||||
import StateTag from './StateTag'
|
||||
import XMarkIcon from '../../Icons/XMarkIcon'
|
||||
import PauseIcon from '../../Icons/PauseIcon'
|
||||
import BinIcon from '../../Icons/BinIcon'
|
||||
import config from '../../../config'
|
||||
import loglevel from 'loglevel'
|
||||
const logger = loglevel.getLogger('SubJobState')
|
||||
logger.setLevel(config.logLevel)
|
||||
|
||||
const SubJobState = ({
|
||||
subJob,
|
||||
@ -28,17 +32,17 @@ const SubJobState = ({
|
||||
useEffect(() => {
|
||||
if (printServer && !initialized && subJob?._id) {
|
||||
setInitialized(true)
|
||||
console.log('on notify_subjob_update')
|
||||
logger.debug('on notify_subjob_update')
|
||||
printServer.on('notify_subjob_update', (statusUpdate) => {
|
||||
if (statusUpdate?._id === subJob._id && statusUpdate?.state) {
|
||||
console.log('statusUpdate', statusUpdate)
|
||||
logger.debug('statusUpdate', statusUpdate)
|
||||
setCurrentState(statusUpdate.state)
|
||||
}
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
if (printServer && initialized) {
|
||||
console.log('off notify_subjob_update')
|
||||
logger.debug('off notify_subjob_update')
|
||||
printServer.off('notify_subjob_update')
|
||||
}
|
||||
}
|
||||
@ -47,7 +51,12 @@ const SubJobState = ({
|
||||
return (
|
||||
<Flex gap='small' align={'center'}>
|
||||
{showId && (
|
||||
<IdText id={subJob._id} showCopy={false} type='subjob' longId={false} />
|
||||
<IdDisplay
|
||||
id={subJob._id}
|
||||
showCopy={false}
|
||||
type='subjob'
|
||||
longId={false}
|
||||
/>
|
||||
)}
|
||||
{showStatus && (
|
||||
<Space>
|
||||
@ -134,7 +143,7 @@ const SubJobState = ({
|
||||
<Tooltip title='Delete' arrow={false}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
console.log('Hello')
|
||||
logger.debug('Hello')
|
||||
}}
|
||||
type='text'
|
||||
style={{ height: 'unset' }}
|
||||
|
||||
@ -114,7 +114,7 @@ const SubJobsTree = ({ jobData, loading }) => {
|
||||
// Add printServer.io event listener for deployment updates
|
||||
if (printServer) {
|
||||
printServer.on('notify_deployment_update', (updateData) => {
|
||||
console.log('Received deployment update:', updateData)
|
||||
logger.debug('Received deployment update:', updateData)
|
||||
setCurrentJobData((prevData) => {
|
||||
if (!prevData) return prevData
|
||||
|
||||
@ -151,7 +151,7 @@ const SubJobsTree = ({ jobData, loading }) => {
|
||||
printServer.on('notify_subjob_update', (updateData) => {
|
||||
// Handle sub-job updates
|
||||
if (updateData.subJobId) {
|
||||
console.log('Received subjob update:', updateData)
|
||||
logger.debug('Received subjob update:', updateData)
|
||||
setCurrentJobData((prevData) => {
|
||||
if (!prevData) return prevData
|
||||
return {
|
||||
|
||||
28
src/components/Dashboard/common/TagsDisplay.jsx
Normal file
@ -0,0 +1,28 @@
|
||||
import React from 'react'
|
||||
import { Tag, Space, Typography } from 'antd'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const TagsDisplay = ({ tags = [], style }) => {
|
||||
if (tags.length == 0) {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<Space size={'small'} wrap style={style}>
|
||||
{tags.map((tag, index) => (
|
||||
<Tag key={index} color='blue' style={{ margin: 0 }}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
TagsDisplay.propTypes = {
|
||||
tags: PropTypes.arrayOf(PropTypes.string),
|
||||
style: PropTypes.object
|
||||
}
|
||||
|
||||
export default TagsDisplay
|
||||
56
src/components/Dashboard/common/TagsInput.jsx
Normal file
@ -0,0 +1,56 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Space, Tag, Input, Button } from 'antd'
|
||||
import PlusIcon from '../../Icons/PlusIcon'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const TagsInput = ({ value = [], onChange }) => {
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const handleTagClose = (removedTag) => {
|
||||
const newTags = value.filter((tag) => tag !== removedTag)
|
||||
onChange && onChange(newTags)
|
||||
}
|
||||
|
||||
const handleTagAdd = () => {
|
||||
const newTag = inputValue.trim()
|
||||
if (newTag && !value.includes(newTag)) {
|
||||
const newTags = [...value, newTag]
|
||||
onChange && onChange(newTags)
|
||||
setInputValue('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space size={'small'} wrap style={{ marginBottom: 4, maxWidth: '300px' }}>
|
||||
{value.map((tag) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
color='blue'
|
||||
closable
|
||||
onClose={() => handleTagClose(tag)}
|
||||
style={{ marginBottom: 12, marginRight: 0 }}
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
<Space.Compact block>
|
||||
<Input
|
||||
placeholder='Add new tag'
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onPressEnter={handleTagAdd}
|
||||
/>
|
||||
<Button onClick={handleTagAdd} icon={<PlusIcon />} />
|
||||
</Space.Compact>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
TagsInput.propTypes = {
|
||||
value: PropTypes.arrayOf(PropTypes.string),
|
||||
onChange: PropTypes.func
|
||||
}
|
||||
|
||||
export default TagsInput
|
||||
@ -55,6 +55,10 @@ const TimeDisplay = ({
|
||||
}
|
||||
}, [dateTime, showSince])
|
||||
|
||||
if (!dateTime) {
|
||||
return <Text type='secondary'>n/a</Text>
|
||||
}
|
||||
|
||||
var dateFormat = ''
|
||||
if (showDate == true) {
|
||||
dateFormat += 'YYYY-MM-DD '
|
||||
|
||||
55
src/components/Dashboard/common/ViewButton.jsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React from 'react'
|
||||
import { Button, Popover, Checkbox, Flex } from 'antd'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const ViewButton = ({
|
||||
loading = false,
|
||||
sections = [],
|
||||
collapseState = {},
|
||||
updateCollapseState = () => {},
|
||||
...buttonProps
|
||||
}) => {
|
||||
return (
|
||||
<Popover
|
||||
content={(() => {
|
||||
return (
|
||||
<Flex vertical>
|
||||
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
|
||||
{sections.map((section) => (
|
||||
<Checkbox
|
||||
checked={collapseState[section.key]}
|
||||
key={section.key}
|
||||
onChange={(e) => {
|
||||
updateCollapseState(section.key, e.target.checked)
|
||||
}}
|
||||
>
|
||||
{section.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
})()}
|
||||
placement='bottomLeft'
|
||||
arrow={false}
|
||||
>
|
||||
<Button disabled={loading} {...buttonProps}>
|
||||
View
|
||||
</Button>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
ViewButton.propTypes = {
|
||||
loading: PropTypes.bool,
|
||||
sections: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
key: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired
|
||||
})
|
||||
),
|
||||
collapseState: PropTypes.object,
|
||||
updateCollapseState: PropTypes.func
|
||||
}
|
||||
|
||||
export default ViewButton
|
||||
@ -10,13 +10,14 @@ import io from 'socket.io-client'
|
||||
import { message, notification, Modal, Space, Button } from 'antd'
|
||||
import PropTypes from 'prop-types'
|
||||
import { AuthContext } from './AuthContext'
|
||||
import config from '../../../config'
|
||||
import loglevel from 'loglevel'
|
||||
|
||||
import axios from 'axios'
|
||||
import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon'
|
||||
import ReloadIcon from '../../Icons/ReloadIcon'
|
||||
const log = loglevel.getLogger('Api Server')
|
||||
log.setLevel(config.logLevel)
|
||||
import config from '../../../config'
|
||||
import loglevel from 'loglevel'
|
||||
const logger = loglevel.getLogger('ApiServerContext')
|
||||
logger.setLevel(config.logLevel)
|
||||
|
||||
const ApiServerContext = createContext()
|
||||
|
||||
@ -34,7 +35,7 @@ const ApiServerProvider = ({ children }) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
log.debug('Token is available, connecting to api server...')
|
||||
logger.debug('Token is available, connecting to api server...')
|
||||
|
||||
const newSocket = io(config.apiServerUrl, {
|
||||
reconnectionAttempts: 3,
|
||||
@ -45,18 +46,18 @@ const ApiServerProvider = ({ children }) => {
|
||||
setConnecting(true)
|
||||
|
||||
newSocket.on('connect', () => {
|
||||
log.debug('Api Server connected')
|
||||
logger.debug('Api Server connected')
|
||||
setConnecting(false)
|
||||
setError(null)
|
||||
})
|
||||
|
||||
newSocket.on('disconnect', () => {
|
||||
log.debug('Api Server disconnected')
|
||||
logger.debug('Api Server disconnected')
|
||||
setError('Api Server disconnected')
|
||||
})
|
||||
|
||||
newSocket.on('connect_error', (err) => {
|
||||
log.error('Api Server connection error:', err)
|
||||
logger.error('Api Server connection error:', err)
|
||||
messageApi.error('Api Server connection error: ' + err.message)
|
||||
setError('Api Server connection error')
|
||||
})
|
||||
@ -69,7 +70,7 @@ const ApiServerProvider = ({ children }) => {
|
||||
})
|
||||
|
||||
newSocket.on('error', (err) => {
|
||||
log.error('Api Server error:', err)
|
||||
logger.error('Api Server error:', err)
|
||||
setError('Api Server error')
|
||||
})
|
||||
|
||||
@ -78,37 +79,37 @@ const ApiServerProvider = ({ children }) => {
|
||||
// Clean up function
|
||||
return () => {
|
||||
if (socketRef.current) {
|
||||
log.debug('Cleaning up api server connection...')
|
||||
logger.debug('Cleaning up api server connection...')
|
||||
socketRef.current.disconnect()
|
||||
socketRef.current = null
|
||||
}
|
||||
}
|
||||
} else if (!token && socketRef.current) {
|
||||
log.debug('Token not available, disconnecting api server...')
|
||||
logger.debug('Token not available, disconnecting api server...')
|
||||
socketRef.current.disconnect()
|
||||
socketRef.current = null
|
||||
}
|
||||
}, [token, messageApi])
|
||||
|
||||
const lockObject = (id, type) => {
|
||||
log.debug('Locking ' + id)
|
||||
logger.debug('Locking ' + id)
|
||||
if (socketRef.current && socketRef.current.connected) {
|
||||
socketRef.current.emit('lock', { _id: id, type: type })
|
||||
log.debug('Sent lock command for object:', id)
|
||||
logger.debug('Sent lock command for object:', id)
|
||||
}
|
||||
}
|
||||
|
||||
const unlockObject = (id, type) => {
|
||||
log.debug('Unlocking ' + id)
|
||||
logger.debug('Unlocking ' + id)
|
||||
if (socketRef.current && socketRef.current.connected == true) {
|
||||
socketRef.current.emit('unlock', { _id: id, type: type })
|
||||
log.debug('Sent unlock command for object:', id)
|
||||
logger.debug('Sent unlock command for object:', id)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchObjectLock = async (id, type) => {
|
||||
if (socketRef.current && socketRef.current.connected == true) {
|
||||
log.debug('Fetching lock status for ' + id)
|
||||
logger.debug('Fetching lock status for ' + id)
|
||||
return new Promise((resolve) => {
|
||||
socketRef.current.emit(
|
||||
'getLock',
|
||||
@ -117,11 +118,11 @@ const ApiServerProvider = ({ children }) => {
|
||||
type: type
|
||||
},
|
||||
(lockEvent) => {
|
||||
log.debug('Received lock event for object:', id, lockEvent)
|
||||
logger.debug('Received lock event for object:', id, lockEvent)
|
||||
resolve(lockEvent)
|
||||
}
|
||||
)
|
||||
log.debug('Sent fetch lock command for object:', id)
|
||||
logger.debug('Sent fetch lock command for object:', id)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -130,7 +131,7 @@ const ApiServerProvider = ({ children }) => {
|
||||
if (socketRef.current && socketRef.current.connected == true) {
|
||||
const eventHandler = (data) => {
|
||||
if (data._id === id && data?.user !== userProfile._id) {
|
||||
log.debug(
|
||||
logger.debug(
|
||||
'Lock update received for object:',
|
||||
id,
|
||||
'locked:',
|
||||
@ -141,7 +142,7 @@ const ApiServerProvider = ({ children }) => {
|
||||
}
|
||||
|
||||
socketRef.current.on('notify_lock_update', eventHandler)
|
||||
log.debug('Registered lock event listener for object:', id)
|
||||
logger.debug('Registered lock event listener for object:', id)
|
||||
|
||||
// Return cleanup function
|
||||
return () => offLockEvent(id, eventHandler)
|
||||
@ -151,7 +152,7 @@ const ApiServerProvider = ({ children }) => {
|
||||
const offLockEvent = (id, eventHandler) => {
|
||||
if (socketRef.current && socketRef.current.connected == true) {
|
||||
socketRef.current.off('notify_lock_update', eventHandler)
|
||||
log.debug('Removed lock event listener for object:', id)
|
||||
logger.debug('Removed lock event listener for object:', id)
|
||||
}
|
||||
}
|
||||
|
||||
@ -159,7 +160,7 @@ const ApiServerProvider = ({ children }) => {
|
||||
if (socketRef.current && socketRef.current.connected == true) {
|
||||
const eventHandler = (data) => {
|
||||
if (data._id === id && data?.user !== userProfile._id) {
|
||||
log.debug(
|
||||
logger.debug(
|
||||
'Update event received for object:',
|
||||
id,
|
||||
'updatedAt:',
|
||||
@ -170,7 +171,7 @@ const ApiServerProvider = ({ children }) => {
|
||||
}
|
||||
|
||||
socketRef.current.on('notify_object_update', eventHandler)
|
||||
log.debug('Registered update event listener for object:', id)
|
||||
logger.debug('Registered update event listener for object:', id)
|
||||
|
||||
// Return cleanup function
|
||||
return () => offUpdateEvent(id, eventHandler)
|
||||
@ -180,7 +181,7 @@ const ApiServerProvider = ({ children }) => {
|
||||
const offUpdateEvent = (id, eventHandler) => {
|
||||
if (socketRef.current && socketRef.current.connected == true) {
|
||||
socketRef.current.off('notify_update', eventHandler)
|
||||
log.debug('Removed update event listener for object:', id)
|
||||
logger.debug('Removed update event listener for object:', id)
|
||||
}
|
||||
}
|
||||
|
||||
@ -203,7 +204,7 @@ const ApiServerProvider = ({ children }) => {
|
||||
const fetchObjectInfo = async (id, type) => {
|
||||
const fetchUrl = `${config.backendUrl}/${type}s/${id}`
|
||||
setFetchLoading(true)
|
||||
log.debug('Fetching from ' + fetchUrl)
|
||||
logger.debug('Fetching from ' + fetchUrl)
|
||||
try {
|
||||
const response = await axios.get(fetchUrl, {
|
||||
headers: {
|
||||
@ -213,7 +214,7 @@ const ApiServerProvider = ({ children }) => {
|
||||
})
|
||||
return response.data
|
||||
} catch (err) {
|
||||
log.error('Failed to fetch object information:', err)
|
||||
logger.error('Failed to fetch object information:', err)
|
||||
// Don't automatically show error - let the component handle it
|
||||
throw err
|
||||
} finally {
|
||||
@ -224,7 +225,7 @@ const ApiServerProvider = ({ children }) => {
|
||||
// Update filament information
|
||||
const updateObjectInfo = async (id, type, value) => {
|
||||
const updateUrl = `${config.backendUrl}/${type}s/${id}`
|
||||
log.debug('Updating info for ' + id)
|
||||
logger.debug('Updating info for ' + id)
|
||||
try {
|
||||
const response = await axios.put(updateUrl, value, {
|
||||
headers: {
|
||||
@ -232,7 +233,7 @@ const ApiServerProvider = ({ children }) => {
|
||||
},
|
||||
withCredentials: true
|
||||
})
|
||||
log.debug('Filament updated successfully')
|
||||
logger.debug('Filament updated successfully')
|
||||
if (socketRef.current && socketRef.current.connected == true) {
|
||||
await socketRef.current.emit('update', {
|
||||
_id: id,
|
||||
@ -242,7 +243,7 @@ const ApiServerProvider = ({ children }) => {
|
||||
}
|
||||
return response.data
|
||||
} catch (err) {
|
||||
log.error('Failed to update filament information:', err)
|
||||
logger.error('Failed to update filament information:', err)
|
||||
// Don't automatically show error - let the component handle it
|
||||
throw err
|
||||
}
|
||||
|
||||
@ -7,6 +7,9 @@ import ExclamationOctogonIcon from '../../Icons/ExclamationOctagonIcon'
|
||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||
import config from '../../../config'
|
||||
import AppError from '../../App/AppError'
|
||||
import loglevel from 'loglevel'
|
||||
const logger = loglevel.getLogger('ApiServerContext')
|
||||
logger.setLevel(config.logLevel)
|
||||
|
||||
const AuthContext = createContext()
|
||||
|
||||
@ -50,7 +53,7 @@ const AuthProvider = ({ children }) => {
|
||||
})
|
||||
|
||||
if (response.status === 200 && response.data) {
|
||||
console.log('User is authenticated!')
|
||||
logger.debug('User is authenticated!')
|
||||
setAuthenticated(true)
|
||||
setToken(response.data.access_token)
|
||||
setExpiresAt(response.data.expires_at)
|
||||
@ -60,7 +63,7 @@ const AuthProvider = ({ children }) => {
|
||||
setAuthError('Failed to authenticate user.')
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Auth check failed', error)
|
||||
logger.debug('Auth check failed', error)
|
||||
if (error.response?.status === 401) {
|
||||
setShowUnauthorizedModal(true)
|
||||
} else {
|
||||
|
||||
@ -16,7 +16,7 @@ import PropTypes from 'prop-types'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import PrinterState from '../common/PrinterState'
|
||||
import JobState from '../common/JobState'
|
||||
import IdText from '../common/IdText'
|
||||
import IdDisplay from '../common/IdDisplay'
|
||||
|
||||
import config from '../../../config'
|
||||
import { getTypeMeta, getPrefixMeta } from '../utils/Utils'
|
||||
@ -243,7 +243,6 @@ const SpotlightProvider = ({ children }) => {
|
||||
if (!value || value.trim() === '') {
|
||||
// Only clear the prefix if the input is completely empty
|
||||
if (value === '') {
|
||||
console.log('Clearing prefix')
|
||||
setInputPrefix(null)
|
||||
}
|
||||
if (formRef.current) {
|
||||
@ -278,7 +277,6 @@ const SpotlightProvider = ({ children }) => {
|
||||
const handleKeyDown = (e) => {
|
||||
// If backspace is pressed and there's a prefix but the input is empty
|
||||
if (e.key === 'Backspace' && inputPrefix && query === '') {
|
||||
console.log('Clearing prefix on backspace')
|
||||
// Clear the prefix
|
||||
setInputPrefix(null)
|
||||
// Prevent the default backspace behavior in this case
|
||||
@ -462,7 +460,6 @@ const SpotlightProvider = ({ children }) => {
|
||||
// Add more inference as needed
|
||||
}
|
||||
const meta = getTypeMeta(type)
|
||||
console.log('meta', inputPrefix?.type)
|
||||
const Icon = meta.icon
|
||||
|
||||
// Determine shortcut text
|
||||
@ -489,7 +486,7 @@ const SpotlightProvider = ({ children }) => {
|
||||
{meta.type == 'printer' ? (
|
||||
<PrinterState
|
||||
printer={item}
|
||||
showPrinterName={false}
|
||||
showName={false}
|
||||
showProgress={false}
|
||||
showId={false}
|
||||
/>
|
||||
@ -520,7 +517,7 @@ const SpotlightProvider = ({ children }) => {
|
||||
/>
|
||||
</Flex>
|
||||
) : null}
|
||||
<IdText
|
||||
<IdDisplay
|
||||
id={item._id}
|
||||
type={meta.type}
|
||||
longId={false}
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import { useCallback } from 'react'
|
||||
import config from '../../../config'
|
||||
import loglevel from 'loglevel'
|
||||
const logger = loglevel.getLogger('useTableScroll')
|
||||
logger.setLevel(config.logLevel)
|
||||
|
||||
export const useTableScroll = ({
|
||||
lazyLoading,
|
||||
@ -27,7 +31,7 @@ export const useTableScroll = ({
|
||||
!lazyLoading &&
|
||||
hasMore
|
||||
) {
|
||||
console.log(loadedPages)
|
||||
logger.debug(loadedPages)
|
||||
const lowestPage = Math.max(...loadedPages)
|
||||
const nextPage = lowestPage + 1
|
||||
if (!loadingPages.has(nextPage)) {
|
||||
@ -39,7 +43,7 @@ export const useTableScroll = ({
|
||||
items: page.items.filter((item) => !item.isSkeleton)
|
||||
}))
|
||||
const relevantPages = filteredPages.slice(-2)
|
||||
console.log('Pages after scroll down:', {
|
||||
logger.debug('Pages after scroll down:', {
|
||||
current: currentLoadedPageNumber,
|
||||
next: nextPage,
|
||||
keeping: relevantPages.map((p) => p.pageNum)
|
||||
@ -78,7 +82,7 @@ export const useTableScroll = ({
|
||||
items: page.items.filter((item) => !item.isSkeleton)
|
||||
}))
|
||||
|
||||
console.log('Pages after scroll up:', {
|
||||
logger.debug('Pages after scroll up:', {
|
||||
current: currentLoadedPageNumber,
|
||||
prev: prevPage,
|
||||
keeping: relevantPages.map((p) => p.pageNum)
|
||||
|
||||
@ -55,14 +55,32 @@ export const TYPE_META = [
|
||||
title: 'Printer',
|
||||
prefix: 'PRN',
|
||||
icon: PrinterIcon,
|
||||
url: (id) => `/dashboard/production/printers/info?printerId=${id}`
|
||||
url: (id) => `/dashboard/production/printers/info?printerId=${id}`,
|
||||
properties: {
|
||||
name: 'text'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'filament',
|
||||
title: 'Filament',
|
||||
prefix: 'FIL',
|
||||
icon: FilamentIcon,
|
||||
url: (id) => `/dashboard/management/filaments/info?filamentId=${id}`
|
||||
url: (id) => `/dashboard/management/filaments/info?filamentId=${id}`,
|
||||
properties: {
|
||||
id: 'id',
|
||||
createdAt: 'dateTime',
|
||||
name: 'text',
|
||||
updatedAt: 'dateTime',
|
||||
vendor: 'object', // objectType: vendor
|
||||
vendorId: 'id', // objectType: vendor
|
||||
type: 'material',
|
||||
cost: 'currency',
|
||||
color: 'color',
|
||||
diameter: 'mm',
|
||||
density: 'density',
|
||||
url: 'text',
|
||||
barcode: 'text'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'spool',
|
||||
@ -104,7 +122,18 @@ export const TYPE_META = [
|
||||
title: 'Vendor',
|
||||
prefix: 'VEN',
|
||||
icon: VendorIcon,
|
||||
url: (id) => `/dashboard/management/vendors/info?vendorId=${id}`
|
||||
url: (id) => `/dashboard/management/vendors/info?vendorId=${id}`,
|
||||
properties: {
|
||||
id: 'id', // objectType: vendor
|
||||
createdAt: 'dateTime',
|
||||
name: 'text',
|
||||
updatedAt: 'dateTime',
|
||||
website: 'url',
|
||||
country: 'country',
|
||||
contact: 'text',
|
||||
phone: 'text',
|
||||
email: 'email'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'subjob',
|
||||
|
||||
55
src/components/Icons/EmailDisplay.jsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Typography, Flex, Button, Tooltip } from 'antd'
|
||||
import NewMailIcon from './NewMailIcon'
|
||||
// import CopyIcon from './CopyIcon'
|
||||
import CopyButton from '../Dashboard/common/CopyButton'
|
||||
|
||||
const { Text, Link } = Typography
|
||||
|
||||
const EmailDisplay = ({ email, showCopy = true, showLink = false }) => {
|
||||
if (!email) return <Text type='secondary'>n/a</Text>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex>
|
||||
{showLink ? (
|
||||
<Link href={`mailto:${email}`} style={{ marginRight: 8 }}>
|
||||
{email}
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Text style={{ marginRight: 8 }}>{email}</Text>
|
||||
<Tooltip title='Email' arrow={false}>
|
||||
<Button
|
||||
icon={<NewMailIcon style={{ fontSize: '14px' }} />}
|
||||
type='text'
|
||||
style={{ height: '22px' }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
window.location.href = `mailto:${email}`
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{showCopy && (
|
||||
<CopyButton
|
||||
text={email}
|
||||
tooltip='Copy Email'
|
||||
style={{ marginLeft: 0 }}
|
||||
iconStyle={{ fontSize: '14px' }}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
EmailDisplay.propTypes = {
|
||||
email: PropTypes.string,
|
||||
showCopy: PropTypes.bool,
|
||||
showLink: PropTypes.bool
|
||||
}
|
||||
|
||||
export default EmailDisplay
|
||||
7
src/components/Icons/EyeIcon.jsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react'
|
||||
import Icon from '@ant-design/icons'
|
||||
import { ReactComponent as CustomIconSvg } from '../../assets/icons/eyeicon.min.svg'
|
||||
|
||||
const EyeIcon = (props) => <Icon component={CustomIconSvg} {...props} />
|
||||
|
||||
export default EyeIcon
|
||||
7
src/components/Icons/EyeSlashIcon.jsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react'
|
||||
import Icon from '@ant-design/icons'
|
||||
import { ReactComponent as CustomIconSvg } from '../../assets/icons/eyeslashicon.min.svg'
|
||||
|
||||
const EyeSlashIcon = (props) => <Icon component={CustomIconSvg} {...props} />
|
||||
|
||||
export default EyeSlashIcon
|
||||
7
src/components/Icons/LinkIcon.jsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react'
|
||||
import Icon from '@ant-design/icons'
|
||||
import { ReactComponent as CustomIconSvg } from '../../assets/icons/linkicon.min.svg'
|
||||
|
||||
const LinkIcon = (props) => <Icon component={CustomIconSvg} {...props} />
|
||||
|
||||
export default LinkIcon
|
||||
7
src/components/Icons/NewMailIcon.jsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react'
|
||||
import Icon from '@ant-design/icons'
|
||||
import { ReactComponent as CustomIconSvg } from '../../assets/icons/newmailicon.min.svg'
|
||||
|
||||
const NewMailIcon = (props) => <Icon component={CustomIconSvg} {...props} />
|
||||
|
||||
export default NewMailIcon
|
||||
59
src/components/Icons/UrlDisplay.jsx
Normal file
@ -0,0 +1,59 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Typography, Flex, Button, Tooltip } from 'antd'
|
||||
import LinkIcon from './LinkIcon'
|
||||
import CopyButton from '../Dashboard/common/CopyButton'
|
||||
|
||||
const { Text, Link } = Typography
|
||||
|
||||
const UrlDisplay = ({ url, showCopy = true, showLink = false }) => {
|
||||
if (!url) return <Text type='secondary'>n/a</Text>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex>
|
||||
{showLink ? (
|
||||
<Link
|
||||
href={url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
style={{ marginRight: 8 }}
|
||||
>
|
||||
{url}
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Text style={{ marginRight: 8 }}>{url}</Text>
|
||||
<Tooltip title='Open URL' arrow={false}>
|
||||
<Button
|
||||
icon={<LinkIcon style={{ fontSize: '14px' }} />}
|
||||
type='text'
|
||||
style={{ height: '22px' }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{showCopy && (
|
||||
<CopyButton
|
||||
text={url}
|
||||
tooltip='Copy URL'
|
||||
style={{ marginLeft: 0 }}
|
||||
iconStyle={{ fontSize: '14px' }}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
UrlDisplay.propTypes = {
|
||||
url: PropTypes.string,
|
||||
showCopy: PropTypes.bool,
|
||||
showLink: PropTypes.bool
|
||||
}
|
||||
|
||||
export default UrlDisplay
|
||||
@ -11,6 +11,6 @@ root.render(
|
||||
)
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// to log results (for example: reportWebVitals(logger.debug))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals()
|
||||
|
||||