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