521 lines
16 KiB
JavaScript
521 lines
16 KiB
JavaScript
import React, { useState, useEffect, useRef } from 'react'
|
|
import { useLocation } from 'react-router-dom'
|
|
import axios from 'axios'
|
|
import {
|
|
Descriptions,
|
|
Spin,
|
|
Space,
|
|
Button,
|
|
message,
|
|
Typography,
|
|
Card,
|
|
Flex,
|
|
Form,
|
|
Input,
|
|
Checkbox,
|
|
InputNumber,
|
|
Switch,
|
|
Tag,
|
|
Collapse
|
|
} from 'antd'
|
|
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
|
import IdText from '../../common/IdText.jsx'
|
|
import { StlViewer } from 'react-stl-viewer'
|
|
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 TimeDisplay from '../../common/TimeDisplay.jsx'
|
|
|
|
const { Title } = Typography
|
|
|
|
const PartInfo = () => {
|
|
const [partData, setPartData] = useState(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState(null)
|
|
const location = useLocation()
|
|
const [messageApi, contextHolder] = message.useMessage()
|
|
const partId = new URLSearchParams(location.search).get('partId')
|
|
const [marginOrPrice, setMarginOrPrice] = useState(false)
|
|
const [useGlobalPricing, setUseGlobalPricing] = useState(true)
|
|
const [collapseState, updateCollapseState] = useCollapseState('PartInfo', {
|
|
info: true,
|
|
preview: true
|
|
})
|
|
|
|
const [partForm] = Form.useForm()
|
|
const [partFormValues, setPartFormValues] = useState({})
|
|
|
|
// Add a ref to store the object URL
|
|
const objectUrlRef = useRef(null)
|
|
// Add a ref to store the array buffer
|
|
const arrayBufferRef = useRef(null)
|
|
|
|
const [isEditing, setIsEditing] = useState(false)
|
|
const [fetchLoading, setFetchLoading] = useState(true)
|
|
|
|
const [partFileObjectId, setPartFileObjectId] = useState(null)
|
|
const [stlLoadError, setStlLoadError] = useState(null)
|
|
|
|
useEffect(() => {
|
|
async function fetchData() {
|
|
await fetchPartDetails()
|
|
setTimeout(async () => {
|
|
await fetchPartContent()
|
|
}, 1000)
|
|
}
|
|
if (partId) {
|
|
fetchData()
|
|
}
|
|
}, [partId])
|
|
|
|
useEffect(() => {
|
|
if (partData) {
|
|
partForm.setFieldsValue({
|
|
name: partData.name || '',
|
|
price: partData.price || null,
|
|
margin: partData.margin || null,
|
|
marginOrPrice: partData.marginOrPrice,
|
|
useGlobalPricing: partData.useGlobalPricing,
|
|
createdAt: partData.createdAt || null,
|
|
updatedAt: partData.updatedAt || null
|
|
})
|
|
setPartFormValues(partData)
|
|
}
|
|
}, [partData, partForm])
|
|
|
|
useEffect(() => {
|
|
setMarginOrPrice(partFormValues.marginOrPrice)
|
|
setUseGlobalPricing(partFormValues.useGlobalPricing)
|
|
}, [partFormValues])
|
|
|
|
const fetchPartDetails = async () => {
|
|
try {
|
|
setFetchLoading(true)
|
|
const response = await axios.get(
|
|
`http://localhost:8080/parts/${partId}`,
|
|
{
|
|
headers: {
|
|
Accept: 'application/json'
|
|
},
|
|
withCredentials: true
|
|
}
|
|
)
|
|
setPartData(response.data)
|
|
setError(null)
|
|
} catch (err) {
|
|
setError('Failed to fetch part details')
|
|
console.log(err)
|
|
messageApi.error('Failed to fetch part details')
|
|
} finally {
|
|
setFetchLoading(false)
|
|
}
|
|
}
|
|
|
|
const fetchPartContent = async () => {
|
|
if (fetchLoading == true) {
|
|
return
|
|
}
|
|
try {
|
|
setFetchLoading(true)
|
|
// Cleanup previous object URL if it exists
|
|
if (objectUrlRef.current) {
|
|
URL.revokeObjectURL(objectUrlRef.current)
|
|
objectUrlRef.current = null
|
|
}
|
|
const response = await axios.get(
|
|
`http://localhost:8080/parts/${partId}/content`,
|
|
{
|
|
withCredentials: true,
|
|
responseType: 'blob'
|
|
}
|
|
)
|
|
|
|
// Check file size before processing
|
|
const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB
|
|
if (response.data.size > MAX_FILE_SIZE) {
|
|
throw new Error(
|
|
`File size exceeds ${MAX_FILE_SIZE / (1024 * 1024)}MB limit`
|
|
)
|
|
}
|
|
|
|
// Convert blob to array buffer for better memory management
|
|
const arrayBuffer = await response.data.arrayBuffer()
|
|
|
|
// Store array buffer in ref for later cleanup
|
|
arrayBufferRef.current = arrayBuffer
|
|
|
|
// Create a new blob from the array buffer
|
|
const blob = new Blob([arrayBuffer], { type: response.data.type })
|
|
|
|
try {
|
|
// Create and store object URL
|
|
const objectUrl = URL.createObjectURL(blob)
|
|
objectUrlRef.current = objectUrl
|
|
|
|
// Update state with the new object URL
|
|
setPartFileObjectId(objectUrl)
|
|
setStlLoadError(null)
|
|
setError(null)
|
|
} catch (allocErr) {
|
|
setStlLoadError(
|
|
'Failed to load STL file: Array buffer allocation failed'
|
|
)
|
|
console.error('STL allocation error:', allocErr)
|
|
}
|
|
} catch (err) {
|
|
setError('Failed to fetch part content')
|
|
console.log(err)
|
|
messageApi.error('Failed to fetch part content')
|
|
} finally {
|
|
setFetchLoading(false)
|
|
}
|
|
}
|
|
|
|
const startEditing = () => {
|
|
updateCollapseState('info', true)
|
|
setIsEditing(true)
|
|
}
|
|
|
|
const cancelEditing = () => {
|
|
partForm.setFieldsValue({
|
|
name: partData?.name || ''
|
|
})
|
|
setIsEditing(false)
|
|
}
|
|
|
|
const updateInfo = async () => {
|
|
try {
|
|
const values = await partForm.validateFields()
|
|
setLoading(true)
|
|
|
|
await axios.put(`http://localhost:8080/parts/${partId}`, values, {
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
withCredentials: true
|
|
})
|
|
|
|
// Update the local state with the new values
|
|
setPartData({ ...partData, ...values })
|
|
setIsEditing(false)
|
|
messageApi.success('Part information updated successfully')
|
|
} catch (err) {
|
|
if (err.errorFields) {
|
|
// This is a form validation error
|
|
return
|
|
}
|
|
console.error('Failed to update part information:', err)
|
|
messageApi.error('Failed to update part information')
|
|
} finally {
|
|
fetchPartDetails()
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
if (fetchLoading) {
|
|
return (
|
|
<div style={{ textAlign: 'center', padding: '20px' }}>
|
|
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error || !partData) {
|
|
return (
|
|
<Space
|
|
direction='vertical'
|
|
style={{ width: '100%', textAlign: 'center' }}
|
|
>
|
|
<p>{error || 'Part not found'}</p>
|
|
<Button icon={<ReloadIcon />} onClick={fetchPartDetails}>
|
|
Retry
|
|
</Button>
|
|
</Space>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
|
{contextHolder}
|
|
<Collapse
|
|
ghost
|
|
collapsible='icon'
|
|
activeKey={collapseState.info ? ['1'] : []}
|
|
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
|
|
expandIcon={({ isActive }) => (
|
|
<CaretRightOutlined
|
|
rotate={isActive ? 90 : 0}
|
|
style={{ paddingTop: '9px' }}
|
|
/>
|
|
)}
|
|
>
|
|
<Collapse.Panel
|
|
header={
|
|
<Flex
|
|
align='center'
|
|
justify='space-between'
|
|
style={{ width: '100%' }}
|
|
>
|
|
<Title level={5} style={{ margin: 0 }}>
|
|
Part Information
|
|
</Title>
|
|
<Space>
|
|
{isEditing ? (
|
|
<>
|
|
<Button
|
|
icon={<CheckIcon />}
|
|
type='primary'
|
|
onClick={updateInfo}
|
|
loading={loading}
|
|
/>
|
|
<Button
|
|
icon={<XMarkIcon />}
|
|
onClick={cancelEditing}
|
|
disabled={loading}
|
|
/>
|
|
</>
|
|
) : (
|
|
<Button icon={<EditIcon />} onClick={startEditing} />
|
|
)}
|
|
</Space>
|
|
</Flex>
|
|
}
|
|
key='1'
|
|
>
|
|
<Form
|
|
form={partForm}
|
|
layout='vertical'
|
|
onValuesChange={(changedValues) =>
|
|
setPartFormValues((prevValues) => ({
|
|
...prevValues,
|
|
...changedValues
|
|
}))
|
|
}
|
|
initialValues={{
|
|
name: partData.name || '',
|
|
version: partData.version || '',
|
|
tags: partData.tags || []
|
|
}}
|
|
>
|
|
<Descriptions
|
|
bordered
|
|
column={{
|
|
xs: 1,
|
|
sm: 1,
|
|
md: 1,
|
|
lg: 2,
|
|
xl: 2,
|
|
xxl: 2
|
|
}}
|
|
>
|
|
<Descriptions.Item label='ID' span={1}>
|
|
{partData.id ? (
|
|
<IdText id={partData.id} type='part'></IdText>
|
|
) : (
|
|
'n/a'
|
|
)}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label='Created At'>
|
|
<TimeDisplay dateTime={partData.createdAt} showSince={true} />
|
|
</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>
|
|
) : (
|
|
partData.name || 'n/a'
|
|
)}
|
|
</Descriptions.Item>
|
|
|
|
<Descriptions.Item label='Updated At'>
|
|
<TimeDisplay dateTime={partData.updatedAt} showSince={true} />
|
|
</Descriptions.Item>
|
|
|
|
<Descriptions.Item label='Product Name' span={1}>
|
|
{partData.product.name || 'n/a'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label='Product ID' span={1}>
|
|
{(
|
|
<IdText
|
|
id={partData.product._id}
|
|
type={'product'}
|
|
showHyperlink={true}
|
|
/>
|
|
) || 'n/a'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item
|
|
label={!marginOrPrice ? 'Margin' : 'Price'}
|
|
span={1}
|
|
>
|
|
{isEditing && useGlobalPricing == false ? (
|
|
<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>
|
|
) : partData.margin &&
|
|
marginOrPrice == false &&
|
|
partData.useGlobalPricing == false ? (
|
|
partData.margin + '%'
|
|
) : partData.price &&
|
|
marginOrPrice == true &&
|
|
partData.useGlobalPricing == false ? (
|
|
'£' + partData.price
|
|
) : (
|
|
'n/a'
|
|
)}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label='Global Pricing'>
|
|
{isEditing ? (
|
|
<Form.Item
|
|
name='useGlobalPricing'
|
|
rules={[
|
|
{
|
|
required: true,
|
|
message: 'Please enter a global price method'
|
|
}
|
|
]}
|
|
style={{ margin: 0 }}
|
|
valuePropName='checked'
|
|
>
|
|
<Switch />
|
|
</Form.Item>
|
|
) : partData.useGlobalPricing == true ? (
|
|
<Tag color='success' icon={<CheckIcon />}>
|
|
Yes
|
|
</Tag>
|
|
) : partData.useGlobalPricing == false ? (
|
|
<Tag icon={<XMarkIcon />}>No</Tag>
|
|
) : (
|
|
'n/a'
|
|
)}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label='Version' span={1}>
|
|
{partData.version || 'n/a'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label='Tags'>
|
|
{partData.tags &&
|
|
partData.tags.map((tag, index) => (
|
|
<Tag key={index}>{tag}</Tag>
|
|
))}
|
|
</Descriptions.Item>
|
|
</Descriptions>
|
|
</Form>
|
|
</Collapse.Panel>
|
|
</Collapse>
|
|
|
|
<Collapse
|
|
ghost
|
|
collapsible='icon'
|
|
activeKey={collapseState.preview ? ['2'] : []}
|
|
onChange={(keys) => updateCollapseState('preview', keys.length > 0)}
|
|
expandIcon={({ isActive }) => (
|
|
<CaretRightOutlined
|
|
rotate={isActive ? 90 : 0}
|
|
style={{ paddingTop: '2px' }}
|
|
/>
|
|
)}
|
|
>
|
|
<Collapse.Panel
|
|
header={
|
|
<Title level={5} style={{ margin: 0 }}>
|
|
Part Preview
|
|
</Title>
|
|
}
|
|
key='2'
|
|
>
|
|
<Card styles={{ body: { padding: '10px' } }}>
|
|
{stlLoadError ? (
|
|
<div
|
|
style={{
|
|
height: '40vw',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: '#f5f5f5'
|
|
}}
|
|
>
|
|
<Space direction='vertical' align='center'>
|
|
<XMarkIcon style={{ fontSize: '24px', color: '#ff4d4f' }} />
|
|
<Typography.Text type='danger'>
|
|
{stlLoadError}
|
|
</Typography.Text>
|
|
</Space>
|
|
</div>
|
|
) : (
|
|
partFileObjectId && (
|
|
<StlViewer
|
|
url={partFileObjectId}
|
|
orbitControls
|
|
shadows
|
|
style={{ height: '40vw' }}
|
|
modelProps={{
|
|
color: '#008675'
|
|
}}
|
|
></StlViewer>
|
|
)
|
|
)}
|
|
</Card>
|
|
</Collapse.Panel>
|
|
</Collapse>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default PartInfo
|