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.

This commit is contained in:
Tom Butcher 2025-06-29 22:38:11 +01:00
parent 0634919bb6
commit 9ccf7faa2f
87 changed files with 3109 additions and 4425 deletions

1
package-lock.json generated
View File

@ -18,6 +18,7 @@
"antd-style": "^3.7.1", "antd-style": "^3.7.1",
"axios": "^1.9.0", "axios": "^1.9.0",
"country-list": "^2.3.0", "country-list": "^2.3.0",
"dayjs": "^1.11.13",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.5",

View File

@ -13,6 +13,7 @@
"antd-style": "^3.7.1", "antd-style": "^3.7.1",
"axios": "^1.9.0", "axios": "^1.9.0",
"country-list": "^2.3.0", "country-list": "^2.3.0",
"dayjs": "^1.11.13",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.5",

View File

@ -110,7 +110,7 @@ code {
margin-bottom: 0.15em; margin-bottom: 0.15em;
} }
.idtext .ant-popover-inner { .iddisplay .ant-popover-inner {
padding: 0 !important; padding: 0 !important;
} }

Binary file not shown.

View 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

View 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

Binary file not shown.

View 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

View 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

Binary file not shown.

View 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

View 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

Binary file not shown.

View 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

View 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

View File

@ -29,9 +29,7 @@ const AppParticles = () => {
}) })
}, []) }, [])
const particlesLoaded = useCallback(() => { const particlesLoaded = useCallback(() => {}, [])
console.log('Particles Loaded!')
}, [])
const options = useMemo( const options = useMemo(
() => ({ () => ({

View File

@ -19,7 +19,7 @@ import { AuthContext } from '../context/AuthContext'
import { PrintServerContext } from '../context/PrintServerContext' import { PrintServerContext } from '../context/PrintServerContext'
import NewFilamentStock from './FilamentStocks/NewFilamentStock' import NewFilamentStock from './FilamentStocks/NewFilamentStock'
import IdText from '../common/IdText' import IdDisplay from '../common/IdDisplay'
import FilamentStockIcon from '../../Icons/FilamentStockIcon' import FilamentStockIcon from '../../Icons/FilamentStockIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import PlusIcon from '../../Icons/PlusIcon' import PlusIcon from '../../Icons/PlusIcon'
@ -124,7 +124,7 @@ const FilamentStocks = () => {
key: 'id', key: 'id',
width: 180, width: 180,
render: (text) => ( 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) { if (printServer && !initialized) {
setInitialized(true) setInitialized(true)
printServer.on('notify_filamentstock_update', (updateData) => { printServer.on('notify_filamentstock_update', (updateData) => {
console.log('Received filament stock update:', updateData)
if (tableRef.current) { if (tableRef.current) {
tableRef.current.updateData(updateData._id, updateData) tableRef.current.updateData(updateData._id, updateData)
} }
@ -225,7 +224,6 @@ const FilamentStocks = () => {
return () => { return () => {
if (printServer && initialized) { if (printServer && initialized) {
console.log('Deregistering filament stock update listener')
printServer.off('notify_filamentstock_update') printServer.off('notify_filamentstock_update')
} }
} }

View File

@ -18,7 +18,7 @@ import {
Checkbox Checkbox
} from 'antd' } from 'antd'
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons' import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import IdText from '../../common/IdText' import IdDisplay from '../../common/IdDisplay'
import { PrintServerContext } from '../../context/PrintServerContext' import { PrintServerContext } from '../../context/PrintServerContext'
import FilamentStockState from '../../common/FilamentStockState' import FilamentStockState from '../../common/FilamentStockState'
import StockEventTable from '../../common/StockEventTable' import StockEventTable from '../../common/StockEventTable'
@ -78,7 +78,6 @@ const FilamentStockInfo = () => {
if (printServer && !initialized && filamentStockId) { if (printServer && !initialized && filamentStockId) {
setInitialized(true) setInitialized(true)
printServer.on('notify_filamentstock_update', (statusUpdate) => { printServer.on('notify_filamentstock_update', (statusUpdate) => {
console.log('GOT FILAMENT STOCK UPDATE', statusUpdate)
setFilamentStockData((prevData) => { setFilamentStockData((prevData) => {
if (statusUpdate?._id === filamentStockId) { if (statusUpdate?._id === filamentStockId) {
return { return {
@ -92,7 +91,6 @@ const FilamentStockInfo = () => {
} }
return () => { return () => {
if (printServer && initialized) { if (printServer && initialized) {
console.log('Deregistering filament stock update listener')
printServer.off('notify_filamentstock_update') printServer.off('notify_filamentstock_update')
} }
} }
@ -260,7 +258,7 @@ const FilamentStockInfo = () => {
{/* Read-only fields */} {/* Read-only fields */}
<Descriptions.Item label='ID' span={1}> <Descriptions.Item label='ID' span={1}>
{filamentStockData?.id ? ( {filamentStockData?.id ? (
<IdText <IdDisplay
id={filamentStockData.id} id={filamentStockData.id}
type={'filamentstock'} type={'filamentstock'}
/> />
@ -316,7 +314,7 @@ const FilamentStockInfo = () => {
<Descriptions.Item label='Filament ID' span={1}> <Descriptions.Item label='Filament ID' span={1}>
{filamentStockData?.filament ? ( {filamentStockData?.filament ? (
<IdText <IdDisplay
id={filamentStockData.filament.id} id={filamentStockData.filament.id}
type={'filament'} type={'filament'}
showHyperlink={true} showHyperlink={true}

View File

@ -90,7 +90,7 @@ const LoadFilamentStock = ({
) )
) )
} }
console.log(statusUpdate) logger.debug(statusUpdate)
} }
printServer.emit('printer.objects.subscribe', params) printServer.emit('printer.objects.subscribe', params)

View File

@ -7,7 +7,7 @@ import { Button, Flex, Space, Modal, message, Dropdown, Typography } from 'antd'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import NewPartStock from './PartStocks/NewPartStock' import NewPartStock from './PartStocks/NewPartStock'
import IdText from '../common/IdText' import IdDisplay from '../common/IdDisplay'
import PartStockIcon from '../../Icons/PartStockIcon' import PartStockIcon from '../../Icons/PartStockIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import PlusIcon from '../../Icons/PlusIcon' import PlusIcon from '../../Icons/PlusIcon'
@ -69,7 +69,9 @@ const PartStocks = () => {
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 180, width: 180,
render: (text) => <IdText id={text} type={'partstock'} longId={false} /> render: (text) => (
<IdDisplay id={text} type={'partstock'} longId={false} />
)
}, },
{ {
title: 'State', title: 'State',

View File

@ -5,7 +5,7 @@ import { Button, Flex, Space, message, Dropdown, Typography } from 'antd'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { PrintServerContext } from '../context/PrintServerContext' import { PrintServerContext } from '../context/PrintServerContext'
import IdText from '../common/IdText' import IdDisplay from '../common/IdDisplay'
import StockAuditIcon from '../../Icons/StockAuditIcon' import StockAuditIcon from '../../Icons/StockAuditIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import PlusIcon from '../../Icons/PlusIcon' import PlusIcon from '../../Icons/PlusIcon'
@ -30,7 +30,6 @@ const StockAudits = () => {
if (printServer && !initialized) { if (printServer && !initialized) {
setInitialized(true) setInitialized(true)
printServer.on('notify_stockaudit_update', (updateData) => { printServer.on('notify_stockaudit_update', (updateData) => {
console.log('Received stock audit update:', updateData)
if (tableRef.current) { if (tableRef.current) {
tableRef.current.updateData(updateData._id, updateData) tableRef.current.updateData(updateData._id, updateData)
} }
@ -39,7 +38,6 @@ const StockAudits = () => {
return () => { return () => {
if (printServer && initialized) { if (printServer && initialized) {
console.log('Deregistering stock audit update listener')
printServer.off('notify_stockaudit_update') printServer.off('notify_stockaudit_update')
} }
} }
@ -76,7 +74,9 @@ const StockAudits = () => {
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 180, width: 180,
render: (text) => <IdText id={text} type={'stockaudit'} longId={false} /> render: (text) => (
<IdDisplay id={text} type={'stockaudit'} longId={false} />
)
}, },
{ {
title: 'Status', title: 'Status',

View File

@ -18,7 +18,7 @@ import {
} from '@ant-design/icons' } from '@ant-design/icons'
import { AuthContext } from '../../context/AuthContext' import { AuthContext } from '../../context/AuthContext'
import IdText from '../../common/IdText' import IdDisplay from '../../common/IdDisplay'
import TimeDisplay from '../../common/TimeDisplay' import TimeDisplay from '../../common/TimeDisplay'
import config from '../../../../config' import config from '../../../../config'
@ -105,7 +105,7 @@ const StockAuditInfo = () => {
key: 'id', key: 'id',
width: 180, width: 180,
render: (text) => ( 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> <Title level={4}>Stock Audit Details</Title>
<Descriptions bordered> <Descriptions bordered>
<Descriptions.Item label='ID'> <Descriptions.Item label='ID'>
<IdText id={stockAudit._id} type={'stockaudit'} longId={true} /> <IdDisplay
id={stockAudit._id}
type={'stockaudit'}
longId={true}
/>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Status'> <Descriptions.Item label='Status'>
{getStatusTag(stockAudit.status)} {getStatusTag(stockAudit.status)}

View File

@ -12,7 +12,7 @@ import {
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { PrintServerContext } from '../context/PrintServerContext' import { PrintServerContext } from '../context/PrintServerContext'
import IdText from '../common/IdText' import IdDisplay from '../common/IdDisplay'
import TimeDisplay from '../common/TimeDisplay' import TimeDisplay from '../common/TimeDisplay'
import ReloadIcon from '../../Icons/ReloadIcon' import ReloadIcon from '../../Icons/ReloadIcon'
import PlusMinusIcon from '../../Icons/PlusMinusIcon' import PlusMinusIcon from '../../Icons/PlusMinusIcon'
@ -75,7 +75,7 @@ const StockEvents = () => {
dataIndex: '_id', dataIndex: '_id',
width: 170, width: 170,
render: (id) => { 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) => { render: (record) => {
if (record.filamentStock?._id) { if (record.filamentStock?._id) {
return ( return (
<IdText <IdDisplay
id={record.filamentStock._id} id={record.filamentStock._id}
longId={false} longId={false}
showHyperlink={true} showHyperlink={true}
@ -119,7 +119,7 @@ const StockEvents = () => {
const ids = ( const ids = (
<Flex gap={'small'} wrap> <Flex gap={'small'} wrap>
{record.job ? ( {record.job ? (
<IdText <IdDisplay
id={record.job} id={record.job}
longId={false} longId={false}
showHyperlink={true} showHyperlink={true}
@ -127,14 +127,14 @@ const StockEvents = () => {
/> />
) : null} ) : null}
{record.subJob?.number ? ( {record.subJob?.number ? (
<IdText <IdDisplay
id={record.subJob.number.toString().padStart(6, '0')} id={record.subJob.number.toString().padStart(6, '0')}
longId={false} longId={false}
type={'subjob'} type={'subjob'}
/> />
) : null} ) : null}
{record.stockAudit ? ( {record.stockAudit ? (
<IdText <IdDisplay
id={record.stockAudit._id} id={record.stockAudit._id}
longId={false} longId={false}
type={'stockaudit'} type={'stockaudit'}
@ -228,7 +228,6 @@ const StockEvents = () => {
if (printServer && !initialized) { if (printServer && !initialized) {
setInitialized(true) setInitialized(true)
printServer.on('notify_stockevent_update', (updateData) => { printServer.on('notify_stockevent_update', (updateData) => {
console.log('Received stock event update:', updateData)
if (tableRef.current) { if (tableRef.current) {
tableRef.current.updateData(updateData._id, updateData) tableRef.current.updateData(updateData._id, updateData)
} }
@ -237,7 +236,6 @@ const StockEvents = () => {
return () => { return () => {
if (printServer && initialized) { if (printServer && initialized) {
console.log('Deregistering stock event update listener')
printServer.off('notify_stockevent_update') printServer.off('notify_stockevent_update')
} }
} }

View File

@ -13,7 +13,7 @@ import {
} from 'antd' } from 'antd'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText' import IdDisplay from '../common/IdDisplay'
import ReloadIcon from '../../Icons/ReloadIcon' import ReloadIcon from '../../Icons/ReloadIcon'
import useColumnVisibility from '../hooks/useColumnVisibility' import useColumnVisibility from '../hooks/useColumnVisibility'
import TimeDisplay from '../common/TimeDisplay' import TimeDisplay from '../common/TimeDisplay'
@ -66,7 +66,7 @@ const formatValue = (value, propertyName) => {
if (isObjectId(value)) { if (isObjectId(value)) {
return ( return (
<IdText <IdDisplay
id={value} id={value}
type={propertyName.toLowerCase().replaceAll('current', '')} type={propertyName.toLowerCase().replaceAll('current', '')}
longId={false} longId={false}
@ -101,7 +101,9 @@ const AuditLogs = () => {
key: 'id', key: 'id',
fixed: 'left', fixed: 'left',
width: 180, width: 180,
render: (text) => <IdText id={text} type={'auditlog'} longId={false} />, render: (text) => (
<IdDisplay id={text} type={'auditlog'} longId={false} />
),
filterDropdown: ({ filterDropdown: ({
setSelectedKeys, setSelectedKeys,
selectedKeys, selectedKeys,
@ -147,7 +149,7 @@ const AuditLogs = () => {
key: 'owner', key: 'owner',
width: 180, width: 180,
render: (record) => ( render: (record) => (
<IdText <IdDisplay
id={record.owner._id} id={record.owner._id}
type={record.ownerModel.toLowerCase()} type={record.ownerModel.toLowerCase()}
longId={false} longId={false}
@ -160,7 +162,7 @@ const AuditLogs = () => {
key: 'target', key: 'target',
width: 180, width: 180,
render: (record) => ( render: (record) => (
<IdText <IdDisplay
id={record.target} id={record.target}
type={record.targetModel.toLowerCase()} type={record.targetModel.toLowerCase()}
longId={false} longId={false}

View File

@ -23,7 +23,7 @@ import { LoadingOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import NewFilament from './Filaments/NewFilament' import NewFilament from './Filaments/NewFilament'
import IdText from '../common/IdText' import IdDisplay from '../common/IdDisplay'
import FilamentIcon from '../../Icons/FilamentIcon' import FilamentIcon from '../../Icons/FilamentIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import PlusIcon from '../../Icons/PlusIcon' import PlusIcon from '../../Icons/PlusIcon'
@ -283,7 +283,9 @@ const Filaments = () => {
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 180, width: 180,
render: (text) => <IdText id={text} type={'filament'} longId={false} />, render: (text) => (
<IdDisplay id={text} type={'filament'} longId={false} />
),
filterDropdown: ({ filterDropdown: ({
setSelectedKeys, setSelectedKeys,
selectedKeys, selectedKeys,

View File

@ -1,62 +1,30 @@
import React, { useState, useEffect, useContext, useCallback } from 'react' import React from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { import { Space, Button, Flex, Dropdown, Card } from 'antd'
Descriptions,
Spin,
Space,
Button,
message,
Badge,
Typography,
Flex,
Form,
Input,
InputNumber,
ColorPicker,
Select,
Dropdown,
Popover,
Checkbox,
Card,
Tag
} from 'antd'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import loglevel from 'loglevel' import loglevel from 'loglevel'
import config from '../../../../config' import config from '../../../../config'
import IdText from '../../common/IdText'
import ReloadIcon from '../../../Icons/ReloadIcon' 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 useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable' import AuditLogTable from '../../common/AuditLogTable'
import DashboardNotes from '../../common/DashboardNotes' import DashboardNotes from '../../common/DashboardNotes'
import InfoCollapse from '../../common/InfoCollapse' import InfoCollapse from '../../common/InfoCollapse'
import ObjectInfo from '../../common/ObjectInfo'
import ViewButton from '../../common/ViewButton'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx' import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx' import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import LockIcon from '../../../Icons/LockIcon.jsx' import EditObjectForm from '../../common/EditObjectForm'
import { ApiServerContext } from '../../context/ApiServerContext' import EditButtons from '../../common/EditButtons'
import LockIndicator from './LockIndicator'
const log = loglevel.getLogger('FilamentInfo') const log = loglevel.getLogger('FilamentInfo')
log.setLevel(config.logLevel) log.setLevel(config.logLevel)
const { Link, Text } = Typography
const FilamentInfo = () => { 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 location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const filamentId = new URLSearchParams(location.search).get('filamentId') const filamentId = new URLSearchParams(location.search).get('filamentId')
const [isEditing, setIsEditing] = useState(false)
const [form] = Form.useForm()
const [collapseState, updateCollapseState] = useCollapseState( const [collapseState, updateCollapseState] = useCollapseState(
'FilamentInfo', 'FilamentInfo',
{ {
@ -66,600 +34,217 @@ const FilamentInfo = () => {
auditLogs: true 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 ( return (
<> <EditObjectForm id={filamentId} type='filament' style={{ height: '100%' }}>
{contextHolder} {({
<Flex loading,
gap='large' isEditing,
vertical='true' startEditing,
style={{ height: '100%', minHeight: 0 }} cancelEditing,
> handleUpdate,
<Flex justify={'space-between'}> formValid,
<Space size='middle'> objectData,
<Space size='small'> editLoading,
<Dropdown menu={actionItems}> lock,
<Button disabled={fetchLoading}>Actions</Button> fetchObject
</Dropdown> }) => (
<Popover <Flex
content={getViewDropdownItems()} gap='large'
placement='bottomLeft' vertical='true'
arrow={false} style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
> >
<Button disabled={fetchLoading}>View</Button> <Flex justify={'space-between'}>
</Popover> <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> </Space>
{lockUser && ( <Space>
<Flex gap={'small'} align='center'> <EditButtons
<Tag isEditing={isEditing}
icon={<LockIcon />} handleUpdate={handleUpdate}
style={{ margin: 0 }} cancelEditing={cancelEditing}
color={'orange'} startEditing={startEditing}
/> editLoading={editLoading}
<IdText formValid={formValid}
id={lockUser} disabled={lock?.locked || loading}
type={'user'} loading={editLoading}
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>
</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>
</Flex> </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>
) )
} }

View File

@ -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

View File

@ -157,7 +157,6 @@ const NewFilament = ({ onOk, reset }) => {
} }
const handleImageUpload = async ({ file, fileList }) => { const handleImageUpload = async ({ file, fileList }) => {
console.log(fileList)
if (fileList.length === 0) { if (fileList.length === 0) {
setImageList(fileList) setImageList(fileList)
newFilamentForm.setFieldsValue({ image: '' }) newFilamentForm.setFieldsValue({ image: '' })

View File

@ -19,7 +19,7 @@ import { LoadingOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import NewMaterial from './Materials/NewMaterial' import NewMaterial from './Materials/NewMaterial'
import IdText from '../common/IdText' import IdDisplay from '../common/IdDisplay'
import MaterialIcon from '../../Icons/MaterialIcon' import MaterialIcon from '../../Icons/MaterialIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import PlusIcon from '../../Icons/PlusIcon' import PlusIcon from '../../Icons/PlusIcon'
@ -169,7 +169,7 @@ const Materials = () => {
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 180, width: 180,
render: (text) => <IdText id={text} type={'material'} longId={false} /> render: (text) => <IdDisplay id={text} type={'material'} longId={false} />
}, },
{ {
title: 'Category', title: 'Category',

View File

@ -14,7 +14,7 @@ import {
Typography Typography
} from 'antd' } from 'antd'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText' import IdDisplay from '../common/IdDisplay'
import NewNoteType from './NoteTypes/NewNoteType' import NewNoteType from './NoteTypes/NewNoteType'
import TimeDisplay from '../common/TimeDisplay' import TimeDisplay from '../common/TimeDisplay'
import DashboardTable from '../common/DashboardTable' import DashboardTable from '../common/DashboardTable'
@ -155,7 +155,9 @@ const NoteTypes = () => {
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 180, width: 180,
render: (text) => <IdText id={text} type={'notetype'} longId={false} />, render: (text) => (
<IdDisplay id={text} type={'notetype'} longId={false} />
),
filterDropdown: ({ filterDropdown: ({
setSelectedKeys, setSelectedKeys,
selectedKeys, selectedKeys,

View File

@ -1,51 +1,22 @@
import React, { useState, useEffect } from 'react' import React from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import axios from 'axios' import { Space, Button, Flex, Dropdown } from 'antd'
import { import { LoadingOutlined } from '@ant-design/icons'
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 ReloadIcon from '../../../Icons/ReloadIcon' 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 useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable' import AuditLogTable from '../../common/AuditLogTable'
import InfoCollapse from '../../common/InfoCollapse'
import config from '../../../../config.js' import ObjectInfo from '../../common/ObjectInfo'
import BoolDisplay from '../../common/BoolDisplay.jsx' import ViewButton from '../../common/ViewButton'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx' import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import EditObjectForm from '../../common/EditObjectForm'
const { Title, Text } = Typography import EditButtons from '../../common/EditButtons'
import LockIndicator from '../Filaments/LockIndicator'
const NoteTypeInfo = () => { const NoteTypeInfo = () => {
const [noteTypeData, setNoteTypeData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const location = useLocation() const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const noteTypeId = new URLSearchParams(location.search).get('noteTypeId') 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( const [collapseState, updateCollapseState] = useCollapseState(
'NoteTypeInfo', '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 ( return (
<> <EditObjectForm
<Flex justify='space-between'> id={noteTypeId}
<Space size='small'> type='notetype'
<Dropdown menu={actionItems}> style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
<Button>Actions</Button> >
</Dropdown> {({
<Popover loading,
content={getViewDropdownItems()} isEditing,
placement='bottomLeft' startEditing,
arrow={false} cancelEditing,
> handleUpdate,
<Button>View</Button> formValid,
</Popover> objectData,
</Space> editLoading,
<Space size={'small'}> lock,
{isEditing ? ( fetchObject
<> }) => (
<Button <Flex
icon={<CheckIcon />} gap='large'
type='primary' vertical='true'
onClick={updateInfo} style={{ height: '100%', minHeight: 0 }}
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' }}
> >
<p>{error || 'Note type not found'}</p> <Flex justify={'space-between'}>
<Button icon={<ReloadIcon />} onClick={fetchNoteTypeDetails}> <Space size='middle'>
Retry <Space size='small'>
</Button> <Dropdown
</Space> menu={{
) : ( items: [
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}> {
{contextHolder} label: 'Reload Note Type',
<Flex vertical gap={'large'}> key: 'reload',
<Collapse icon: <ReloadIcon />
ghost }
expandIconPosition='end' ],
activeKey={collapseState.info ? ['1'] : []} onClick: ({ key }) => {
onChange={(keys) => updateCollapseState('info', keys.length > 0)} if (key === 'reload') {
expandIcon={({ isActive }) => ( fetchObject()
<CaretLeftOutlined }
rotate={isActive ? 90 : 0} }
style={{ paddingTop: '2px' }} }}
>
<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}
/> />
)} </Space>
className='no-h-padding-collapse no-t-padding-collapse' <LockIndicator lock={lock} />
> </Space>
<Collapse.Panel <Space>
header={ <EditButtons
<Flex align='center' gap={'small'}> isEditing={isEditing}
<InfoCircleIcon /> handleUpdate={handleUpdate}
<Title level={5} style={{ margin: 0 }}> cancelEditing={cancelEditing}
Note Type Information startEditing={startEditing}
</Title> editLoading={editLoading}
</Flex> formValid={formValid}
} disabled={lock?.locked || loading}
key='1' 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 />}> <ObjectInfo
<Form form={form} layout='vertical'> loading={loading}
<Descriptions indicator={<LoadingOutlined />}
bordered isEditing={isEditing}
column={{ type='notetype'
xs: 1, items={[
sm: 1, {
md: 1, name: 'id',
lg: 2, label: 'ID',
xl: 2, value: objectData?._id,
xxl: 2 type: 'id',
}} objectType: 'notetype',
> showCopy: true
<Descriptions.Item label='ID'> },
<IdText id={noteTypeData?._id} type='notetype' /> {
</Descriptions.Item> name: 'createdAt',
<Descriptions.Item label='Created At'> label: 'Created At',
<TimeDisplay value: objectData?.createdAt,
dateTime={noteTypeData?.createdAt} type: 'dateTime',
showSince={true} readOnly: true
/> },
</Descriptions.Item> {
name: 'name',
<Descriptions.Item label='Name'> label: 'Name',
{isEditing ? ( value: objectData?.name,
<Form.Item required: true,
name='name' type: 'text'
rules={[ },
{ {
required: true, name: 'updatedAt',
message: 'Please enter a note type name' label: 'Updated At',
}, value: objectData?.updatedAt,
{ type: 'dateTime',
max: 100, readOnly: true
message: 'Name cannot exceed 100 characters' },
} {
]} name: 'color',
style={{ margin: 0 }} label: 'Color',
> value: objectData?.color,
<Input /> type: 'color'
</Form.Item> },
) : ( {
noteTypeData?.name name: 'active',
)} label: 'Active',
</Descriptions.Item> value: objectData?.active,
<Descriptions.Item label='Updated At'> type: 'bool'
<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' }}
/> />
)} </InfoCollapse>
className='no-h-padding-collapse'
> <InfoCollapse
<Collapse.Panel title='Audit Logs'
header={ icon={<AuditLogIcon />}
<Flex align='center' gap={'small'}> active={collapseState.auditLogs}
<AuditLogIcon /> onToggle={(expanded) =>
<Title level={5} style={{ margin: 0 }}> updateCollapseState('auditLogs', expanded)
Audit Logs
</Title>
</Flex>
} }
key='2' key='auditLogs'
> >
<AuditLogTable <AuditLogTable
items={noteTypeData?.auditLogs || []} items={objectData?.auditLogs || []}
loading={loading} loading={loading}
showTargetColumn={false} showTargetColumn={false}
/> />
</Collapse.Panel> </InfoCollapse>
</Collapse> </Flex>
</Flex> </div>
</div> </Flex>
)} )}
</> </EditObjectForm>
) )
} }

View File

@ -17,7 +17,7 @@ import {
import { DownloadOutlined } from '@ant-design/icons' import { DownloadOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText' import IdDisplay from '../common/IdDisplay'
import DashboardTable from '../common/DashboardTable' import DashboardTable from '../common/DashboardTable'
import NewProduct from './Products/NewProduct' import NewProduct from './Products/NewProduct'
import PartIcon from '../../Icons/PartIcon' import PartIcon from '../../Icons/PartIcon'
@ -81,7 +81,7 @@ const Parts = () => {
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 180, width: 180,
render: (text) => <IdText id={text} type={'part'} longId={false} /> render: (text) => <IdDisplay id={text} type={'part'} longId={false} />
}, },
{ {
title: 'Product Name', title: 'Product Name',
@ -109,7 +109,7 @@ const Parts = () => {
key: 'productId', key: 'productId',
width: 180, width: 180,
render: (record) => ( render: (record) => (
<IdText <IdDisplay
id={record?.product?._id} id={record?.product?._id}
type={'product'} type={'product'}
longId={false} longId={false}

View File

@ -21,7 +21,7 @@ import {
Popover Popover
} from 'antd' } from 'antd'
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons' 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 { StlViewer } from 'react-stl-viewer'
import ReloadIcon from '../../../Icons/ReloadIcon' import ReloadIcon from '../../../Icons/ReloadIcon'
import EditIcon from '../../../Icons/EditIcon.jsx' import EditIcon from '../../../Icons/EditIcon.jsx'
@ -38,6 +38,9 @@ import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import PartIcon from '../../../Icons/PartIcon.jsx' import PartIcon from '../../../Icons/PartIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx' import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import loglevel from 'loglevel'
const logger = loglevel.getLogger('PartInfo')
logger.setLevel(config.logLevel)
const { Title, Text } = Typography const { Title, Text } = Typography
@ -116,7 +119,7 @@ const PartInfo = () => {
setError(null) setError(null)
} catch (err) { } catch (err) {
setError('Failed to fetch part details') setError('Failed to fetch part details')
console.log(err) logger.debug(err)
messageApi.error('Failed to fetch part details') messageApi.error('Failed to fetch part details')
} finally { } finally {
setFetchLoading(false) setFetchLoading(false)
@ -176,7 +179,7 @@ const PartInfo = () => {
} }
} catch (err) { } catch (err) {
setError('Failed to fetch part content') setError('Failed to fetch part content')
console.log(err) logger.debug(err)
messageApi.error('Failed to fetch part content') messageApi.error('Failed to fetch part content')
} finally { } finally {
setFetchLoading(false) setFetchLoading(false)
@ -398,7 +401,7 @@ const PartInfo = () => {
> >
<Descriptions.Item label='ID' span={1}> <Descriptions.Item label='ID' span={1}>
{partData?.id ? ( {partData?.id ? (
<IdText id={partData.id} type='part'></IdText> <IdDisplay id={partData.id} type='part'></IdDisplay>
) : ( ) : (
<Text>n/a</Text> <Text>n/a</Text>
)} )}
@ -459,7 +462,7 @@ const PartInfo = () => {
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Product ID' span={1}> <Descriptions.Item label='Product ID' span={1}>
{partData?.product?._id ? ( {partData?.product?._id ? (
<IdText <IdDisplay
id={partData.product._id} id={partData.product._id}
type={'product'} type={'product'}
showHyperlink={true} showHyperlink={true}

View File

@ -17,7 +17,7 @@ import {
import { DownloadOutlined } from '@ant-design/icons' import { DownloadOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText' import IdDisplay from '../common/IdDisplay'
import TimeDisplay from '../common/TimeDisplay' import TimeDisplay from '../common/TimeDisplay'
import DashboardTable from '../common/DashboardTable' import DashboardTable from '../common/DashboardTable'
import NewProduct from './Products/NewProduct' import NewProduct from './Products/NewProduct'
@ -102,7 +102,7 @@ const Products = () => {
key: 'id', key: 'id',
fixed: 'left', fixed: 'left',
width: 180, width: 180,
render: (text) => <IdText id={text} type={'product'} longId={false} />, render: (text) => <IdDisplay id={text} type={'product'} longId={false} />,
filterDropdown: ({ filterDropdown: ({
setSelectedKeys, setSelectedKeys,
selectedKeys, selectedKeys,

View File

@ -1,56 +1,25 @@
import React, { useState, useEffect } from 'react' import React from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import axios from 'axios' import { Space, Button, Flex, Dropdown, Card } from 'antd'
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 ReloadIcon from '../../../Icons/ReloadIcon' import ReloadIcon from '../../../Icons/ReloadIcon'
import EditIcon from '../../../Icons/EditIcon.jsx' import useCollapseState from '../../hooks/useCollapseState'
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
import CheckIcon from '../../../Icons/CheckIcon.jsx'
import TimeDisplay from '../../common/TimeDisplay.jsx'
import AuditLogTable from '../../common/AuditLogTable' import AuditLogTable from '../../common/AuditLogTable'
import DashboardNotes from '../../common/DashboardNotes' import DashboardNotes from '../../common/DashboardNotes'
import InfoCollapse from '../../common/InfoCollapse'
import config from '../../../../config.js' 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 InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import ProductIcon from '../../../Icons/ProductIcon.jsx' import ProductIcon from '../../../Icons/ProductIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
const { Title, Text } = Typography
const ProductInfo = () => { const ProductInfo = () => {
const [productData, setProductData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const location = useLocation() const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const productId = new URLSearchParams(location.search).get('productId') 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', { const [collapseState, updateCollapseState] = useCollapseState('ProductInfo', {
info: true, info: true,
parts: true, parts: true,
@ -58,600 +27,209 @@ const ProductInfo = () => {
auditLogs: true 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 ( return (
<> <EditObjectForm
{contextHolder} id={productId}
<Flex type='product'
gap='large' style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
vertical='true' >
style={{ height: '100%', minHeight: 0 }} {({
> loading,
<Flex justify={'space-between'}> isEditing,
<Space size='small'> startEditing,
<Dropdown menu={actionItems}> cancelEditing,
<Button>Actions</Button> handleUpdate,
</Dropdown> formValid,
<Popover objectData,
content={getViewDropdownItems()} editLoading,
placement='bottomLeft' lock,
arrow={false} fetchObject
> }) => (
<Button>View</Button> <Flex
</Popover> gap='large'
</Space> vertical='true'
<Space> style={{ height: '100%', minHeight: 0 }}
{isEditing ? ( >
<> <Flex justify={'space-between'}>
<Button <Space size='middle'>
icon={<CheckIcon />} <Space size='small'>
type='primary' <Dropdown
onClick={updateInfo} menu={{
items: [
{
label: 'Reload Product',
key: 'reload',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchObject()
}
}
}}
>
<Button disabled={loading}>Actions</Button>
</Dropdown>
<ViewButton
loading={loading} 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 </Space>
icon={<XMarkIcon />} <LockIndicator lock={lock} />
onClick={cancelEditing} </Space>
disabled={loading} <Space>
/> <EditButtons
</> isEditing={isEditing}
) : ( handleUpdate={handleUpdate}
<Button icon={<EditIcon />} onClick={startEditing} /> cancelEditing={cancelEditing}
)} startEditing={startEditing}
</Space> editLoading={editLoading}
</Flex> 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' }}> <div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}> <Flex vertical gap={'large'}>
<Collapse <InfoCollapse
ghost title='Product Information'
expandIconPosition='end' icon={<InfoCircleIcon />}
activeKey={collapseState.info ? ['1'] : []} active={collapseState.info}
onChange={(keys) => onToggle={(expanded) => updateCollapseState('info', expanded)}
updateCollapseState('info', keys.length > 0) key='info'
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse no-t-padding-collapse'
> >
<Collapse.Panel <ObjectInfo
header={ loading={loading}
<Flex align='center' gap={'middle'}> isEditing={isEditing}
<InfoCircleIcon /> indicator={null}
<Title level={5} style={{ margin: 0 }}> type='product'
Product Information items={[
</Title> {
</Flex> name: '_id',
} label: 'ID',
key='1' value: objectData?._id,
> type: 'id',
<Form objectType: 'product',
form={productForm} showCopy: true,
layout='vertical' readOnly: true
onValuesChange={(changedValues) => },
setProductFormValues((prevValues) => ({ {
...prevValues, name: 'createdAt',
...changedValues 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: '' }, </InfoCollapse>
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>
<Descriptions.Item label='Created At'> <InfoCollapse
{productData?.createdAt ? ( title='Product Parts'
<TimeDisplay icon={<ProductIcon />}
dateTime={productData.createdAt} active={collapseState.parts}
showSince={true} onToggle={(expanded) => updateCollapseState('parts', expanded)}
/> key='parts'
) : (
<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'
> >
<Collapse.Panel <PartsTable data={objectData?.parts || []} />
header={ </InfoCollapse>
<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>
<Collapse <InfoCollapse
ghost title='Notes'
expandIconPosition='end' icon={<NoteIcon />}
activeKey={collapseState.notes ? ['notes'] : []} active={collapseState.notes}
onChange={(keys) => onToggle={(expanded) => updateCollapseState('notes', expanded)}
updateCollapseState('notes', keys.length > 0) key='notes'
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
> >
<Collapse.Panel <Card>
header={ <DashboardNotes _id={productId} />
<Flex align='center' gap={'middle'}> </Card>
<NoteIcon /> </InfoCollapse>
<Title level={5} style={{ margin: 0 }}>
Notes
</Title>
</Flex>
}
key='notes'
>
<Card>
<DashboardNotes _id={productId} />
</Card>
</Collapse.Panel>
</Collapse>
<Collapse <InfoCollapse
ghost title='Audit Logs'
expandIconPosition='end' icon={<AuditLogIcon />}
activeKey={collapseState.auditLogs ? ['auditLogs'] : []} active={collapseState.auditLogs}
onChange={(keys) => onToggle={(expanded) =>
updateCollapseState('auditLogs', keys.length > 0) updateCollapseState('auditLogs', expanded)
} }
expandIcon={({ isActive }) => ( key='auditLogs'
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
> >
<Collapse.Panel <AuditLogTable
header={ items={objectData?.auditLogs || []}
<Flex align='center' gap={'middle'}> loading={loading}
<AuditLogIcon /> showTargetColumn={false}
<Title level={5} style={{ margin: 0 }}> />
Audit Logs </InfoCollapse>
</Title>
</Flex>
}
key='auditLogs'
>
<AuditLogTable
items={productData?.auditLogs || []}
loading={fetchLoading}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</Flex> </Flex>
</div> </div>
)} </Flex>
</Flex> )}
</> </EditObjectForm>
) )
} }

View File

@ -12,7 +12,7 @@ import {
} from 'antd' } from 'antd'
import { ExportOutlined } from '@ant-design/icons' import { ExportOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText' import IdDisplay from '../common/IdDisplay'
import TimeDisplay from '../common/TimeDisplay' import TimeDisplay from '../common/TimeDisplay'
import DashboardTable from '../common/DashboardTable' import DashboardTable from '../common/DashboardTable'
import PersonIcon from '../../Icons/PersonIcon' import PersonIcon from '../../Icons/PersonIcon'
@ -248,7 +248,7 @@ const Users = () => {
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 180, width: 180,
render: (text) => <IdText id={text} type={'user'} longId={false} />, render: (text) => <IdDisplay id={text} type={'user'} longId={false} />,
filterDropdown: ({ filterDropdown: ({
setSelectedKeys, setSelectedKeys,
selectedKeys, selectedKeys,

View File

@ -1,509 +1,207 @@
import React, { useState, useEffect } from 'react' import React from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import axios from 'axios' import { Space, Button, Flex, Dropdown, Card } from 'antd'
import { import { LoadingOutlined } from '@ant-design/icons'
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 ReloadIcon from '../../../Icons/ReloadIcon' 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 useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable' import AuditLogTable from '../../common/AuditLogTable'
import DashboardNotes from '../../common/DashboardNotes' 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 InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx' import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import EditObjectForm from '../../common/EditObjectForm'
import config from '../../../../config.js' import EditButtons from '../../common/EditButtons'
import LockIndicator from '../Filaments/LockIndicator'
const { Title, Link, Text } = Typography
const UserInfo = () => { const UserInfo = () => {
const [userData, setUserData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const location = useLocation() const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const userId = new URLSearchParams(location.search).get('userId') 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', { const [collapseState, updateCollapseState] = useCollapseState('UserInfo', {
info: true, info: true,
notes: true, notes: true,
auditLogs: 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 ( return (
<> <EditObjectForm
{contextHolder} id={userId}
<Flex type='user'
gap='large' style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
vertical='true' >
style={{ height: '100%', minHeight: 0 }} {({
> loading,
<Flex justify={'space-between'}> isEditing,
<Space size='small'> startEditing,
<Dropdown menu={actionItems}> cancelEditing,
<Button>Actions</Button> handleUpdate,
</Dropdown> formValid,
<Popover objectData,
content={getViewDropdownItems()} editLoading,
placement='bottomLeft' lock,
arrow={false} fetchObject
> }) => (
<Button>View</Button> <Flex
</Popover> gap='large'
</Space> vertical='true'
<Space> style={{ height: '100%', minHeight: 0 }}
{isEditing ? ( >
<> <Flex justify={'space-between'}>
<Button <Space size='middle'>
icon={<CheckIcon />} <Space size='small'>
type='primary' <Dropdown
onClick={updateInfo} menu={{
items: [
{
label: 'Reload User',
key: 'reload',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchObject()
}
}
}}
>
<Button disabled={loading}>Actions</Button>
</Dropdown>
<ViewButton
loading={loading} loading={loading}
disabled={loading} sections={[
{ key: 'info', label: 'User Information' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
collapseState={collapseState}
updateCollapseState={updateCollapseState}
/> />
<Button </Space>
icon={<XMarkIcon />} <LockIndicator lock={lock} />
onClick={cancelEditing} </Space>
disabled={loading} <Space>
/> <EditButtons
</> isEditing={isEditing}
) : ( handleUpdate={handleUpdate}
<Button icon={<EditIcon />} onClick={startEditing} /> cancelEditing={cancelEditing}
)} startEditing={startEditing}
</Space> editLoading={editLoading}
</Flex> 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' }}> <div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}> <Flex vertical gap={'large'}>
<Collapse <InfoCollapse
ghost title='User Information'
expandIconPosition='end' icon={<InfoCircleIcon />}
activeKey={collapseState.info ? ['1'] : []} active={collapseState.info}
onChange={(keys) => onToggle={(expanded) => updateCollapseState('info', expanded)}
updateCollapseState('info', keys.length > 0) key='info'
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined
rotate={isActive ? -90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
> >
<Collapse.Panel <ObjectInfo
header={ loading={loading}
<Flex align='center' gap={'middle'}> indicator={<LoadingOutlined />}
<InfoCircleIcon /> isEditing={isEditing}
<Title level={5} style={{ margin: 0 }}> type='user'
User Information items={[
</Title> {
</Flex> name: '_id',
} label: 'ID',
key='1' value: objectData?._id,
> type: 'id',
<Form form={form} layout='vertical'> objectType: 'user',
<Spin showCopy: true
indicator={<LoadingOutlined />} },
spinning={fetchLoading} {
> name: 'createdAt',
<Descriptions label: 'Created At',
bordered value: objectData?.createdAt,
column={{ type: 'dateTime',
xs: 1, readOnly: true
sm: 1, },
md: 1, {
lg: 2, name: 'name',
xl: 2, label: 'Name',
xxl: 2 value: objectData?.name,
}} required: true,
> type: 'text'
<Descriptions.Item label='ID'> },
{userData?._id ? ( {
<IdText id={userData._id} type='user' /> name: 'updatedAt',
) : ( label: 'Updated At',
<Text>n/a</Text> value: objectData?.updatedAt,
)} type: 'dateTime',
</Descriptions.Item> readOnly: true
},
<Descriptions.Item label='Created At'> {
{userData?.createdAt ? ( name: 'firstName',
<TimeDisplay label: 'First Name',
dateTime={userData.createdAt} value: objectData?.firstName,
showSince={true} type: 'text'
/> },
) : ( {
<Text>n/a</Text> name: 'username',
)} label: 'Username',
</Descriptions.Item> 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'> <InfoCollapse
{isEditing ? ( title='Notes'
<Form.Item icon={<NoteIcon />}
name='name' active={collapseState.notes}
rules={[ onToggle={(expanded) => updateCollapseState('notes', expanded)}
{ key='notes'
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'
> >
<Collapse.Panel <Card>
header={ <DashboardNotes _id={userId} />
<Flex align='center' gap={'middle'}> </Card>
<NoteIcon /> </InfoCollapse>
<Title level={5} style={{ margin: 0 }}>
Notes
</Title>
</Flex>
}
key='notes'
>
<Card>
<DashboardNotes _id={userId} />
</Card>
</Collapse.Panel>
</Collapse>
<Collapse <InfoCollapse
ghost title='Audit Logs'
expandIconPosition='end' icon={<AuditLogIcon />}
activeKey={collapseState.auditLogs ? ['auditLogs'] : []} active={collapseState.auditLogs}
onChange={(keys) => onToggle={(expanded) =>
updateCollapseState('auditLogs', keys.length > 0) updateCollapseState('auditLogs', expanded)
} }
expandIcon={({ isActive }) => ( key='auditLogs'
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
> >
<Collapse.Panel <AuditLogTable
header={ items={objectData?.auditLogs || []}
<Flex align='center' gap={'middle'}> loading={loading}
<AuditLogIcon /> showTargetColumn={false}
<Title level={5} style={{ margin: 0 }}> />
Audit Logs </InfoCollapse>
</Title>
</Flex>
}
key='auditLogs'
>
<AuditLogTable
items={userData?.auditLogs || []}
loading={fetchLoading}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</Flex> </Flex>
</div> </div>
)} </Flex>
</Flex> )}
</> </EditObjectForm>
) )
} }

View File

@ -14,7 +14,7 @@ import {
} from 'antd' } from 'antd'
import { ExportOutlined } from '@ant-design/icons' import { ExportOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText' import IdDisplay from '../common/IdDisplay'
import NewVendor from './Vendors/NewVendor' import NewVendor from './Vendors/NewVendor'
import CountryDisplay from '../common/CountryDisplay' import CountryDisplay from '../common/CountryDisplay'
import TimeDisplay from '../common/TimeDisplay' import TimeDisplay from '../common/TimeDisplay'
@ -155,7 +155,7 @@ const Vendors = () => {
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 180, width: 180,
render: (text) => <IdText id={text} type={'vendor'} longId={false} />, render: (text) => <IdDisplay id={text} type={'vendor'} longId={false} />,
filterDropdown: ({ filterDropdown: ({
setSelectedKeys, setSelectedKeys,
selectedKeys, selectedKeys,

View File

@ -1,539 +1,216 @@
import React, { useState, useEffect, useCallback } from 'react' import React from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import axios from 'axios' import { Space, Button, Flex, Dropdown, Card } from 'antd'
import { import { LoadingOutlined } from '@ant-design/icons'
Descriptions, import loglevel from 'loglevel'
Spin, import config from '../../../../config'
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 ReloadIcon from '../../../Icons/ReloadIcon' 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 useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable' import AuditLogTable from '../../common/AuditLogTable'
import DashboardNotes from '../../common/DashboardNotes' 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 InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx' import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.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 log = loglevel.getLogger('VendorInfo')
log.setLevel(config.logLevel)
const { Title, Link, Text } = Typography
const VendorInfo = () => { const VendorInfo = () => {
const [vendorData, setVendorData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const location = useLocation() const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const vendorId = new URLSearchParams(location.search).get('vendorId') 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', { const [collapseState, updateCollapseState] = useCollapseState('VendorInfo', {
info: true, info: true,
notes: true, notes: true,
auditLogs: 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 ( return (
<> <EditObjectForm
{contextHolder} id={vendorId}
<Flex type='vendor'
gap='large' style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
vertical='true' >
style={{ height: '100%', minHeight: 0 }} {({
> loading,
<Flex justify={'space-between'}> isEditing,
<Space size='small'> startEditing,
<Dropdown menu={actionItems}> cancelEditing,
<Button>Actions</Button> handleUpdate,
</Dropdown> formValid,
<Popover objectData,
content={getViewDropdownItems()} editLoading,
placement='bottomLeft' lock,
arrow={false} fetchObject
> }) => (
<Button>View</Button> <Flex
</Popover> gap='large'
</Space> vertical='true'
<Space> style={{ height: '100%', minHeight: 0 }}
{isEditing ? ( >
<> <Flex justify={'space-between'}>
<Button <Space size='middle'>
icon={<CheckIcon />} <Space size='small'>
type='primary' <Dropdown
onClick={updateInfo} menu={{
items: [
{
label: 'Reload Vendor',
key: 'reload',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchObject()
}
}
}}
>
<Button disabled={loading}>Actions</Button>
</Dropdown>
<ViewButton
loading={loading} loading={loading}
disabled={loading} sections={[
{ key: 'info', label: 'Vendor Information' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
collapseState={collapseState}
updateCollapseState={updateCollapseState}
/> />
<Button </Space>
icon={<XMarkIcon />} <LockIndicator lock={lock} />
onClick={cancelEditing} </Space>
disabled={loading} <Space>
/> <EditButtons
</> isEditing={isEditing}
) : ( handleUpdate={handleUpdate}
<Button icon={<EditIcon />} onClick={startEditing} /> cancelEditing={cancelEditing}
)} startEditing={startEditing}
</Space> editLoading={editLoading}
</Flex> 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' }}> <div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}> <Flex vertical gap={'large'}>
<Collapse <InfoCollapse
ghost title='Vendor Information'
expandIconPosition='end' icon={<InfoCircleIcon />}
activeKey={collapseState.info ? ['1'] : []} active={collapseState.info}
onChange={(keys) => onToggle={(expanded) => updateCollapseState('info', expanded)}
updateCollapseState('info', keys.length > 0) key='info'
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined
rotate={isActive ? -90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
> >
<Collapse.Panel <ObjectInfo
header={ loading={loading}
<Flex align='center' gap={'middle'}> indicator={<LoadingOutlined />}
<InfoCircleIcon /> isEditing={isEditing}
<Title level={5} style={{ margin: 0 }}> items={[
Vendor Information {
</Title> name: 'id',
</Flex> label: 'ID',
} value: objectData?._id,
key='1' type: 'id',
> objectType: 'vendor',
<Form form={form} layout='vertical'> showCopy: true
<Spin },
indicator={<LoadingOutlined />} {
spinning={fetchLoading} name: 'createdAt',
> label: 'Created At',
<Descriptions value: objectData?.createdAt,
bordered type: 'dateTime',
column={{ readOnly: true
xs: 1, },
sm: 1, {
md: 1, name: 'name',
lg: 2, label: 'Name',
xl: 2, value: objectData?.name,
xxl: 2 required: true,
}} type: 'text'
> },
<Descriptions.Item label='ID'> {
{vendorData?._id ? ( name: 'updatedAt',
<IdText id={vendorData._id} type='vendor' /> label: 'Updated At',
) : ( value: objectData?.updatedAt,
<Text>n/a</Text> type: 'dateTime',
)} readOnly: true
</Descriptions.Item> },
<Descriptions.Item label='Created At'> {
{vendorData?.createdAt ? ( name: 'website',
<TimeDisplay label: 'Website',
dateTime={vendorData.createdAt} value: objectData?.website,
showSince={true} type: 'url'
/> },
) : ( {
<Text>n/a</Text> name: 'country',
)} label: 'Country',
</Descriptions.Item> 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'> <InfoCollapse
{isEditing ? ( title='Notes'
<Form.Item icon={<NoteIcon />}
name='name' active={collapseState.notes}
rules={[ onToggle={(expanded) => updateCollapseState('notes', expanded)}
{ key='notes'
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'
> >
<Collapse.Panel <Card>
header={ <DashboardNotes _id={vendorId} />
<Flex align='center' gap={'middle'}> </Card>
<NoteIcon /> </InfoCollapse>
<Title level={5} style={{ margin: 0 }}>
Notes
</Title>
</Flex>
}
key='notes'
>
<Card>
<DashboardNotes _id={vendorId} />
</Card>
</Collapse.Panel>
</Collapse>
<Collapse <InfoCollapse
ghost title='Audit Logs'
expandIconPosition='end' icon={<AuditLogIcon />}
activeKey={collapseState.auditLogs ? ['auditLogs'] : []} active={collapseState.auditLogs}
onChange={(keys) => onToggle={(expanded) =>
updateCollapseState('auditLogs', keys.length > 0) updateCollapseState('auditLogs', expanded)
} }
expandIcon={({ isActive }) => ( key='auditLogs'
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
> >
<Collapse.Panel <AuditLogTable
header={ items={objectData?.auditLogs || []}
<Flex align='center' gap={'middle'}> loading={loading}
<AuditLogIcon /> showTargetColumn={false}
<Title level={5} style={{ margin: 0 }}> />
Audit Logs </InfoCollapse>
</Title>
</Flex>
}
key='auditLogs'
>
<AuditLogTable
items={vendorData?.auditLogs || []}
loading={fetchLoading}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</Flex> </Flex>
</div> </div>
)} </Flex>
</Flex> )}
</> </EditObjectForm>
) )
} }

View File

@ -21,7 +21,7 @@ import { DownloadOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import NewGCodeFile from './GCodeFiles/NewGCodeFile' import NewGCodeFile from './GCodeFiles/NewGCodeFile'
import IdText from '../common/IdText' import IdDisplay from '../common/IdDisplay'
import GCodeFileIcon from '../../Icons/GCodeFileIcon' import GCodeFileIcon from '../../Icons/GCodeFileIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import useColumnVisibility from '../hooks/useColumnVisibility' import useColumnVisibility from '../hooks/useColumnVisibility'
@ -120,7 +120,9 @@ const GCodeFiles = () => {
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 180, width: 180,
render: (text) => <IdText id={text} type={'gcodefile'} longId={false} /> render: (text) => (
<IdDisplay id={text} type={'gcodefile'} longId={false} />
)
}, },
{ {
title: 'Filament', title: 'Filament',

View File

@ -1,596 +1,295 @@
import React, { useState, useEffect } from 'react' import React from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import axios from 'axios' import { Space, Button, Flex, Dropdown, Card, Typography } from 'antd'
import { import { LoadingOutlined } from '@ant-design/icons'
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 ReloadIcon from '../../../Icons/ReloadIcon' import ReloadIcon from '../../../Icons/ReloadIcon'
import EditIcon from '../../../Icons/EditIcon.jsx' import useCollapseState from '../../hooks/useCollapseState'
import XMarkIcon from '../../../Icons/XMarkIcon.jsx' import AuditLogTable from '../../common/AuditLogTable'
import CheckIcon from '../../../Icons/CheckIcon.jsx' import DashboardNotes from '../../common/DashboardNotes'
import InfoCollapse from '../../common/InfoCollapse'
import config from '../../../../config.js' import ObjectInfo from '../../common/ObjectInfo'
import AuditLogTable from '../../common/AuditLogTable.jsx' import ViewButton from '../../common/ViewButton'
import DashboardNotes from '../../common/DashboardNotes.jsx' import EditObjectForm from '../../common/EditObjectForm'
import BinIcon from '../../../Icons/BinIcon.jsx' import EditButtons from '../../common/EditButtons'
import LockIndicator from '../../Management/Filaments/LockIndicator'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx' import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import GCodeFileIcon from '../../../Icons/GCodeFileIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx' import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import GCodeFileIcon from '../../../Icons/GCodeFileIcon.jsx'
const { Title, Text } = Typography const { Text } = Typography
const GCodeFileInfo = () => { const GCodeFileInfo = () => {
const [gcodeFileData, setGCodeFileData] = useState(null)
const [editLoading, setLoading] = useState(false)
const [error, setError] = useState(null)
const location = useLocation() const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const gcodeFileId = new URLSearchParams(location.search).get('gcodeFileId') 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( const [collapseState, updateCollapseState] = useCollapseState(
'GCodeFileInfo', 'GCodeFileInfo',
{ {
info: true, 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 ( return (
<> <EditObjectForm
{contextHolder} id={gcodeFileId}
<Flex type='gcodefile'
gap='large' style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
vertical='true' >
style={{ height: '100%', minHeight: 0 }} {({
> loading,
<Flex justify={'space-between'}> isEditing,
<Space size='small'> startEditing,
<Dropdown menu={actionItems}> cancelEditing,
<Button>Actions</Button> handleUpdate,
</Dropdown> formValid,
<Popover objectData,
content={getViewDropdownItems()} editLoading,
placement='bottomLeft' lock,
arrow={false} fetchObject
> }) => (
<Button>View</Button> <Flex
</Popover> gap='large'
</Space> vertical='true'
<Space> style={{ height: '100%', minHeight: 0 }}
{isEditing ? ( >
<> <Flex justify={'space-between'}>
<Button <Space size='middle'>
icon={<CheckIcon />} <Space size='small'>
type='primary' <Dropdown
onClick={updateGCodeFileInfo} menu={{
loading={editLoading} items: [
disabled={editLoading} {
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 </Space>
icon={<XMarkIcon />} <LockIndicator lock={lock} />
onClick={cancelEditing} </Space>
disabled={editLoading} <Space>
/> <EditButtons
</> isEditing={isEditing}
) : ( handleUpdate={handleUpdate}
<Button icon={<EditIcon />} onClick={startEditing} /> cancelEditing={cancelEditing}
)} startEditing={startEditing}
</Space> editLoading={editLoading}
</Flex> 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' }}> <div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}> <Flex vertical gap={'large'}>
<Collapse <InfoCollapse
ghost title='GCode File Information'
expandIconPosition='end' icon={<InfoCircleIcon />}
activeKey={collapseState.info ? ['info'] : []} active={collapseState.info}
onChange={(keys) => onToggle={(expanded) => updateCollapseState('info', expanded)}
updateCollapseState('info', keys.length > 0) key='info'
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse no-t-padding-collapse'
> >
<Collapse.Panel <ObjectInfo
header={ loading={loading}
<Flex align='center' gap={'middle'}> indicator={<LoadingOutlined />}
<InfoCircleIcon /> isEditing={isEditing}
<Title level={5} style={{ margin: 0 }}> items={[
GCode File Information {
</Title> name: '_id',
</Flex> label: 'ID',
} type: 'id',
key='info' objectType: 'gcodefile',
> value: objectData?._id,
<Form form={form} layout='vertical'> showCopy: true
<Spin },
spinning={fetchLoading} {
indicator={<LoadingOutlined />} name: 'createdAt',
> label: 'Created At',
<Descriptions type: 'dateTime',
bordered value: objectData?.createdAt,
column={{ readOnly: true
xs: 1, },
sm: 1, {
md: 1, name: 'name',
lg: 2, label: 'Name',
xl: 2, type: 'text',
xxl: 2 value: objectData?.name,
}} required: true
> },
<Descriptions.Item label='ID' span={1}> {
{gcodeFileData?._id ? ( name: 'updatedAt',
<IdText label: 'Updated At',
id={gcodeFileData._id} type: 'dateTime',
type='gcodefile' value: objectData?.updatedAt,
></IdText> readOnly: true
) : ( },
<Text>n/a</Text> {
)} name: 'filament',
</Descriptions.Item> label: 'Filament',
<Descriptions.Item label='Created At'> type: 'object',
{gcodeFileData?.createdAt ? ( value: objectData?.filament,
<TimeDisplay objectType: 'filament',
dateTime={gcodeFileData.createdAt} required: true
showSince={true} },
/> {
) : ( name: 'cost',
<Text>n/a</Text> label: 'Cost',
)} type: 'currency',
</Descriptions.Item> 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'> <InfoCollapse
{isEditing ? ( title='GCode File Preview'
<Form.Item icon={<GCodeFileIcon />}
name='name' active={collapseState.preview}
rules={[ onToggle={(expanded) =>
{ updateCollapseState('preview', expanded)
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)
} }
expandIcon={({ isActive }) => ( key='preview'
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
> >
<Collapse.Panel <Card>
header={ {objectData?.gcodeFileInfo?.thumbnail ? (
<Flex align='center' gap={'middle'}> <img
<GCodeFileIcon /> src={`data:image/png;base64,${objectData.gcodeFileInfo.thumbnail.data}`}
<Title level={5} style={{ margin: 0 }}> alt='GCodeFile'
GCode File Preview style={{ maxWidth: '100%' }}
</Title> />
</Flex> ) : (
} <Text>n/a</Text>
key='preview' )}
> </Card>
<Spin spinning={fetchLoading} indicator={<LoadingOutlined />}> </InfoCollapse>
<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>
<Collapse <InfoCollapse
ghost title='Notes'
expandIconPosition='end' icon={<NoteIcon />}
activeKey={collapseState.notes ? ['notes'] : []} active={collapseState.notes}
onChange={(keys) => onToggle={(expanded) => updateCollapseState('notes', expanded)}
updateCollapseState('notes', keys.length > 0) key='notes'
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
> >
<Collapse.Panel <Card>
header={ <DashboardNotes _id={gcodeFileId} />
<Flex align='center' gap={'middle'}> </Card>
<NoteIcon /> </InfoCollapse>
<Title level={5} style={{ margin: 0 }}>
Notes
</Title>
</Flex>
}
key='notes'
>
<Card>
<DashboardNotes _id={gcodeFileId} />
</Card>
</Collapse.Panel>
</Collapse>
<Collapse <InfoCollapse
ghost title='Audit Logs'
expandIconPosition='end' icon={<AuditLogIcon />}
activeKey={collapseState.auditLogs ? ['auditLogs'] : []} active={collapseState.auditLogs}
onChange={(keys) => onToggle={(expanded) =>
updateCollapseState('auditLogs', keys.length > 0) updateCollapseState('auditLogs', expanded)
} }
expandIcon={({ isActive }) => ( key='auditLogs'
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
> >
<Collapse.Panel <AuditLogTable
header={ items={objectData?.auditLogs || []}
<Flex align='center' gap={'middle'}> loading={loading}
<AuditLogIcon /> showTargetColumn={false}
<Title level={5} style={{ margin: 0 }}> />
Audit Log </InfoCollapse>
</Title>
</Flex>
}
key='auditLogs'
>
<AuditLogTable
items={gcodeFileData?.auditLogs || []}
loading={fetchLoading}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</Flex> </Flex>
</div> </div>
)} </Flex>
</Flex> )}
</> </EditObjectForm>
) )
} }

View File

@ -218,7 +218,6 @@ const NewGCodeFile = ({ onOk, reset }) => {
newGCodeFileFormValues?.gcodeFileInfo?.filamentUsedG newGCodeFileFormValues?.gcodeFileInfo?.filamentUsedG
if (filamentCost && gcodeFilamentUsed) { if (filamentCost && gcodeFilamentUsed) {
const cost = (filamentCost / 1000) * gcodeFilamentUsed const cost = (filamentCost / 1000) * gcodeFilamentUsed
console.log('Setting cost')
setNewGCodeFileFormValues((prev) => ({ ...prev, cost: cost.toFixed(2) })) setNewGCodeFileFormValues((prev) => ({ ...prev, cost: cost.toFixed(2) }))
newGCodeFileForm.setFieldValue('cost', cost.toFixed(2)) newGCodeFileForm.setFieldValue('cost', cost.toFixed(2))
} }
@ -304,8 +303,6 @@ const NewGCodeFile = ({ onOk, reset }) => {
gcodeFileInfo: parsedConfig gcodeFileInfo: parsedConfig
}) })
console.log(parsedConfig)
// Update filter settings if filament info is available // Update filter settings if filament info is available
if (parsedConfig.filament_type && parsedConfig.filament_diameter) { if (parsedConfig.filament_type && parsedConfig.filament_diameter) {
setFilamentSelectFilter({ setFilamentSelectFilter({
@ -525,7 +522,6 @@ const NewGCodeFile = ({ onOk, reset }) => {
onClick={() => { onClick={() => {
setCurrentStep(currentStep + 1) setCurrentStep(currentStep + 1)
setNextEnabled(false) setNextEnabled(false)
console.log(newGCodeFileFormValues)
}} }}
> >
Next Next

View File

@ -22,7 +22,7 @@ import NewJob from './Jobs/NewJob.jsx'
import JobState from '../common/JobState.jsx' import JobState from '../common/JobState.jsx'
import SubJobCounter from '../common/SubJobCounter.jsx' import SubJobCounter from '../common/SubJobCounter.jsx'
import TimeDisplay from '../common/TimeDisplay.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 useColumnVisibility from '../hooks/useColumnVisibility.js'
import JobIcon from '../../Icons/JobIcon.jsx' import JobIcon from '../../Icons/JobIcon.jsx'
import InfoCircleIcon from '../../Icons/InfoCircleIcon.jsx' import InfoCircleIcon from '../../Icons/InfoCircleIcon.jsx'
@ -126,7 +126,7 @@ const Jobs = () => {
dataIndex: 'id', dataIndex: 'id',
key: 'id', key: 'id',
width: 180, width: 180,
render: (text) => <IdText id={text} type={'job'} longId={false} />, render: (text) => <IdDisplay id={text} type={'job'} longId={false} />,
filterDropdown: ({ filterDropdown: ({
setSelectedKeys, setSelectedKeys,
selectedKeys, selectedKeys,

View File

@ -1,48 +1,26 @@
import React, { useState, useEffect, useContext } from 'react' import React from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import axios from 'axios' import { Space, Button, Flex, Dropdown, Card } from 'antd'
import { import { LoadingOutlined } from '@ant-design/icons'
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 useCollapseState from '../../hooks/useCollapseState' import useCollapseState from '../../hooks/useCollapseState'
import config from '../../../../config'
import AuditLogTable from '../../common/AuditLogTable' import AuditLogTable from '../../common/AuditLogTable'
import DashboardNotes from '../../common/DashboardNotes' 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 InfoCircleIcon from '../../../Icons/InfoCircleIcon'
import JobIcon from '../../../Icons/JobIcon' import JobIcon from '../../../Icons/JobIcon'
import AuditLogIcon from '../../../Icons/AuditLogIcon' import AuditLogIcon from '../../../Icons/AuditLogIcon'
import NoteIcon from '../../../Icons/NoteIcon' import NoteIcon from '../../../Icons/NoteIcon'
import GCodeFileIcon from '../../../Icons/GCodeFileIcon'
const { Title, Text } = Typography
const JobInfo = () => { const JobInfo = () => {
const [jobData, setJobData] = useState(null)
const [fetchLoading, setFetchLoading] = useState(true)
const [error, setError] = useState(null)
const location = useLocation() const location = useLocation()
const [messageApi] = message.useMessage()
const jobId = new URLSearchParams(location.search).get('jobId') const jobId = new URLSearchParams(location.search).get('jobId')
const { printServer } = useContext(PrintServerContext)
const [collapseState, updateCollapseState] = useCollapseState('JobInfo', { const [collapseState, updateCollapseState] = useCollapseState('JobInfo', {
info: true, info: true,
subJobs: true, subJobs: true,
@ -50,352 +28,205 @@ const JobInfo = () => {
auditLogs: true 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 ( return (
<> <EditObjectForm
<Flex id={jobId}
gap='large' type='job'
vertical='true' style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
style={{ height: '100%', minHeight: 0 }} >
> {({
<Flex justify={'space-between'}> loading,
<Space size='small'> isEditing,
<Dropdown menu={actionItems}> startEditing,
<Button>Actions</Button> cancelEditing,
</Dropdown> handleUpdate,
<Popover formValid,
content={getViewDropdownItems()} objectData,
placement='bottomLeft' editLoading,
arrow={false} lock,
> fetchObject
<Button>View</Button> }) => (
</Popover> <Flex
</Space> gap='large'
</Flex> 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' }}> <div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical style={{ flexGrow: 1 }} gap={'large'}> <Flex vertical gap={'large'}>
<Collapse <InfoCollapse
ghost title='Job Information'
expandIconPosition='end' icon={<InfoCircleIcon />}
activeKey={collapseState.info ? ['info'] : []} active={collapseState.info}
onChange={(keys) => onToggle={(expanded) => updateCollapseState('info', expanded)}
updateCollapseState('info', keys.length > 0) key='info'
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse no-t-padding-collapse'
> >
<Collapse.Panel <ObjectInfo
header={ loading={loading}
<Flex align='center' gap={'middle'}> indicator={<LoadingOutlined />}
<InfoCircleIcon /> isEditing={isEditing}
<Title level={5} style={{ margin: 0 }}> type='job'
Job Information items={[
</Title> {
</Flex> name: '_id',
} label: 'ID',
key='info' value: objectData?._id,
> type: 'id',
<Spin spinning={fetchLoading} indicator={<LoadingOutlined />}> objectType: 'job',
<Descriptions showCopy: true
bordered },
column={{ {
xs: 1, name: 'state',
sm: 1, label: 'Status',
md: 1, value: objectData,
lg: 2, type: 'state',
xl: 2, objectType: 'job',
xxl: 2 showStatus: true,
}} showProgress: true,
> showId: false,
<Descriptions.Item label='ID'> showQuantity: false,
{jobData?._id ? ( readOnly: true
<IdText id={jobData._id} type={'job'} /> },
) : ( {
<Text>n/a</Text> name: 'gcodeFile',
)} label: 'GCode File',
</Descriptions.Item> value: objectData?.gcodeFile,
<Descriptions.Item label='Status'> type: 'object',
{jobData?.state ? ( objectType: 'gcodeFile',
<JobState readOnly: true
job={jobData} },
showProgress={false} {
showQuantity={false} name: 'gcodeFileId',
showId={false} label: 'GCode File ID',
/> value: objectData?.gcodeFile?._id,
) : ( type: 'id',
<Text>n/a</Text> objectType: 'gcodefile',
)} showHyperlink: true
</Descriptions.Item> },
<Descriptions.Item label='GCode File Name'> {
{jobData?.gcodeFile ? ( name: 'quantity',
<Space> label: 'Quantity',
<GCodeFileIcon /> value: objectData?.quantity,
<Text> type: 'number',
{jobData.gcodeFile.name || 'Not specified'} readOnly: true
</Text> },
</Space> {
) : ( name: 'createdAt',
<Text>n/a</Text> label: 'Created At',
)} value: objectData?.createdAt,
</Descriptions.Item> type: 'dateTime',
<Descriptions.Item label='GCode File ID'> readOnly: true
{jobData?.gcodeFile?._id ? ( },
<IdText {
id={jobData.gcodeFile._id} name: 'startedAt',
type={'gcodefile'} label: 'Started At',
showHyperlink={true} value: objectData?.startedAt,
/> type: 'dateTime',
) : ( readOnly: true
<Text>n/a</Text> },
)} {
</Descriptions.Item> name: 'assignedPrinters',
<Descriptions.Item label='Quantity'> label: 'Assigned Printers',
{jobData?.quantity ? ( value: objectData?.printers?.length,
<Text>{jobData.quantity}</Text> type: 'number',
) : ( readOnly: true
<Text>n/a</Text> }
)} ]}
</Descriptions.Item> />
<Descriptions.Item label='Created At'> </InfoCollapse>
{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>
<Collapse <InfoCollapse
ghost title='Sub Jobs'
expandIconPosition='end' icon={<JobIcon />}
activeKey={collapseState.subJobs ? ['2'] : []} active={collapseState.subJobs}
onChange={(keys) => onToggle={(expanded) =>
updateCollapseState('subJobs', keys.length > 0) updateCollapseState('subJobs', expanded)
} }
expandIcon={({ isActive }) => ( key='subJobs'
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
> >
<Collapse.Panel <SubJobsTree jobData={objectData} loading={loading} />
header={ </InfoCollapse>
<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>
<Collapse <InfoCollapse
ghost title='Notes'
expandIconPosition='end' icon={<NoteIcon />}
activeKey={collapseState.notes ? ['notes'] : []} active={collapseState.notes}
onChange={(keys) => onToggle={(expanded) => updateCollapseState('notes', expanded)}
updateCollapseState('notes', keys.length > 0) key='notes'
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
> >
<Collapse.Panel <Card>
header={ <DashboardNotes _id={jobId} />
<Flex align='center' gap={'middle'}> </Card>
<NoteIcon /> </InfoCollapse>
<Title level={5} style={{ margin: 0 }}>
Notes
</Title>
</Flex>
}
key='notes'
>
<Card>
<DashboardNotes _id={jobId} />
</Card>
</Collapse.Panel>
</Collapse>
<Collapse <InfoCollapse
ghost title='Audit Logs'
expandIconPosition='end' icon={<AuditLogIcon />}
activeKey={collapseState.auditLogs ? ['auditLogs'] : []} active={collapseState.auditLogs}
onChange={(keys) => onToggle={(expanded) =>
updateCollapseState('auditLogs', keys.length > 0) updateCollapseState('auditLogs', expanded)
} }
expandIcon={({ isActive }) => ( key='auditLogs'
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
> >
<Collapse.Panel <AuditLogTable
header={ items={objectData?.auditLogs || []}
<Flex align='center' gap={'middle'}> loading={loading}
<AuditLogIcon /> showTargetColumn={false}
<Title level={5} style={{ margin: 0 }}> />
Audit Logs </InfoCollapse>
</Title>
</Flex>
}
key='auditLogs'
>
<AuditLogTable
items={jobData?.auditLogs || []}
loading={fetchLoading}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</Flex> </Flex>
</div> </div>
)} </Flex>
</Flex> )}
</> </EditObjectForm>
) )
} }

View File

@ -18,7 +18,7 @@ import {
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import PrinterState from '../common/PrinterState' import PrinterState from '../common/PrinterState'
import NewPrinter from './Printers/NewPrinter' import NewPrinter from './Printers/NewPrinter'
import IdText from '../common/IdText' import IdDisplay from '../common/IdDisplay'
import PrinterIcon from '../../Icons/PrinterIcon' import PrinterIcon from '../../Icons/PrinterIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import ControlIcon from '../../Icons/ControlIcon' import ControlIcon from '../../Icons/ControlIcon'
@ -79,7 +79,7 @@ const Printers = () => {
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 180, width: 180,
render: (text) => <IdText id={text} type='printer' longId={false} /> render: (text) => <IdDisplay id={text} type='printer' longId={false} />
}, },
{ {
title: 'State', title: 'State',
@ -89,7 +89,7 @@ const Printers = () => {
return ( return (
<PrinterState <PrinterState
printer={record} printer={record}
showPrinterName={false} showName={false}
showControls={false} showControls={false}
/> />
) )

View File

@ -31,7 +31,7 @@ import PrinterMiscPanel from '../../common/PrinterMiscPanel'
import PrinterState from '../../common/PrinterState' import PrinterState from '../../common/PrinterState'
import { AuthContext } from '../../context/AuthContext' import { AuthContext } from '../../context/AuthContext'
import PrinterSubJobsTree from '../../common/PrinterJobsTree' import PrinterSubJobsTree from '../../common/PrinterJobsTree'
import IdText from '../../common/IdText' import IdDisplay from '../../common/IdDisplay'
import FilamentIcon from '../../../Icons/FilamentIcon' import FilamentIcon from '../../../Icons/FilamentIcon'
import FilamentStockIcon from '../../../Icons/FilamentStockIcon' import FilamentStockIcon from '../../../Icons/FilamentStockIcon'
@ -179,7 +179,6 @@ const ControlPrinter = () => {
} }
return () => { return () => {
if (printServer && initialized) { if (printServer && initialized) {
console.log('Deregistering')
printServer.off('notify_printer_update') printServer.off('notify_printer_update')
printServer.off('notify_filamentstock_update') printServer.off('notify_filamentstock_update')
} }
@ -187,7 +186,6 @@ const ControlPrinter = () => {
}, [printServer, initialized, printerId]) }, [printServer, initialized, printerId])
function handleEmergencyStop() { function handleEmergencyStop() {
console.log('Emergency stop button clicked')
printServer.emit('printer.emergency_stop', { printerId }) printServer.emit('printer.emergency_stop', { printerId })
} }
@ -438,7 +436,7 @@ const ControlPrinter = () => {
<PrinterState <PrinterState
printer={printerData} printer={printerData}
showProgress={false} showProgress={false}
showPrinterName={false} showName={false}
showControls={false} showControls={false}
/> />
) : ( ) : (
@ -548,7 +546,7 @@ const ControlPrinter = () => {
<Descriptions.Item label='Printer ID'> <Descriptions.Item label='Printer ID'>
{printerData?._id ? ( {printerData?._id ? (
<IdText <IdDisplay
id={printerData._id} id={printerData._id}
type='printer' type='printer'
longId={false} longId={false}
@ -573,7 +571,7 @@ const ControlPrinter = () => {
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='GCode File ID'> <Descriptions.Item label='GCode File ID'>
{printerData?.currentJob?.gcodeFile ? ( {printerData?.currentJob?.gcodeFile ? (
<IdText <IdDisplay
id={printerData.currentJob.gcodeFile.id} id={printerData.currentJob.gcodeFile.id}
type='gcodeFile' type='gcodeFile'
longId={false} longId={false}
@ -586,7 +584,7 @@ const ControlPrinter = () => {
<Descriptions.Item label='Print Job ID'> <Descriptions.Item label='Print Job ID'>
{printerData?.currentJob?.id ? ( {printerData?.currentJob?.id ? (
<IdText <IdDisplay
id={printerData.currentJob.id} id={printerData.currentJob.id}
type='job' type='job'
longId={false} longId={false}
@ -599,7 +597,7 @@ const ControlPrinter = () => {
<Descriptions.Item label='Sub Job ID'> <Descriptions.Item label='Sub Job ID'>
{printerData?.currentSubJob?.id ? ( {printerData?.currentSubJob?.id ? (
<IdText <IdDisplay
id={printerData.currentSubJob.number id={printerData.currentSubJob.number
.toString() .toString()
.padStart(6, '0')} .padStart(6, '0')}
@ -719,7 +717,7 @@ const ControlPrinter = () => {
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Filament Stock ID'> <Descriptions.Item label='Filament Stock ID'>
{printerData?.currentFilamentStock?._id ? ( {printerData?.currentFilamentStock?._id ? (
<IdText <IdDisplay
id={printerData.currentFilamentStock._id} id={printerData.currentFilamentStock._id}
type='filamentstock' type='filamentstock'
longId={false} longId={false}
@ -748,7 +746,7 @@ const ControlPrinter = () => {
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Filament ID'> <Descriptions.Item label='Filament ID'>
{printerData?.currentFilamentStock?.filament ? ( {printerData?.currentFilamentStock?.filament ? (
<IdText <IdDisplay
id={printerData.currentFilamentStock.filament._id} id={printerData.currentFilamentStock.filament._id}
type='filament' type='filament'
longId={false} longId={false}

View File

@ -1,689 +1,262 @@
import React, { useState, useEffect } from 'react' import React from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import axios from 'axios' import { Space, Button, Flex, Dropdown, Card } from 'antd'
import { import { LoadingOutlined } from '@ant-design/icons'
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 useCollapseState from '../../hooks/useCollapseState' import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import config from '../../../../config.js' import DashboardNotes from '../../common/DashboardNotes'
import AuditLogTable from '../../common/AuditLogTable.jsx' import InfoCollapse from '../../common/InfoCollapse'
import DashboardNotes from '../../common/DashboardNotes.jsx' 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 InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx' import NoteIcon from '../../../Icons/NoteIcon.jsx'
import PrinterIcon from '../../../Icons/PrinterIcon.jsx' import PrinterIcon from '../../../Icons/PrinterIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
const { Title, Text } = Typography
const PrinterInfo = () => { 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 location = useLocation()
const printerId = new URLSearchParams(location.search).get('printerId') 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', { const [collapseState, updateCollapseState] = useCollapseState('PrinterInfo', {
info: true, info: true,
jobs: true, jobs: true,
notes: true,
auditLogs: 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 ( return (
<> <EditObjectForm
{contextHolder} id={printerId}
<Flex type='printer'
gap='large' style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
vertical='true' >
style={{ height: '100%', minHeight: 0 }} {({
> loading,
<Flex justify={'space-between'}> isEditing,
<Space size='small'> startEditing,
<Dropdown menu={actionItems}> cancelEditing,
<Button>Actions</Button> handleUpdate,
</Dropdown> formValid,
<Popover objectData,
content={getViewDropdownItems()} editLoading,
placement='bottomLeft' lock,
arrow={false} fetchObject
> }) => (
<Button>View</Button> <Flex
</Popover> gap='large'
</Space> vertical='true'
<Space> style={{ height: '100%', minHeight: 0 }}
{isEditing ? ( >
<> <Flex justify={'space-between'}>
<Button <Space size='middle'>
icon={<CheckIcon />} <Space size='small'>
type='primary' <Dropdown
onClick={updatePrinterInfo} menu={{
loading={editLoading} items: [
disabled={editLoading} {
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 </Space>
icon={<XMarkIcon />} <LockIndicator lock={lock} />
onClick={cancelEditing} </Space>
disabled={editLoading} <Space>
/> <EditButtons
</> isEditing={isEditing}
) : ( handleUpdate={handleUpdate}
<Button icon={<EditIcon />} onClick={startEditing} /> cancelEditing={cancelEditing}
)} startEditing={startEditing}
</Space> editLoading={editLoading}
</Flex> 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' }}> <div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}> <Flex vertical gap={'large'}>
<Collapse <InfoCollapse
ghost title='Printer Information'
expandIconPosition='end' icon={<InfoCircleIcon />}
activeKey={collapseState.info ? ['info'] : []} active={collapseState.info}
onChange={(keys) => onToggle={(expanded) => updateCollapseState('info', expanded)}
updateCollapseState('info', keys.length > 0) key='info'
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse no-t-padding-collapse'
> >
<Collapse.Panel <ObjectInfo
header={ loading={loading}
<Flex align='center' gap={'middle'}> indicator={<LoadingOutlined />}
<InfoCircleIcon /> isEditing={isEditing}
<Title level={5} style={{ margin: 0 }}> type='printer'
Printer Information items={[
</Title> {
</Flex> name: '_id',
} label: 'ID',
key='info' value: objectData?._id,
> type: 'id',
<Form objectType: 'printer',
form={form} showCopy: true
layout='vertical' },
initialValues={{ {
name: printerData?.name || '', name: 'connectedAt',
vendor: printerData?.vendor || { id: null, name: '' }, label: 'Connected At',
moonraker: { value: objectData?.connectedAt,
host: printerData?.moonraker?.host || '', type: 'dateTime',
port: printerData?.moonraker?.port || null, readOnly: true
protocol: printerData?.moonraker?.protocol || 'ws', },
apiKey: printerData?.moonraker?.apiKey || '' {
}, name: 'name',
tags: printerData?.tags || [] label: 'Name',
}} value: objectData?.name,
> required: true,
<Spin type: 'text'
spinning={fetchLoading} },
indicator={<LoadingOutlined />} {
> name: 'state',
<Descriptions label: 'Status',
bordered value: objectData,
column={{ type: 'state',
xs: 1, objectType: 'printer',
sm: 1, showName: false,
md: 1, readOnly: true
lg: 2, },
xl: 2, {
xxl: 2 name: 'vendor',
}} label: 'Vendor',
> value: objectData?.vendor,
{/* Read-only fields */} type: 'object',
<Descriptions.Item label='ID'> objectType: 'vendor',
{printerData?._id ? ( required: true
<IdText id={printerData._id} type={'printer'} /> },
) : ( {
<Text>n/a</Text> name: ['moonraker', 'host'],
)} label: 'Host',
</Descriptions.Item> value: objectData?.moonraker?.host,
<Descriptions.Item label='Connected At'> type: 'text',
{printerData?.connectedAt ? ( required: true
<TimeDisplay },
dateTime={printerData.connectedAt} {
showSince={true} name: 'vendorId',
/> label: 'Vendor ID',
) : ( value: objectData?.vendor?.id,
<Text>n/a</Text> type: 'id',
)} objectType: 'vendor',
</Descriptions.Item> showHyperlink: true,
readOnly: true
},
{/* Editable fields */} {
<Descriptions.Item label='Name'> name: ['moonraker', 'port'],
{isEditing ? ( label: 'Port',
<Form.Item value: objectData?.moonraker?.port,
name='name' type: 'number',
rules={[ required: true
{ },
required: true, {
message: 'Please enter a printer name' name: ['moonraker', 'apiKey'],
}, label: 'API Key',
{ value: objectData?.moonraker?.apiKey,
max: 100, type: 'secret',
message: 'Name cannot exceed 100 characters' reveal: true,
} required: false
]} },
style={{ margin: 0 }} {
> name: ['moonraker', 'protocol'],
<Input placeholder='Enter printer name' /> label: 'Protocol',
</Form.Item> value: objectData?.moonraker?.protocol,
) : printerData?.name ? ( type: 'wsprotocol',
<Text>{printerData.name}</Text> required: true
) : ( },
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Host'> {
{isEditing ? ( name: 'tags',
<Form.Item label: 'Tags',
name={['moonraker', 'host']} value: objectData?.tags,
rules={[ type: 'tags',
{ required: false
required: true, },
message: 'Please enter a host' {
}, name: 'firmware',
{ label: 'Firmware Version',
pattern: value: objectData?.firmware,
/^[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/, type: 'text',
message: required: false,
'Please enter a valid hostname or IP address' readOnly: true
} }
]} ]}
style={{ margin: 0 }} />
> </InfoCollapse>
<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>
<Descriptions.Item label='Vendor'> <InfoCollapse
{isEditing ? ( title='Printer Jobs'
<Form.Item icon={<PrinterIcon />}
name='vendor' active={collapseState.jobs}
rules={[ onToggle={(expanded) => updateCollapseState('jobs', expanded)}
{ key='jobs'
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'
> >
<Collapse.Panel <PrinterJobsTree
header={ subJobs={objectData?.subJobs}
<Flex align='center' gap={'middle'}> loading={loading}
<PrinterIcon /> />
<Title level={5} style={{ margin: 0 }}> </InfoCollapse>
Printer Jobs
</Title>
</Flex>
}
key='jobs'
>
<PrinterSubJobsList
subJobs={printerData?.subJobs}
loading={fetchLoading}
/>
</Collapse.Panel>
</Collapse>
<Collapse <InfoCollapse
ghost title='Notes'
expandIconPosition='end' icon={<NoteIcon />}
activeKey={collapseState.notes ? ['notes'] : []} active={collapseState.notes}
onChange={(keys) => onToggle={(expanded) => updateCollapseState('notes', expanded)}
updateCollapseState('notes', keys.length > 0) key='notes'
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
> >
<Collapse.Panel <Card>
header={ <DashboardNotes _id={printerId} />
<Flex align='center' gap={'middle'}> </Card>
<NoteIcon /> </InfoCollapse>
<Title level={5} style={{ margin: 0 }}>
Notes
</Title>
</Flex>
}
key='notes'
>
<Card>
<DashboardNotes _id={printerId} />
</Card>
</Collapse.Panel>
</Collapse>
<Collapse <InfoCollapse
ghost title='Audit Logs'
expandIconPosition='end' icon={<AuditLogIcon />}
activeKey={collapseState.auditLogs ? ['auditLogs'] : []} active={collapseState.auditLogs}
onChange={(keys) => onToggle={(expanded) =>
updateCollapseState('auditLogs', keys.length > 0) updateCollapseState('auditLogs', expanded)
} }
expandIcon={({ isActive }) => ( key='auditLogs'
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
> >
<Collapse.Panel <AuditLogTable
header={ items={objectData?.auditLogs || []}
<Flex align='center' gap={'middle'}> loading={loading}
<AuditLogIcon /> showTargetColumn={false}
<Title level={5} style={{ margin: 0 }}> />
Audit Log </InfoCollapse>
</Title>
</Flex>
}
key='auditLogs'
>
<AuditLogTable
items={printerData?.auditLogs || []}
loading={fetchLoading}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</Flex> </Flex>
</div> </div>
)} </Flex>
</Flex> )}
</> </EditObjectForm>
) )
} }

View File

@ -61,7 +61,6 @@ const ProductionOverview = () => {
await fetchPrinterStats() await fetchPrinterStats()
await fetchJobstats() await fetchJobstats()
await fetchChartData() await fetchChartData()
console.log(stats)
}, []) }, [])
const fetchPrinterStats = async () => { const fetchPrinterStats = async () => {
@ -74,7 +73,6 @@ const ProductionOverview = () => {
withCredentials: true withCredentials: true
}) })
const printStats = response.data const printStats = response.data
console.log(printStats)
setStats((prev) => ({ ...prev, printers: printStats })) setStats((prev) => ({ ...prev, printers: printStats }))
setError(null) setError(null)
} catch (err) { } catch (err) {

View File

@ -1,7 +1,7 @@
import React, { forwardRef, useState } from 'react' import React, { forwardRef, useState } from 'react'
import { Typography, Space, Descriptions, Badge, Table } from 'antd' import { Typography, Space, Descriptions, Badge, Table } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import IdText from './IdText' import IdDisplay from './IdDisplay'
import { AuditOutlined, LoadingOutlined } from '@ant-design/icons' import { AuditOutlined, LoadingOutlined } from '@ant-design/icons'
import TimeDisplay from '../common/TimeDisplay' import TimeDisplay from '../common/TimeDisplay'
import BoolDisplay from './BoolDisplay' import BoolDisplay from './BoolDisplay'
@ -51,7 +51,7 @@ const formatValue = (value, propertyName) => {
if (isObjectId(value)) { if (isObjectId(value)) {
return ( return (
<IdText <IdDisplay
id={value} id={value}
type={propertyName.toLowerCase().replaceAll('current', '')} type={propertyName.toLowerCase().replaceAll('current', '')}
longId={false} longId={false}
@ -90,7 +90,9 @@ const AuditLogTable = forwardRef(
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 180, 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) sorter: (a, b) => a._id.localeCompare(b._id)
} }
] ]
@ -110,7 +112,7 @@ const AuditLogTable = forwardRef(
key: 'owner', key: 'owner',
width: 180, width: 180,
render: (record) => ( render: (record) => (
<IdText <IdDisplay
id={record.owner._id} id={record.owner._id}
type={record.ownerModel.toLowerCase()} type={record.ownerModel.toLowerCase()}
longId={false} longId={false}
@ -127,7 +129,7 @@ const AuditLogTable = forwardRef(
key: 'target', key: 'target',
width: 180, width: 180,
render: (record) => ( render: (record) => (
<IdText <IdDisplay
id={record.target} id={record.target}
type={record.targetModel.toLowerCase()} type={record.targetModel.toLowerCase()}
longId={false} longId={false}

View 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

View 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

View File

@ -28,7 +28,7 @@ import config from '../../../config'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import InfoCircleIcon from '../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import NoteTypeSelect from './NoteTypeSelect' import NoteTypeSelect from './NoteTypeSelect'
import IdText from './IdText' import IdDisplay from './IdDisplay'
import ReloadIcon from '../../Icons/ReloadIcon' import ReloadIcon from '../../Icons/ReloadIcon'
import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon' import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon'
@ -177,7 +177,7 @@ const NoteItem = ({
</Space> </Space>
<Space size={'small'} style={{ marginRight: 8 }}> <Space size={'small'} style={{ marginRight: 8 }}>
<Text type='secondary'>User ID:</Text> <Text type='secondary'>User ID:</Text>
<IdText <IdDisplay
longId={false} longId={false}
id={note.user._id} id={note.user._id}
type={'user'} type={'user'}

View File

@ -21,6 +21,10 @@ import { LoadingOutlined } from '@ant-design/icons'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import axios from 'axios' import axios from 'axios'
import config from '../../../config'
import loglevel from 'loglevel'
const logger = loglevel.getLogger('DasboardTable')
logger.setLevel(config.logLevel)
const DashboardTable = forwardRef( const DashboardTable = forwardRef(
( (
@ -96,7 +100,7 @@ const DashboardTable = forwardRef(
const existingPageIndex = prev.findIndex( const existingPageIndex = prev.findIndex(
(p) => p.pageNum === pageNum (p) => p.pageNum === pageNum
) )
console.log(prev.map((p) => p.pageNum)) logger.debug(prev.map((p) => p.pageNum))
if (existingPageIndex !== -1) { if (existingPageIndex !== -1) {
// Update existing page // Update existing page
const newPages = [...prev] const newPages = [...prev]
@ -132,7 +136,7 @@ const DashboardTable = forwardRef(
const loadNextPage = useCallback(() => { const loadNextPage = useCallback(() => {
const highestPage = Math.max(...pages.map((p) => p.pageNum)) const highestPage = Math.max(...pages.map((p) => p.pageNum))
const nextPage = highestPage + 1 const nextPage = highestPage + 1
console.log('Next page', nextPage) logger.debug('Next page', nextPage)
if (hasMore) { if (hasMore) {
setPages((prev) => { setPages((prev) => {
@ -185,7 +189,7 @@ const DashboardTable = forwardRef(
const lowestPage = Math.min(...pages.map((p) => p.pageNum)) const lowestPage = Math.min(...pages.map((p) => p.pageNum))
const prevPage = lowestPage - 1 const prevPage = lowestPage - 1
console.log( logger.debug(
'Down', 'Down',
scrollHeight - scrollTop - clientHeight < 100, scrollHeight - scrollTop - clientHeight < 100,
lazyLoading lazyLoading
@ -197,7 +201,7 @@ const DashboardTable = forwardRef(
target.scrollTop = scrollHeight / 2 target.scrollTop = scrollHeight / 2
}, 0) }, 0)
setLazyLoading(true) setLazyLoading(true)
console.log('Loading next page...') logger.debug('Loading next page...')
loadNextPage() loadNextPage()
} }
@ -207,7 +211,7 @@ const DashboardTable = forwardRef(
target.scrollTop = scrollHeight / 2 target.scrollTop = scrollHeight / 2
}, 0) }, 0)
setLazyLoading(true) setLazyLoading(true)
console.log('Loading previous page...') logger.debug('Loading previous page...')
loadPreviousPage() loadPreviousPage()
} }
}, },

View 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

View 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

View File

@ -3,7 +3,7 @@ import { Flex, Typography, Badge } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import FilamentStockIcon from '../../Icons/FilamentStockIcon' import FilamentStockIcon from '../../Icons/FilamentStockIcon'
import IdText from './IdText' import IdDisplay from './IdDisplay'
const { Text } = Typography const { Text } = Typography
@ -37,7 +37,7 @@ const FilamentStockDisplay = ({
{showColor && <Badge color={filamentStock.filament.color} />} {showColor && <Badge color={filamentStock.filament.color} />}
<Text>{`${filamentStock.filament?.name} (${filamentStock.currentNetWeight}g)`}</Text> <Text>{`${filamentStock.filament?.name} (${filamentStock.currentNetWeight}g)`}</Text>
{showId && ( {showId && (
<IdText <IdDisplay
id={filamentStock._id} id={filamentStock._id}
longId={longId} longId={longId}
type={'filamentstock'} type={'filamentstock'}

View File

@ -1,24 +1,16 @@
// PrinterSelect.js // PrinterSelect.js
import React from 'react' import React from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { import { Flex, Typography, Space, Popover } from 'antd'
Flex,
Typography,
Button,
Tooltip,
message,
Space,
Popover
} from 'antd'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import CopyIcon from '../../Icons/CopyIcon' import CopyButton from './CopyButton'
import SpotlightTooltip from './SpotlightTooltip' import SpotlightTooltip from './SpotlightTooltip'
import { getTypeMeta } from '../utils/Utils' import { getTypeMeta } from '../utils/Utils'
const { Text, Link } = Typography const { Text, Link } = Typography
const IdText = ({ const IdDisplay = ({
id, id,
type, type,
showCopy = true, showCopy = true,
@ -26,7 +18,6 @@ const IdText = ({
showHyperlink = false, showHyperlink = false,
showSpotlight = true showSpotlight = true
}) => { }) => {
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate() const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
@ -36,6 +27,10 @@ const IdText = ({
const IconComponent = meta.icon const IconComponent = meta.icon
const icon = <IconComponent style={{ paddingTop: '4px' }} /> const icon = <IconComponent style={{ paddingTop: '4px' }} />
if (!id) {
return <Text type='secondary'>n/a</Text>
}
id = typeof id === 'string' ? id.toString().toUpperCase() : 'XXXXXX' id = typeof id === 'string' ? id.toString().toUpperCase() : 'XXXXXX'
var displayId = prefix + ':' + id var displayId = prefix + ':' + id
var copyId = prefix + ':' + id var copyId = prefix + ':' + id
@ -45,9 +40,7 @@ const IdText = ({
} }
return ( return (
<Flex align={'center'} gap={'small'} className='idtext'> <Flex align={'center'} className='iddisplay'>
{contextHolder}
{showHyperlink && {showHyperlink &&
(showSpotlight ? ( (showSpotlight ? (
<Popover <Popover
@ -67,6 +60,7 @@ const IdText = ({
navigate(hyperlink) navigate(hyperlink)
} }
}} }}
style={{ marginRight: 6 }}
> >
<Text code ellipsis> <Text code ellipsis>
<Space size={4}> <Space size={4}>
@ -105,7 +99,7 @@ const IdText = ({
placement='topLeft' placement='topLeft'
arrow={false} arrow={false}
> >
<Text code ellipsis> <Text code ellipsis style={{ marginRight: 6 }}>
<Space size={4}> <Space size={4}>
{icon} {icon}
{displayId} {displayId}
@ -121,59 +115,18 @@ const IdText = ({
</Text> </Text>
))} ))}
{showCopy && ( {showCopy && (
<Tooltip title='Copy ID' arrow={false}> <CopyButton
<Button text={copyId}
icon={<CopyIcon style={{ fontSize: '14px' }} />} tooltip='Copy ID'
type='text' style={{ marginLeft: 0 }}
style={{ height: '22px' }} iconStyle={{ fontSize: '14px' }}
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>
)} )}
</Flex> </Flex>
) )
} }
IdText.propTypes = { IdDisplay.propTypes = {
id: PropTypes.string, id: PropTypes.string,
type: PropTypes.string, type: PropTypes.string,
showCopy: PropTypes.bool, showCopy: PropTypes.bool,
@ -182,4 +135,4 @@ IdText.propTypes = {
showSpotlight: PropTypes.bool showSpotlight: PropTypes.bool
} }
export default IdText export default IdDisplay

View File

@ -2,7 +2,7 @@ import PropTypes from 'prop-types'
import { Progress, Flex, Typography, Space } from 'antd' import { Progress, Flex, Typography, Space } from 'antd'
import React, { useState, useContext, useEffect } from 'react' import React, { useState, useContext, useEffect } from 'react'
import { PrintServerContext } from '../context/PrintServerContext' import { PrintServerContext } from '../context/PrintServerContext'
import IdText from './IdText' import IdDisplay from './IdDisplay'
import StateTag from './StateTag' import StateTag from './StateTag'
const JobState = ({ const JobState = ({
@ -38,7 +38,7 @@ const JobState = ({
return ( return (
<Flex gap='small' align={'center'}> <Flex gap='small' align={'center'}>
{showId && ( {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>} {showQuantity && <Text>({job.quantity})</Text>}
{showStatus && ( {showStatus && (

View 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

View 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

View File

@ -1,10 +1,11 @@
import React, { useEffect, useState, useCallback } from 'react' import React, { useEffect, useState, useCallback } from 'react'
import PropTypes from 'prop-types' 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 axios from 'axios'
import { getTypeMeta } from '../utils/Utils' import { getTypeMeta } from '../utils/Utils'
import IdText from './IdText' import IdDisplay from './IdDisplay'
import CountryDisplay from './CountryDisplay' import CountryDisplay from './CountryDisplay'
import ReloadIcon from '../../Icons/ReloadIcon'
const { Text } = Typography const { Text } = Typography
const { SHOW_CHILD } = TreeSelect const { SHOW_CHILD } = TreeSelect
/** /**
@ -121,7 +122,7 @@ const ObjectSelect = ({
{Icon && <Icon />} {Icon && <Icon />}
{item?.color && <Badge color={item.color}></Badge>} {item?.color && <Badge color={item.color}></Badge>}
<Text ellipsis>{item.name || type.title}</Text> <Text ellipsis>{item.name || type.title}</Text>
<IdText id={item._id} longId={false} type={type} /> <IdDisplay id={item._id} longId={false} type={type} />
</Flex> </Flex>
) )
}, },
@ -139,7 +140,6 @@ const ObjectSelect = ({
// Build category nodes for each property level and load all available options // Build category nodes for each property level and load all available options
for (let i = 0; i < propertyOrder.length; i++) { for (let i = 0; i < propertyOrder.length; i++) {
const propertyName = propertyOrder[i] const propertyName = propertyOrder[i]
console.log('propname', propertyName)
let propertyValue let propertyValue
// Handle nested property access (e.g., 'filament.diameter') // Handle nested property access (e.g., 'filament.diameter')
@ -342,9 +342,6 @@ const ObjectSelect = ({
value = item value = item
} }
const title = renderTitle({ ...item, value }, isLeaf) const title = renderTitle({ ...item, value }, isLeaf)
console.log('propname', propertyName)
console.log('value', value)
console.log(item)
return { return {
id: value, id: value,
pId: node.id, pId: node.id,
@ -401,7 +398,6 @@ const ObjectSelect = ({
onChange(node ? node.raw : val, selectedOptions) onChange(node ? node.raw : val, selectedOptions)
} }
} }
console.log('val', val)
setDefaultValue(val) setDefaultValue(val)
} }
@ -486,17 +482,18 @@ const ObjectSelect = ({
]) ])
return error ? ( return error ? (
<div style={{ color: 'red', padding: 8 }}> <Space.Compact style={{ width: '100%' }}>
Failed to load data.{' '} <Input value='Failed to load data.' status='error' disabled />
<button
<Button
icon={<ReloadIcon />}
onClick={() => { onClick={() => {
setError(false) setError(false)
setTreeData([]) setTreeData([])
}} }}
> danger
Retry />
</button> </Space.Compact>
</div>
) : ( ) : (
<TreeSelect <TreeSelect
treeDataSimpleMode treeDataSimpleMode
@ -505,7 +502,7 @@ const ObjectSelect = ({
treeData={treeData} treeData={treeData}
onChange={handleOnChange} onChange={handleOnChange}
loading={loading} loading={loading}
value={defaultValue} value={loading ? 'Loading...' : defaultValue}
showSearch={showSearch} showSearch={showSearch}
onSearch={showSearch ? handleSearch : undefined} onSearch={showSearch ? handleSearch : undefined}
treeCheckable={treeCheckable} treeCheckable={treeCheckable}

View File

@ -2,7 +2,7 @@ import React from 'react'
import { Table } from 'antd' import { Table } from 'antd'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import IdText from './IdText' import IdDisplay from './IdDisplay'
import PartIcon from '../../Icons/PartIcon' import PartIcon from '../../Icons/PartIcon'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
@ -28,7 +28,9 @@ const PartsTable = ({ data = [], loading = false, showHeader = false }) => {
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 180, width: 180,
render: (text) => <IdText id={text} type={'part'} showHyperlink={true} /> render: (text) => (
<IdDisplay id={text} type={'part'} showHyperlink={true} />
)
} }
] ]

View File

@ -41,7 +41,7 @@ const PrinterMovementPanel = ({ printerId }) => {
const handleHomeAxisClick = (axis) => { const handleHomeAxisClick = (axis) => {
if (printServer) { if (printServer) {
console.log('Homeing Axis:', axis) logger.debug('Homeing Axis:', axis)
printServer.emit('printer.gcode.script', { printServer.emit('printer.gcode.script', {
printerId, printerId,
script: `G28 ${axis}` script: `G28 ${axis}`
@ -52,7 +52,7 @@ const PrinterMovementPanel = ({ printerId }) => {
const handleMoveAxisClick = (axis, minus) => { const handleMoveAxisClick = (axis, minus) => {
const distanceValue = !minus ? posValue * -1 : posValue const distanceValue = !minus ? posValue * -1 : posValue
if (printServer) { if (printServer) {
console.log('Moving Axis:', axis, distanceValue) logger.debug('Moving Axis:', axis, distanceValue)
printServer.emit('printer.gcode.script', { printServer.emit('printer.gcode.script', {
printerId, printerId,
script: `_CLIENT_LINEAR_MOVE ${axis}=${distanceValue} F=${rateValue}` script: `_CLIENT_LINEAR_MOVE ${axis}=${distanceValue} F=${rateValue}`

View File

@ -12,7 +12,7 @@ const PrinterState = ({
printer, printer,
showProgress = true, showProgress = true,
showStatus = true, showStatus = true,
showPrinterName = true, showName = true,
showControls = true showControls = true
}) => { }) => {
const { printServer } = useContext(PrintServerContext) const { printServer } = useContext(PrintServerContext)
@ -43,7 +43,7 @@ const PrinterState = ({
return ( return (
<Flex gap='small' align={'center'}> <Flex gap='small' align={'center'}>
{showPrinterName && <Text>{printer.name}</Text>} {showName && <Text>{printer.name}</Text>}
{showStatus && ( {showStatus && (
<Space> <Space>
<StateTag state={currentState.type} /> <StateTag state={currentState.type} />
@ -122,7 +122,7 @@ PrinterState.propTypes = {
}), }),
showProgress: PropTypes.bool, showProgress: PropTypes.bool,
showStatus: PropTypes.bool, showStatus: PropTypes.bool,
showPrinterName: PropTypes.bool, showName: PropTypes.bool,
showControls: PropTypes.bool showControls: PropTypes.bool
} }

View File

@ -97,14 +97,12 @@ const PrinterTemperaturePanel = ({
} }
} }
if (printServer?.connected == true) { if (printServer?.connected == true) {
console.log('Printer Temperature Panel is subscribing...')
printServer.emit('printer.objects.subscribe', params) printServer.emit('printer.objects.subscribe', params)
printServer.emit('printer.objects.query', params) printServer.emit('printer.objects.query', params)
printServer.on('notify_status_update', notifyTemperatureStatusUpdate) printServer.on('notify_status_update', notifyTemperatureStatusUpdate)
} }
return () => { return () => {
if (printServer && shouldUnsubscribe == true) { if (printServer && shouldUnsubscribe == true) {
console.log('Printer Temperature Panel is unsubscribing...')
printServer.off('notify_status_update', notifyTemperatureStatusUpdate) printServer.off('notify_status_update', notifyTemperatureStatusUpdate)
printServer.emit('printer.objects.unsubscribe', params) printServer.emit('printer.objects.unsubscribe', params)
} }
@ -113,7 +111,6 @@ const PrinterTemperaturePanel = ({
const handleSetTemperatureClick = (target, value) => { const handleSetTemperatureClick = (target, value) => {
if (printServer) { if (printServer) {
console.log('printer.gcode.script', target, value)
printServer.emit('printer.gcode.script', { printServer.emit('printer.gcode.script', {
printerId, printerId,
script: `SET_HEATER_TEMPERATURE HEATER=${target} TARGET=${value}` script: `SET_HEATER_TEMPERATURE HEATER=${target} TARGET=${value}`

View 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

View File

@ -15,7 +15,7 @@ import React, { useEffect, useState, useContext, useCallback } from 'react'
import axios from 'axios' import axios from 'axios'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import config from '../../../config' import config from '../../../config'
import IdText from './IdText' import IdDisplay from './IdDisplay'
import TimeDisplay from './TimeDisplay' import TimeDisplay from './TimeDisplay'
import { Tag } from 'antd' import { Tag } from 'antd'
import InfoCircleIcon from '../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../Icons/InfoCircleIcon'
@ -93,7 +93,7 @@ const SpotlightTooltip = ({ query, type }) => {
const renderValue = (key, value) => { const renderValue = (key, value) => {
if (key === '_id' || key === 'id') { if (key === '_id' || key === 'id') {
return ( return (
<IdText <IdDisplay
id={value} id={value}
type={type} type={type}
showCopy={true} showCopy={true}
@ -108,7 +108,7 @@ const SpotlightTooltip = ({ query, type }) => {
<PrinterState <PrinterState
printer={spotlightData} printer={spotlightData}
showControls={false} showControls={false}
showPrinterName={false} showName={false}
/> />
) )
} }

View File

@ -1,7 +1,7 @@
import React, { useEffect, useContext, useState } from 'react' import React, { useEffect, useContext, useState } from 'react'
import { Table, Typography } from 'antd' import { Table, Typography } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import IdText from './IdText' import IdDisplay from './IdDisplay'
import { AuditOutlined } from '@ant-design/icons' import { AuditOutlined } from '@ant-design/icons'
import { PrintServerContext } from '../context/PrintServerContext' import { PrintServerContext } from '../context/PrintServerContext'
import moment from 'moment' import moment from 'moment'
@ -22,7 +22,6 @@ const StockEventTable = ({ stockEvents }) => {
if (printServer && !initialized) { if (printServer && !initialized) {
setInitialized(true) setInitialized(true)
printServer.on('notify_stockevent_update', (updateData) => { printServer.on('notify_stockevent_update', (updateData) => {
console.log('Received stock event update:', updateData)
setStockEventsData((prevData) => { setStockEventsData((prevData) => {
return prevData.map((stockEvent) => { return prevData.map((stockEvent) => {
if (stockEvent?._id) { if (stockEvent?._id) {
@ -42,7 +41,6 @@ const StockEventTable = ({ stockEvents }) => {
return () => { return () => {
if (printServer && initialized) { if (printServer && initialized) {
console.log('Deregistering stock event update listener')
printServer.off('notify_stockevent_update') printServer.off('notify_stockevent_update')
} }
} }
@ -138,7 +136,7 @@ const StockEventTable = ({ stockEvents }) => {
render: (record) => { render: (record) => {
if (record.subJob) { if (record.subJob) {
return ( return (
<IdText <IdDisplay
id={record.subJob.number.toString().padStart(6, '0')} id={record.subJob.number.toString().padStart(6, '0')}
longId={false} longId={false}
type={'subjob'} type={'subjob'}
@ -147,7 +145,7 @@ const StockEventTable = ({ stockEvents }) => {
} }
if (record.stockAudit) { if (record.stockAudit) {
return ( return (
<IdText <IdDisplay
id={record.stockAudit._id} id={record.stockAudit._id}
longId={false} longId={false}
type={'stockaudit'} type={'stockaudit'}
@ -164,7 +162,7 @@ const StockEventTable = ({ stockEvents }) => {
render: (record) => { render: (record) => {
if (record.subJob) { if (record.subJob) {
return ( return (
<IdText <IdDisplay
id={record.job._id} id={record.job._id}
longId={false} longId={false}
type={'job'} type={'job'}

View File

@ -23,11 +23,9 @@ const SubJobCounter = ({
useEffect(() => { useEffect(() => {
if (printServer && !initialized && job?.id) { if (printServer && !initialized && job?.id) {
setInitialized(true) setInitialized(true)
console.log('on notify_subjob_update')
printServer.on('notify_subjob_update', (statusUpdate) => { printServer.on('notify_subjob_update', (statusUpdate) => {
for (const subJob of job.subJobs) { for (const subJob of job.subJobs) {
if (statusUpdate?._id === subJob.id && statusUpdate?.state) { if (statusUpdate?._id === subJob.id && statusUpdate?.state) {
console.log('statusUpdate', statusUpdate)
setSubJobs((prev) => [...prev, statusUpdate]) setSubJobs((prev) => [...prev, statusUpdate])
} }
} }
@ -35,7 +33,6 @@ const SubJobCounter = ({
} }
return () => { return () => {
if (printServer && initialized) { if (printServer && initialized) {
console.log('off notify_subjob_update')
printServer.off('notify_subjob_update') printServer.off('notify_subjob_update')
} }
} }

View File

@ -3,11 +3,15 @@ import { Progress, Flex, Button, Space, Tooltip } from 'antd' // eslint-disable-
import { CaretLeftOutlined } from '@ant-design/icons' // eslint-disable-line import { CaretLeftOutlined } from '@ant-design/icons' // eslint-disable-line
import React, { useState, useContext, useEffect } from 'react' import React, { useState, useContext, useEffect } from 'react'
import { PrintServerContext } from '../context/PrintServerContext' import { PrintServerContext } from '../context/PrintServerContext'
import IdText from './IdText' import IdDisplay from './IdDisplay'
import StateTag from './StateTag' import StateTag from './StateTag'
import XMarkIcon from '../../Icons/XMarkIcon' import XMarkIcon from '../../Icons/XMarkIcon'
import PauseIcon from '../../Icons/PauseIcon' import PauseIcon from '../../Icons/PauseIcon'
import BinIcon from '../../Icons/BinIcon' import BinIcon from '../../Icons/BinIcon'
import config from '../../../config'
import loglevel from 'loglevel'
const logger = loglevel.getLogger('SubJobState')
logger.setLevel(config.logLevel)
const SubJobState = ({ const SubJobState = ({
subJob, subJob,
@ -28,17 +32,17 @@ const SubJobState = ({
useEffect(() => { useEffect(() => {
if (printServer && !initialized && subJob?._id) { if (printServer && !initialized && subJob?._id) {
setInitialized(true) setInitialized(true)
console.log('on notify_subjob_update') logger.debug('on notify_subjob_update')
printServer.on('notify_subjob_update', (statusUpdate) => { printServer.on('notify_subjob_update', (statusUpdate) => {
if (statusUpdate?._id === subJob._id && statusUpdate?.state) { if (statusUpdate?._id === subJob._id && statusUpdate?.state) {
console.log('statusUpdate', statusUpdate) logger.debug('statusUpdate', statusUpdate)
setCurrentState(statusUpdate.state) setCurrentState(statusUpdate.state)
} }
}) })
} }
return () => { return () => {
if (printServer && initialized) { if (printServer && initialized) {
console.log('off notify_subjob_update') logger.debug('off notify_subjob_update')
printServer.off('notify_subjob_update') printServer.off('notify_subjob_update')
} }
} }
@ -47,7 +51,12 @@ const SubJobState = ({
return ( return (
<Flex gap='small' align={'center'}> <Flex gap='small' align={'center'}>
{showId && ( {showId && (
<IdText id={subJob._id} showCopy={false} type='subjob' longId={false} /> <IdDisplay
id={subJob._id}
showCopy={false}
type='subjob'
longId={false}
/>
)} )}
{showStatus && ( {showStatus && (
<Space> <Space>
@ -134,7 +143,7 @@ const SubJobState = ({
<Tooltip title='Delete' arrow={false}> <Tooltip title='Delete' arrow={false}>
<Button <Button
onClick={() => { onClick={() => {
console.log('Hello') logger.debug('Hello')
}} }}
type='text' type='text'
style={{ height: 'unset' }} style={{ height: 'unset' }}

View File

@ -114,7 +114,7 @@ const SubJobsTree = ({ jobData, loading }) => {
// Add printServer.io event listener for deployment updates // Add printServer.io event listener for deployment updates
if (printServer) { if (printServer) {
printServer.on('notify_deployment_update', (updateData) => { printServer.on('notify_deployment_update', (updateData) => {
console.log('Received deployment update:', updateData) logger.debug('Received deployment update:', updateData)
setCurrentJobData((prevData) => { setCurrentJobData((prevData) => {
if (!prevData) return prevData if (!prevData) return prevData
@ -151,7 +151,7 @@ const SubJobsTree = ({ jobData, loading }) => {
printServer.on('notify_subjob_update', (updateData) => { printServer.on('notify_subjob_update', (updateData) => {
// Handle sub-job updates // Handle sub-job updates
if (updateData.subJobId) { if (updateData.subJobId) {
console.log('Received subjob update:', updateData) logger.debug('Received subjob update:', updateData)
setCurrentJobData((prevData) => { setCurrentJobData((prevData) => {
if (!prevData) return prevData if (!prevData) return prevData
return { return {

View 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

View 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

View File

@ -55,6 +55,10 @@ const TimeDisplay = ({
} }
}, [dateTime, showSince]) }, [dateTime, showSince])
if (!dateTime) {
return <Text type='secondary'>n/a</Text>
}
var dateFormat = '' var dateFormat = ''
if (showDate == true) { if (showDate == true) {
dateFormat += 'YYYY-MM-DD ' dateFormat += 'YYYY-MM-DD '

View 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

View File

@ -10,13 +10,14 @@ import io from 'socket.io-client'
import { message, notification, Modal, Space, Button } from 'antd' import { message, notification, Modal, Space, Button } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { AuthContext } from './AuthContext' import { AuthContext } from './AuthContext'
import config from '../../../config'
import loglevel from 'loglevel'
import axios from 'axios' import axios from 'axios'
import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon' import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon'
import ReloadIcon from '../../Icons/ReloadIcon' import ReloadIcon from '../../Icons/ReloadIcon'
const log = loglevel.getLogger('Api Server') import config from '../../../config'
log.setLevel(config.logLevel) import loglevel from 'loglevel'
const logger = loglevel.getLogger('ApiServerContext')
logger.setLevel(config.logLevel)
const ApiServerContext = createContext() const ApiServerContext = createContext()
@ -34,7 +35,7 @@ const ApiServerProvider = ({ children }) => {
useEffect(() => { useEffect(() => {
if (token) { 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, { const newSocket = io(config.apiServerUrl, {
reconnectionAttempts: 3, reconnectionAttempts: 3,
@ -45,18 +46,18 @@ const ApiServerProvider = ({ children }) => {
setConnecting(true) setConnecting(true)
newSocket.on('connect', () => { newSocket.on('connect', () => {
log.debug('Api Server connected') logger.debug('Api Server connected')
setConnecting(false) setConnecting(false)
setError(null) setError(null)
}) })
newSocket.on('disconnect', () => { newSocket.on('disconnect', () => {
log.debug('Api Server disconnected') logger.debug('Api Server disconnected')
setError('Api Server disconnected') setError('Api Server disconnected')
}) })
newSocket.on('connect_error', (err) => { 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) messageApi.error('Api Server connection error: ' + err.message)
setError('Api Server connection error') setError('Api Server connection error')
}) })
@ -69,7 +70,7 @@ const ApiServerProvider = ({ children }) => {
}) })
newSocket.on('error', (err) => { newSocket.on('error', (err) => {
log.error('Api Server error:', err) logger.error('Api Server error:', err)
setError('Api Server error') setError('Api Server error')
}) })
@ -78,37 +79,37 @@ const ApiServerProvider = ({ children }) => {
// Clean up function // Clean up function
return () => { return () => {
if (socketRef.current) { if (socketRef.current) {
log.debug('Cleaning up api server connection...') logger.debug('Cleaning up api server connection...')
socketRef.current.disconnect() socketRef.current.disconnect()
socketRef.current = null socketRef.current = null
} }
} }
} else if (!token && socketRef.current) { } 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.disconnect()
socketRef.current = null socketRef.current = null
} }
}, [token, messageApi]) }, [token, messageApi])
const lockObject = (id, type) => { const lockObject = (id, type) => {
log.debug('Locking ' + id) logger.debug('Locking ' + id)
if (socketRef.current && socketRef.current.connected) { if (socketRef.current && socketRef.current.connected) {
socketRef.current.emit('lock', { _id: id, type: type }) 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) => { const unlockObject = (id, type) => {
log.debug('Unlocking ' + id) logger.debug('Unlocking ' + id)
if (socketRef.current && socketRef.current.connected == true) { if (socketRef.current && socketRef.current.connected == true) {
socketRef.current.emit('unlock', { _id: id, type: type }) 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) => { const fetchObjectLock = async (id, type) => {
if (socketRef.current && socketRef.current.connected == true) { 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) => { return new Promise((resolve) => {
socketRef.current.emit( socketRef.current.emit(
'getLock', 'getLock',
@ -117,11 +118,11 @@ const ApiServerProvider = ({ children }) => {
type: type type: type
}, },
(lockEvent) => { (lockEvent) => {
log.debug('Received lock event for object:', id, lockEvent) logger.debug('Received lock event for object:', id, lockEvent)
resolve(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) { if (socketRef.current && socketRef.current.connected == true) {
const eventHandler = (data) => { const eventHandler = (data) => {
if (data._id === id && data?.user !== userProfile._id) { if (data._id === id && data?.user !== userProfile._id) {
log.debug( logger.debug(
'Lock update received for object:', 'Lock update received for object:',
id, id,
'locked:', 'locked:',
@ -141,7 +142,7 @@ const ApiServerProvider = ({ children }) => {
} }
socketRef.current.on('notify_lock_update', eventHandler) 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 cleanup function
return () => offLockEvent(id, eventHandler) return () => offLockEvent(id, eventHandler)
@ -151,7 +152,7 @@ const ApiServerProvider = ({ children }) => {
const offLockEvent = (id, eventHandler) => { const offLockEvent = (id, eventHandler) => {
if (socketRef.current && socketRef.current.connected == true) { if (socketRef.current && socketRef.current.connected == true) {
socketRef.current.off('notify_lock_update', eventHandler) 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) { if (socketRef.current && socketRef.current.connected == true) {
const eventHandler = (data) => { const eventHandler = (data) => {
if (data._id === id && data?.user !== userProfile._id) { if (data._id === id && data?.user !== userProfile._id) {
log.debug( logger.debug(
'Update event received for object:', 'Update event received for object:',
id, id,
'updatedAt:', 'updatedAt:',
@ -170,7 +171,7 @@ const ApiServerProvider = ({ children }) => {
} }
socketRef.current.on('notify_object_update', eventHandler) 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 cleanup function
return () => offUpdateEvent(id, eventHandler) return () => offUpdateEvent(id, eventHandler)
@ -180,7 +181,7 @@ const ApiServerProvider = ({ children }) => {
const offUpdateEvent = (id, eventHandler) => { const offUpdateEvent = (id, eventHandler) => {
if (socketRef.current && socketRef.current.connected == true) { if (socketRef.current && socketRef.current.connected == true) {
socketRef.current.off('notify_update', eventHandler) 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 fetchObjectInfo = async (id, type) => {
const fetchUrl = `${config.backendUrl}/${type}s/${id}` const fetchUrl = `${config.backendUrl}/${type}s/${id}`
setFetchLoading(true) setFetchLoading(true)
log.debug('Fetching from ' + fetchUrl) logger.debug('Fetching from ' + fetchUrl)
try { try {
const response = await axios.get(fetchUrl, { const response = await axios.get(fetchUrl, {
headers: { headers: {
@ -213,7 +214,7 @@ const ApiServerProvider = ({ children }) => {
}) })
return response.data return response.data
} catch (err) { } 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 // Don't automatically show error - let the component handle it
throw err throw err
} finally { } finally {
@ -224,7 +225,7 @@ const ApiServerProvider = ({ children }) => {
// Update filament information // Update filament information
const updateObjectInfo = async (id, type, value) => { const updateObjectInfo = async (id, type, value) => {
const updateUrl = `${config.backendUrl}/${type}s/${id}` const updateUrl = `${config.backendUrl}/${type}s/${id}`
log.debug('Updating info for ' + id) logger.debug('Updating info for ' + id)
try { try {
const response = await axios.put(updateUrl, value, { const response = await axios.put(updateUrl, value, {
headers: { headers: {
@ -232,7 +233,7 @@ const ApiServerProvider = ({ children }) => {
}, },
withCredentials: true withCredentials: true
}) })
log.debug('Filament updated successfully') logger.debug('Filament updated successfully')
if (socketRef.current && socketRef.current.connected == true) { if (socketRef.current && socketRef.current.connected == true) {
await socketRef.current.emit('update', { await socketRef.current.emit('update', {
_id: id, _id: id,
@ -242,7 +243,7 @@ const ApiServerProvider = ({ children }) => {
} }
return response.data return response.data
} catch (err) { } 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 // Don't automatically show error - let the component handle it
throw err throw err
} }

View File

@ -7,6 +7,9 @@ import ExclamationOctogonIcon from '../../Icons/ExclamationOctagonIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import config from '../../../config' import config from '../../../config'
import AppError from '../../App/AppError' import AppError from '../../App/AppError'
import loglevel from 'loglevel'
const logger = loglevel.getLogger('ApiServerContext')
logger.setLevel(config.logLevel)
const AuthContext = createContext() const AuthContext = createContext()
@ -50,7 +53,7 @@ const AuthProvider = ({ children }) => {
}) })
if (response.status === 200 && response.data) { if (response.status === 200 && response.data) {
console.log('User is authenticated!') logger.debug('User is authenticated!')
setAuthenticated(true) setAuthenticated(true)
setToken(response.data.access_token) setToken(response.data.access_token)
setExpiresAt(response.data.expires_at) setExpiresAt(response.data.expires_at)
@ -60,7 +63,7 @@ const AuthProvider = ({ children }) => {
setAuthError('Failed to authenticate user.') setAuthError('Failed to authenticate user.')
} }
} catch (error) { } catch (error) {
console.log('Auth check failed', error) logger.debug('Auth check failed', error)
if (error.response?.status === 401) { if (error.response?.status === 401) {
setShowUnauthorizedModal(true) setShowUnauthorizedModal(true)
} else { } else {

View File

@ -16,7 +16,7 @@ import PropTypes from 'prop-types'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import PrinterState from '../common/PrinterState' import PrinterState from '../common/PrinterState'
import JobState from '../common/JobState' import JobState from '../common/JobState'
import IdText from '../common/IdText' import IdDisplay from '../common/IdDisplay'
import config from '../../../config' import config from '../../../config'
import { getTypeMeta, getPrefixMeta } from '../utils/Utils' import { getTypeMeta, getPrefixMeta } from '../utils/Utils'
@ -243,7 +243,6 @@ const SpotlightProvider = ({ children }) => {
if (!value || value.trim() === '') { if (!value || value.trim() === '') {
// Only clear the prefix if the input is completely empty // Only clear the prefix if the input is completely empty
if (value === '') { if (value === '') {
console.log('Clearing prefix')
setInputPrefix(null) setInputPrefix(null)
} }
if (formRef.current) { if (formRef.current) {
@ -278,7 +277,6 @@ const SpotlightProvider = ({ children }) => {
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
// If backspace is pressed and there's a prefix but the input is empty // If backspace is pressed and there's a prefix but the input is empty
if (e.key === 'Backspace' && inputPrefix && query === '') { if (e.key === 'Backspace' && inputPrefix && query === '') {
console.log('Clearing prefix on backspace')
// Clear the prefix // Clear the prefix
setInputPrefix(null) setInputPrefix(null)
// Prevent the default backspace behavior in this case // Prevent the default backspace behavior in this case
@ -462,7 +460,6 @@ const SpotlightProvider = ({ children }) => {
// Add more inference as needed // Add more inference as needed
} }
const meta = getTypeMeta(type) const meta = getTypeMeta(type)
console.log('meta', inputPrefix?.type)
const Icon = meta.icon const Icon = meta.icon
// Determine shortcut text // Determine shortcut text
@ -489,7 +486,7 @@ const SpotlightProvider = ({ children }) => {
{meta.type == 'printer' ? ( {meta.type == 'printer' ? (
<PrinterState <PrinterState
printer={item} printer={item}
showPrinterName={false} showName={false}
showProgress={false} showProgress={false}
showId={false} showId={false}
/> />
@ -520,7 +517,7 @@ const SpotlightProvider = ({ children }) => {
/> />
</Flex> </Flex>
) : null} ) : null}
<IdText <IdDisplay
id={item._id} id={item._id}
type={meta.type} type={meta.type}
longId={false} longId={false}

View File

@ -1,4 +1,8 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import config from '../../../config'
import loglevel from 'loglevel'
const logger = loglevel.getLogger('useTableScroll')
logger.setLevel(config.logLevel)
export const useTableScroll = ({ export const useTableScroll = ({
lazyLoading, lazyLoading,
@ -27,7 +31,7 @@ export const useTableScroll = ({
!lazyLoading && !lazyLoading &&
hasMore hasMore
) { ) {
console.log(loadedPages) logger.debug(loadedPages)
const lowestPage = Math.max(...loadedPages) const lowestPage = Math.max(...loadedPages)
const nextPage = lowestPage + 1 const nextPage = lowestPage + 1
if (!loadingPages.has(nextPage)) { if (!loadingPages.has(nextPage)) {
@ -39,7 +43,7 @@ export const useTableScroll = ({
items: page.items.filter((item) => !item.isSkeleton) items: page.items.filter((item) => !item.isSkeleton)
})) }))
const relevantPages = filteredPages.slice(-2) const relevantPages = filteredPages.slice(-2)
console.log('Pages after scroll down:', { logger.debug('Pages after scroll down:', {
current: currentLoadedPageNumber, current: currentLoadedPageNumber,
next: nextPage, next: nextPage,
keeping: relevantPages.map((p) => p.pageNum) keeping: relevantPages.map((p) => p.pageNum)
@ -78,7 +82,7 @@ export const useTableScroll = ({
items: page.items.filter((item) => !item.isSkeleton) items: page.items.filter((item) => !item.isSkeleton)
})) }))
console.log('Pages after scroll up:', { logger.debug('Pages after scroll up:', {
current: currentLoadedPageNumber, current: currentLoadedPageNumber,
prev: prevPage, prev: prevPage,
keeping: relevantPages.map((p) => p.pageNum) keeping: relevantPages.map((p) => p.pageNum)

View File

@ -55,14 +55,32 @@ export const TYPE_META = [
title: 'Printer', title: 'Printer',
prefix: 'PRN', prefix: 'PRN',
icon: PrinterIcon, icon: PrinterIcon,
url: (id) => `/dashboard/production/printers/info?printerId=${id}` url: (id) => `/dashboard/production/printers/info?printerId=${id}`,
properties: {
name: 'text'
}
}, },
{ {
type: 'filament', type: 'filament',
title: 'Filament', title: 'Filament',
prefix: 'FIL', prefix: 'FIL',
icon: FilamentIcon, 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', type: 'spool',
@ -104,7 +122,18 @@ export const TYPE_META = [
title: 'Vendor', title: 'Vendor',
prefix: 'VEN', prefix: 'VEN',
icon: VendorIcon, 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', type: 'subjob',

View 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

View 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

View 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

View 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

View 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

View 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

View File

@ -11,6 +11,6 @@ root.render(
) )
// If you want to start measuring performance in your app, pass a function // 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 // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals() reportWebVitals()