diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000..08ebcd1
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,31 @@
+{
+ "extends": [
+ "eslint:recommended",
+ "plugin:prettier/recommended",
+ "plugin:react/recommended",
+ "plugin:react-hooks/recommended"
+ ],
+ "env": {
+ "browser": true, // Allows access to browser globals like `localStorage`
+ "node": true, // If you're also using Node.js
+ "es2021": true // Use ECMAScript 2021 features
+ },
+ "parserOptions": {
+ "ecmaVersion": "latest",
+ "sourceType": "module",
+ "ecmaFeatures": {
+ "jsx": true
+ }
+ },
+ "settings": {
+ "react": {
+ "version": "detect" // Automatically detect the React version
+ }
+ },
+ "rules": {
+ "camelcase": ["error", { "properties": "always" }],
+ "multiline-ternary": ["error", "never"],
+ "no-debugger": "off",
+ "no-console": "warn"
+ }
+}
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..625a5f5
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,7 @@
+{
+ "trailingComma": "none",
+ "tabWidth": 2,
+ "semi": false,
+ "singleQuote": true,
+ "jsxSingleQuote": true
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..f55da2d
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,5 @@
+{
+ "eslint.options": {
+ "overrideConfigFile": "./.eslintrc.json"
+ }
+}
diff --git a/public/gcode-worker.js b/public/gcode-worker.js
new file mode 100644
index 0000000..c8a97de
--- /dev/null
+++ b/public/gcode-worker.js
@@ -0,0 +1,84 @@
+// gcode-worker.js
+
+self.onmessage = function (event) {
+ const { configString } = event.data
+ const configObject = {}
+ let isThumbnailSection = false
+ let base64ImageData = ''
+ const lines = configString.split('\n')
+ const totalLines = lines.length
+
+ for (let i = 0; i < totalLines; i++) {
+ const line = lines[i]
+ let trimmedLine = line.trim()
+
+ // Skip empty lines or lines that are not part of the config
+ if (!trimmedLine || !trimmedLine.startsWith(';')) {
+ continue
+ }
+
+ // Remove the leading semicolon and trim the line
+ trimmedLine = trimmedLine.substring(1).trim()
+
+ // Handle thumbnail section
+ if (trimmedLine.startsWith('thumbnail begin')) {
+ isThumbnailSection = true
+ base64ImageData = '' // Reset image data
+ continue
+ } else if (trimmedLine.startsWith('thumbnail end')) {
+ isThumbnailSection = false
+ configObject.thumbnail = base64ImageData // Store base64 string as-is
+ continue
+ }
+
+ if (isThumbnailSection) {
+ base64ImageData += trimmedLine // Accumulate base64 data
+ continue
+ }
+
+ // Split the line into key and value parts
+ let [key, ...valueParts] = trimmedLine.split('=').map((part) => part.trim())
+
+ if (!key || !valueParts.length) {
+ continue
+ }
+
+ if (
+ key === 'end_gcode' ||
+ key === 'start_gcode' ||
+ key === 'start_filament_gcode' ||
+ key === 'end_filament_gcode'
+ ) {
+ continue
+ }
+
+ const value = valueParts.join('=').trim()
+
+ // Handle multi-line values (assuming they start and end with curly braces)
+ if (value.startsWith('{')) {
+ let multiLineValue = value
+ while (!multiLineValue.endsWith('}')) {
+ // Read the next line
+ const nextLine = lines[++i].trim()
+ multiLineValue += '\n' + nextLine
+ }
+ // Remove the starting and ending braces
+ configObject[key.replace(/\s+/g, '_').replace('(', '').replace(')', '')] =
+ multiLineValue.substring(1, multiLineValue.length - 1).trim()
+ } else {
+ key = key.replace('[', '').replace(']', '')
+ key = key.replace('(', '').replace(')', '')
+ // Regular key-value pair
+ configObject[key.replace(/\s+/g, '_')] = value
+ .replace('"', '')
+ .replace('"', '')
+ }
+
+ // Report progress
+ const progress = ((i + 1) / totalLines) * 100
+ self.postMessage({ type: 'progress', progress })
+ }
+
+ // Post the result back to the main thread
+ self.postMessage({ type: 'result', configObject })
+}
diff --git a/public/silent-check-sso.html b/public/silent-check-sso.html
new file mode 100644
index 0000000..dd91685
--- /dev/null
+++ b/public/silent-check-sso.html
@@ -0,0 +1,13 @@
+
+
+
+ Silent SSO Check
+
+
+
+
+
+
diff --git a/src/assets/icons/filamenticon.afdesign b/src/assets/icons/filamenticon.afdesign
new file mode 100644
index 0000000..3e5d04a
Binary files /dev/null and b/src/assets/icons/filamenticon.afdesign differ
diff --git a/src/assets/icons/filamenticon.svg b/src/assets/icons/filamenticon.svg
new file mode 100644
index 0000000..52ae03f
--- /dev/null
+++ b/src/assets/icons/filamenticon.svg
@@ -0,0 +1,8 @@
+
+
+
diff --git a/src/assets/icons/levelbedicon.afdesign b/src/assets/icons/levelbedicon.afdesign
new file mode 100644
index 0000000..ec82c90
Binary files /dev/null and b/src/assets/icons/levelbedicon.afdesign differ
diff --git a/src/assets/icons/levelbedicon.svg b/src/assets/icons/levelbedicon.svg
new file mode 100644
index 0000000..af737c7
--- /dev/null
+++ b/src/assets/icons/levelbedicon.svg
@@ -0,0 +1,5 @@
+
+
+
diff --git a/src/assets/icons/newwindowicon.afdesign b/src/assets/icons/newwindowicon.afdesign
new file mode 100644
index 0000000..556eb85
Binary files /dev/null and b/src/assets/icons/newwindowicon.afdesign differ
diff --git a/src/assets/icons/newwindowicon.svg b/src/assets/icons/newwindowicon.svg
new file mode 100644
index 0000000..38c396a
--- /dev/null
+++ b/src/assets/icons/newwindowicon.svg
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/assets/icons/particon.afdesign b/src/assets/icons/particon.afdesign
new file mode 100644
index 0000000..f15a3cd
Binary files /dev/null and b/src/assets/icons/particon.afdesign differ
diff --git a/src/assets/icons/particon.svg b/src/assets/icons/particon.svg
new file mode 100644
index 0000000..b78f23d
--- /dev/null
+++ b/src/assets/icons/particon.svg
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/assets/icons/producticon.afdesign b/src/assets/icons/producticon.afdesign
new file mode 100644
index 0000000..dfea98b
Binary files /dev/null and b/src/assets/icons/producticon.afdesign differ
diff --git a/src/assets/icons/producticon.svg b/src/assets/icons/producticon.svg
new file mode 100644
index 0000000..60e0d17
--- /dev/null
+++ b/src/assets/icons/producticon.svg
@@ -0,0 +1,5 @@
+
+
+
diff --git a/src/assets/icons/unloadicon.afdesign b/src/assets/icons/unloadicon.afdesign
new file mode 100644
index 0000000..f4ed7fc
Binary files /dev/null and b/src/assets/icons/unloadicon.afdesign differ
diff --git a/src/assets/icons/unloadicon.svg b/src/assets/icons/unloadicon.svg
new file mode 100644
index 0000000..6b4c0d8
--- /dev/null
+++ b/src/assets/icons/unloadicon.svg
@@ -0,0 +1,8 @@
+
+
+
diff --git a/src/components/Auth/_RegisterPasskey.jsx.old b/src/components/Auth/_RegisterPasskey.jsx.old
new file mode 100644
index 0000000..0e51c14
--- /dev/null
+++ b/src/components/Auth/_RegisterPasskey.jsx.old
@@ -0,0 +1,56 @@
+import React, { useState, useContext } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { Button, Typography, Flex } from 'antd'
+import { LockOutlined } from '@ant-design/icons'
+import { AuthContext } from './AuthContext'
+
+import PassKeysIcon from '../Icons/PassKeysIcon' // Adjust the path if necessary
+
+import './Auth.css'
+import AuthLayout from './AuthLayout'
+
+const { Text } = Typography
+
+const RegisterPasskey = () => {
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState('')
+ const navigate = useNavigate()
+ const { registerPasskey } = useContext(AuthContext)
+ const [init, setInit] = useState(false)
+
+ const handleRegisterPasskey = async (e) => {
+ const result = await registerPasskey(email, password)
+ if (result.successful === true) {
+ setTimeout(() => {
+ navigate('/dashboard/overview')
+ }, 500)
+ } else {
+ }
+ }
+
+ return (
+
+
+
+ Register a Passkey
+
+ Please setup a passkey in order to continue. The passkey may use
+ another device for encryption.
+
+
+ }
+ onClick={() => {
+ handleRegisterPasskey()
+ }}
+ >
+ Continue
+
+
+ )
+}
+
+export default RegisterPasskey
diff --git a/src/components/Dashboard/Inventory/Spools.jsx b/src/components/Dashboard/Inventory/Spools.jsx
new file mode 100644
index 0000000..a982896
--- /dev/null
+++ b/src/components/Dashboard/Inventory/Spools.jsx
@@ -0,0 +1,204 @@
+import React, { useEffect, useState, useContext } from 'react'
+import axios from 'axios'
+import moment from 'moment'
+
+import {
+ Table,
+ Button,
+ Flex,
+ Space,
+ Modal,
+ Drawer,
+ message,
+ Dropdown
+} from 'antd'
+import { EditOutlined, LoadingOutlined, PlusOutlined } from '@ant-design/icons'
+
+import { AuthContext } from '../../Auth/AuthContext'
+
+import NewSpool from './Spools/NewSpool.jsx'
+import EditSpool from './Spools/EditSpool.jsx'
+
+const Spools = () => {
+ const [messageApi, contextHolder] = message.useMessage()
+
+ const [spoolsData, setSpoolsData] = useState([])
+
+ const [pagination] = useState({
+ current: 1,
+ pageSize: 10,
+ total: 0
+ })
+
+ const [newSpoolOpen, setNewSpoolOpen] = useState(false)
+ const [loading, setLoading] = useState(true)
+
+ const [editSpoolOpen, setEditSpoolOpen] = useState(false)
+ const [editSpool, setEditSpool] = useState(null)
+
+ const { token } = useContext(AuthContext)
+
+ const fetchSpoolsData = async () => {
+ try {
+ const response = await axios.get('http://localhost:8080/spools', {
+ params: {
+ page: 1,
+ limit: 25
+ },
+ headers: {
+ Accept: 'application/json'
+ },
+ withCredentials: true // Important for including cookies
+ })
+ setSpoolsData(response.data)
+ setLoading(false)
+ } catch (err) {
+ messageApi.info(err)
+ }
+ }
+
+ useEffect(() => {
+ // Fetch initial data
+ //fetchSpoolsData()
+ }, [token])
+
+ // Column definitions
+ const columns = [
+ {
+ title: 'Name',
+ dataIndex: 'name',
+ key: 'name'
+ },
+ {
+ title: 'Filament',
+ dataIndex: 'filament',
+ key: 'filament',
+ render: (filament) => {
+ return filament?.name || 'N/A'
+ }
+ },
+ {
+ title: 'Current Weight',
+ dataIndex: 'currentWeight',
+ key: 'currentWeight',
+ render: (weight) => {
+ return weight ? weight + 'g' : 'N/A'
+ }
+ },
+ {
+ title: 'Barcode',
+ dataIndex: 'barcode',
+ key: 'barcode'
+ },
+ {
+ title: 'Updated At',
+ dataIndex: 'updatedat',
+ key: 'updatedAt',
+ render: (updatedAt) => {
+ if (updatedAt !== null) {
+ const formattedDate = moment(updatedAt.$date).format(
+ 'YYYY-MM-DD HH:mm:ss'
+ )
+ return {formattedDate}
+ } else {
+ return 'n/a'
+ }
+ }
+ },
+ {
+ title: 'Actions',
+ key: 'operation',
+ fixed: 'right',
+ width: 100,
+ render: (text, record) => {
+ return (
+
+ }
+ onClick={() => {
+ handleEdit(record._id)
+ }}
+ />
+
+ )
+ }
+ }
+ ]
+
+ const handleEdit = (id) => {
+ setEditSpool(
+ {
+ setEditSpoolOpen(false)
+ fetchSpoolsData()
+ setEditSpool(null)
+ }}
+ />
+ )
+ setEditSpoolOpen(true)
+ }
+
+ return (
+ <>
+
+ {contextHolder}
+
+
+ }
+ ],
+ onClick: ({ key }) => {
+ if (key === '1') {
+ setNewSpoolOpen(true)
+ }
+ }
+ }}
+ >
+
+
+
+ }}
+ />
+
+ {
+ setNewSpoolOpen(false)
+ }}
+ >
+ {
+ setNewSpoolOpen(false)
+ fetchSpoolsData()
+ }}
+ reset={newSpoolOpen}
+ />
+
+ {
+ setEditSpoolOpen(false)
+ }}
+ >
+ {editSpool}
+
+ >
+ )
+}
+
+export default Spools
diff --git a/src/components/Dashboard/Inventory/Spools/EditSpool.jsx b/src/components/Dashboard/Inventory/Spools/EditSpool.jsx
new file mode 100644
index 0000000..cf3d5fd
--- /dev/null
+++ b/src/components/Dashboard/Inventory/Spools/EditSpool.jsx
@@ -0,0 +1,450 @@
+import PropTypes from 'prop-types'
+import React, { useState, useEffect } from 'react'
+import axios from 'axios'
+import {
+ Form,
+ Input,
+ InputNumber,
+ Button,
+ message,
+ Typography,
+ Select,
+ Flex,
+ Steps,
+ Divider,
+ ColorPicker,
+ Upload,
+ Descriptions,
+ Badge
+} from 'antd'
+import { UploadOutlined, LinkOutlined } from '@ant-design/icons'
+
+const { Text } = Typography
+
+const EditSpool = ({ id, onOk }) => {
+ const [messageApi, contextHolder] = message.useMessage()
+ const [filaments, setFilaments] = useState([])
+
+ const [editSpoolLoading, setEditSpoolLoading] = useState(false)
+ const [currentStep, setCurrentStep] = useState(0)
+ const [nextEnabled, setNextEnabled] = useState(false)
+
+ const [editSpoolForm] = Form.useForm()
+ const [editSpoolFormValues, setEditSpoolFormValues] = useState(null)
+
+ const [imageList, setImageList] = useState([])
+
+ const editSpoolFormUpdateValues = Form.useWatch([], editSpoolForm)
+
+ useEffect(() => {
+ const fetchFilaments = async () => {
+ try {
+ const response = await axios.get('http://localhost:8080/filaments', {
+ withCredentials: true
+ })
+ setFilaments(response.data)
+ } catch (error) {
+ messageApi.error('Error fetching filaments: ' + error.message)
+ }
+ }
+ fetchFilaments()
+ }, [messageApi])
+
+ React.useEffect(() => {
+ editSpoolForm
+ .validateFields({
+ validateOnly: true
+ })
+ .then(() => setNextEnabled(true))
+ .catch(() => setNextEnabled(false))
+ }, [editSpoolForm, editSpoolFormUpdateValues])
+
+ useEffect(() => {
+ const fetchSpoolData = async () => {
+ try {
+ const response = await axios.get(`http://localhost:8080/spools/${id}`, {
+ withCredentials: true
+ })
+ const spoolData = response.data
+ setEditSpoolFormValues(spoolData)
+ editSpoolForm.setFieldsValue(spoolData)
+ if (spoolData.image) {
+ setImageList([
+ {
+ uid: '-1',
+ name: 'Spool Image',
+ status: 'done',
+ url: spoolData.image
+ }
+ ])
+ }
+ } catch (error) {
+ messageApi.error('Error fetching spool data: ' + error.message)
+ }
+ }
+
+ fetchSpoolData()
+ }, [id, editSpoolForm, messageApi])
+
+ const summaryItems = [
+ {
+ key: 'name',
+ label: 'Name',
+ children: editSpoolFormValues?.name
+ },
+ {
+ key: 'brand',
+ label: 'Brand',
+ children: editSpoolFormValues?.brand
+ },
+ {
+ key: 'type',
+ label: 'Material',
+ children: editSpoolFormValues?.type
+ },
+ {
+ key: 'price',
+ label: 'Price',
+ children: '£' + editSpoolFormValues?.price + ' per kg'
+ },
+ {
+ key: 'color',
+ label: 'Colour',
+ children: (
+
+ )
+ },
+ {
+ key: 'diameter',
+ label: 'Diameter',
+ children: editSpoolFormValues?.diameter + 'mm'
+ },
+ {
+ key: 'density',
+ label: 'Density',
+ children: editSpoolFormValues?.diameter + 'g/cm³'
+ },
+ {
+ key: 'image',
+ label: 'Image',
+ children: editSpoolFormValues?.image ? (
+
+ ) : null
+ },
+ {
+ key: 'url',
+ label: 'URL',
+ children: editSpoolFormValues?.url
+ },
+ {
+ key: 'barcode',
+ label: 'Barcode',
+ children: editSpoolFormValues?.barcode
+ },
+ {
+ key: 'filament',
+ label: 'Filament',
+ children: editSpoolFormValues?.filament?.name || 'N/A'
+ },
+ {
+ key: 'currentWeight',
+ label: 'Current Weight',
+ children: editSpoolFormValues?.currentWeight + 'g'
+ }
+ ]
+
+ const handleEditSpool = async () => {
+ setEditSpoolLoading(true)
+ try {
+ await axios.put(
+ `http://localhost:8080/spools/${id}`,
+ editSpoolFormValues,
+ {
+ withCredentials: true
+ }
+ )
+ messageApi.success('Spool updated successfully.')
+ onOk()
+ } catch (error) {
+ messageApi.error('Error updating spool: ' + error.message)
+ } finally {
+ setEditSpoolLoading(false)
+ }
+ }
+
+ const getBase64 = (file) => {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.readAsDataURL(file)
+ reader.onload = () => resolve(reader.result)
+ reader.onerror = (error) => reject(error)
+ })
+ }
+
+ const handleImageUpload = async ({ file, fileList }) => {
+ if (fileList.length === 0) {
+ setImageList(fileList)
+ editSpoolForm.setFieldsValue({ image: '' })
+ return
+ }
+ const base64 = await getBase64(file)
+ setEditSpoolFormValues((prevValues) => ({
+ ...prevValues,
+ image: base64
+ }))
+ fileList[0].name = 'Spool Image'
+ setImageList(fileList)
+ editSpoolForm.setFieldsValue({ image: base64 })
+ }
+
+ const steps = [
+ {
+ title: 'Required',
+ key: 'required',
+ content: (
+ <>
+
+ Required information:
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ if (!value) return '£'
+ return `£${value}`
+ }}
+ step={0.01}
+ style={{ width: '100%' }}
+ addonAfter='per kg'
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+ },
+ {
+ title: 'Optional',
+ key: 'optional',
+ content: (
+ <>
+
+
+
+
+
+ }>Upload
+
+
+
+ }
+ placeholder='https://example.com'
+ />
+
+ >
+ )
+ },
+ {
+ title: 'Summary',
+ key: 'summary',
+ content: (
+ <>
+
+ Please review the information:
+
+
+ >
+ )
+ }
+ ]
+
+ return (
+ <>
+ {contextHolder}
+
+ >
+ )
+}
+
+EditSpool.propTypes = {
+ id: PropTypes.string.isRequired,
+ onOk: PropTypes.func.isRequired
+}
+
+export default EditSpool
diff --git a/src/components/Dashboard/Inventory/Spools/NewSpool.jsx b/src/components/Dashboard/Inventory/Spools/NewSpool.jsx
new file mode 100644
index 0000000..1456d91
--- /dev/null
+++ b/src/components/Dashboard/Inventory/Spools/NewSpool.jsx
@@ -0,0 +1,443 @@
+import PropTypes from 'prop-types'
+import React, { useState, useEffect } from 'react'
+import axios from 'axios'
+import {
+ Form,
+ Input,
+ InputNumber,
+ Button,
+ message,
+ Typography,
+ Select,
+ Flex,
+ Steps,
+ Divider,
+ ColorPicker,
+ Upload,
+ Descriptions,
+ Badge
+} from 'antd'
+import { UploadOutlined, LinkOutlined } from '@ant-design/icons'
+
+const { Text } = Typography
+
+const initialNewSpoolForm = {
+ name: '',
+ brand: '',
+ type: '',
+ price: 0,
+ color: '#FFFFFF',
+ diameter: '1.75',
+ image: null,
+ url: '',
+ barcode: '',
+ filament: null,
+ currentWeight: 0
+}
+
+const NewSpool = ({ onOk, reset }) => {
+ const [messageApi, contextHolder] = message.useMessage()
+ const [filaments, setFilaments] = useState([])
+
+ const [newSpoolLoading, setNewSpoolLoading] = useState(false)
+ const [currentStep, setCurrentStep] = useState(0)
+ const [nextEnabled, setNextEnabled] = useState(false)
+
+ const [newSpoolForm] = Form.useForm()
+ const [newSpoolFormValues, setNewSpoolFormValues] =
+ useState(initialNewSpoolForm)
+
+ const [imageList, setImageList] = useState([])
+
+ const newSpoolFormUpdateValues = Form.useWatch([], newSpoolForm)
+
+ useEffect(() => {
+ const fetchFilaments = async () => {
+ try {
+ const response = await axios.get('http://localhost:8080/filaments', {
+ withCredentials: true
+ })
+ setFilaments(response.data)
+ } catch (error) {
+ messageApi.error('Error fetching filaments: ' + error.message)
+ }
+ }
+ fetchFilaments()
+ }, [messageApi])
+
+ React.useEffect(() => {
+ newSpoolForm
+ .validateFields({
+ validateOnly: true
+ })
+ .then(() => setNextEnabled(true))
+ .catch(() => setNextEnabled(false))
+ }, [newSpoolForm, newSpoolFormUpdateValues])
+
+ const summaryItems = [
+ {
+ key: 'name',
+ label: 'Name',
+ children: newSpoolFormValues.name
+ },
+ {
+ key: 'brand',
+ label: 'Brand',
+ children: newSpoolFormValues.brand
+ },
+ {
+ key: 'type',
+ label: 'Material',
+ children: newSpoolFormValues.type
+ },
+ {
+ key: 'price',
+ label: 'Price',
+ children: '£' + newSpoolFormValues.price + ' per kg'
+ },
+ {
+ key: 'color',
+ label: 'Colour',
+ children: (
+
+ )
+ },
+ {
+ key: 'diameter',
+ label: 'Diameter',
+ children: newSpoolFormValues.diameter + 'mm'
+ },
+ {
+ key: 'density',
+ label: 'Density',
+ children: newSpoolFormValues.diameter + 'g/cm³'
+ },
+ {
+ key: 'image',
+ label: 'Image',
+ children: (
+
+ )
+ },
+ {
+ key: 'url',
+ label: 'URL',
+ children: newSpoolFormValues.url
+ },
+ {
+ key: 'barcode',
+ label: 'Barcode',
+ children: newSpoolFormValues.barcode
+ },
+ {
+ key: 'filament',
+ label: 'Filament',
+ children: newSpoolFormValues.filament?.name || 'N/A'
+ },
+ {
+ key: 'currentWeight',
+ label: 'Current Weight',
+ children: newSpoolFormValues.currentWeight + 'g'
+ }
+ ]
+
+ React.useEffect(() => {
+ if (reset) {
+ newSpoolForm.resetFields()
+ }
+ }, [reset, newSpoolForm])
+
+ const handleNewSpool = async () => {
+ setNewSpoolLoading(true)
+ try {
+ await axios.post(`http://localhost:8080/spools`, newSpoolFormValues, {
+ withCredentials: true
+ })
+ messageApi.success('New spool created successfully.')
+ onOk()
+ } catch (error) {
+ messageApi.error('Error creating new spool: ' + error.message)
+ } finally {
+ setNewSpoolLoading(false)
+ }
+ }
+
+ const getBase64 = (file) => {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.readAsDataURL(file)
+ reader.onload = () => resolve(reader.result)
+ reader.onerror = (error) => reject(error)
+ })
+ }
+
+ const handleImageUpload = async ({ file, fileList }) => {
+ if (fileList.length === 0) {
+ setImageList(fileList)
+ newSpoolForm.setFieldsValue({ image: '' })
+ return
+ }
+ const base64 = await getBase64(file)
+ setNewSpoolFormValues((prevValues) => ({
+ ...prevValues,
+ image: base64
+ }))
+ fileList[0].name = 'Spool Image'
+ setImageList(fileList)
+ newSpoolForm.setFieldsValue({ image: base64 })
+ }
+
+ const steps = [
+ {
+ title: 'Required',
+ key: 'required',
+ content: (
+ <>
+
+ Required information:
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ if (!value) return '£'
+ return `£${value}`
+ }}
+ step={0.01}
+ style={{ width: '100%' }}
+ addonAfter='per kg'
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+ },
+ {
+ title: 'Optional',
+ key: 'optional',
+ content: (
+ <>
+
+ Optional information:
+
+
+
+
+
+
+ }>Upload
+
+
+
+ }
+ placeholder='https://example.com'
+ />
+
+ >
+ )
+ },
+ {
+ title: 'Summary',
+ key: 'summary',
+ content: (
+ <>
+
+ Please review the information:
+
+
+ >
+ )
+ }
+ ]
+
+ return (
+ <>
+ {contextHolder}
+
+ >
+ )
+}
+
+NewSpool.propTypes = {
+ onOk: PropTypes.func.isRequired,
+ reset: PropTypes.bool.isRequired
+}
+
+export default NewSpool
diff --git a/src/components/Dashboard/Management/Filaments.jsx b/src/components/Dashboard/Management/Filaments.jsx
new file mode 100644
index 0000000..6d53194
--- /dev/null
+++ b/src/components/Dashboard/Management/Filaments.jsx
@@ -0,0 +1,262 @@
+// src/filaments.js
+
+import React, { useEffect, useState, useContext, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+import axios from 'axios'
+import moment from 'moment'
+
+import {
+ Table,
+ Badge,
+ Button,
+ Flex,
+ Space,
+ Modal,
+ message,
+ Dropdown
+} from 'antd'
+import { createStyles } from 'antd-style'
+import {
+ LoadingOutlined,
+ PlusOutlined,
+ ReloadOutlined,
+ InfoCircleOutlined
+} from '@ant-design/icons'
+
+import { AuthContext } from '../../Auth/AuthContext'
+
+import NewFilament from './Filaments/NewFilament'
+import IdText from '../common/IdText'
+import FilamentIcon from '../../Icons/FilamentIcon'
+
+const useStyle = createStyles(({ css, token }) => {
+ const { antCls } = token
+ return {
+ customTable: css`
+ ${antCls}-table {
+ ${antCls}-table-container {
+ ${antCls}-table-body,
+ ${antCls}-table-content {
+ scrollbar-width: thin;
+ scrollbar-color: #eaeaea transparent;
+ scrollbar-gutter: stable;
+ }
+ }
+ }
+ `
+ }
+})
+
+const Filaments = () => {
+ const [messageApi, contextHolder] = message.useMessage()
+ const navigate = useNavigate()
+ const { styles } = useStyle()
+
+ const [filamentsData, setFilamentsData] = useState([])
+
+ const [newFilamentOpen, setNewFilamentOpen] = useState(false)
+ //const [newFilament, setNewFilament] = useState(null)
+
+ const [loading, setLoading] = useState(true)
+
+ const { authenticated } = useContext(AuthContext)
+
+ const fetchFilamentsData = useCallback(async () => {
+ try {
+ const response = await axios.get('http://localhost:8080/filaments', {
+ params: {
+ page: 1,
+ limit: 25
+ },
+ headers: {
+ Accept: 'application/json'
+ },
+ withCredentials: true // Important for including cookies
+ })
+ setFilamentsData(response.data)
+ setLoading(false)
+ //setPagination({ ...pagination, total: response.data.totalItems }); // Update total count
+ } catch (err) {
+ messageApi.info(err)
+ }
+ }, [messageApi])
+
+ useEffect(() => {
+ // Fetch initial data
+ if (authenticated) {
+ fetchFilamentsData()
+ }
+ }, [authenticated, fetchFilamentsData])
+
+ const getFilamentActionItems = (id) => {
+ return {
+ items: [
+ {
+ label: 'Info',
+ key: 'info',
+ icon:
+ }
+ ],
+ onClick: ({ key }) => {
+ if (key === 'info') {
+ navigate(`/management/filaments/info?filamentId=${id}`)
+ }
+ }
+ }
+ }
+
+ // Column definitions
+ const columns = [
+ {
+ title: '',
+ dataIndex: '',
+ key: 'icon',
+ width: 40,
+ fixed: 'left',
+ render: () =>
+ },
+ {
+ title: 'Name',
+ dataIndex: 'name',
+ key: 'name',
+ width: 200,
+ fixed: 'left'
+ },
+ {
+ title: 'ID',
+ dataIndex: '_id',
+ key: 'id',
+ width: 165,
+ render: (text) =>
+ },
+ {
+ title: 'Vendor',
+ dataIndex: 'brand',
+ key: 'brand',
+ width: 200
+ },
+ {
+ title: 'Material',
+ dataIndex: 'type',
+ width: 90,
+ key: 'material'
+ },
+ {
+ title: 'Price',
+ dataIndex: 'price',
+ width: 120,
+ key: 'price',
+ render: (price) => {
+ return '£' + price + ' per kg'
+ }
+ },
+ {
+ title: 'Colour',
+ dataIndex: 'color',
+ key: 'color',
+ width: 120,
+ render: (color) => {
+ return
+ }
+ },
+ {
+ title: 'Created At',
+ dataIndex: 'createdAt',
+ key: 'createdAt',
+ width: 180,
+ render: (createdAt) => {
+ if (createdAt) {
+ const formattedDate = moment(createdAt).format('YYYY-MM-DD HH:mm:ss')
+ return {formattedDate}
+ } else {
+ return 'n/a'
+ }
+ }
+ },
+ {
+ title: 'Actions',
+ key: 'actions',
+ fixed: 'right',
+ width: 150,
+ render: (text, record) => {
+ return (
+
+ }
+ onClick={() =>
+ navigate(`/management/filaments/info?filamentId=${record._id}`)
+ }
+ />
+
+
+
+
+ )
+ }
+ }
+ ]
+
+ const actionItems = {
+ items: [
+ {
+ label: 'New Filament',
+ key: 'newFilament',
+ icon:
+ },
+ { type: 'divider' },
+ {
+ label: 'Reload List',
+ key: 'reloadList',
+ icon:
+ }
+ ],
+ onClick: ({ key }) => {
+ if (key === 'reloadList') {
+ fetchFilamentsData()
+ } else if (key === 'newFilament') {
+ setNewFilamentOpen(true)
+ }
+ }
+ }
+
+ return (
+ <>
+
+ {contextHolder}
+
+
+
+
+
+ }}
+ scroll={{ y: 'calc(100vh - 270px)' }}
+ />
+
+ {
+ setNewFilamentOpen(false)
+ }}
+ destroyOnClose
+ >
+ {
+ setNewFilamentOpen(false)
+ fetchFilamentsData()
+ }}
+ reset={newFilamentOpen}
+ />
+
+ >
+ )
+}
+
+export default Filaments
diff --git a/src/components/Dashboard/Management/Filaments/FilamentInfo.jsx b/src/components/Dashboard/Management/Filaments/FilamentInfo.jsx
new file mode 100644
index 0000000..cf30b65
--- /dev/null
+++ b/src/components/Dashboard/Management/Filaments/FilamentInfo.jsx
@@ -0,0 +1,445 @@
+import React, { useState, useEffect } from 'react'
+import { useLocation } from 'react-router-dom'
+import axios from 'axios'
+import {
+ Descriptions,
+ Spin,
+ Space,
+ Button,
+ message,
+ Badge,
+ Typography,
+ Flex,
+ Form,
+ Input,
+ InputNumber,
+ ColorPicker,
+ Select
+} from 'antd'
+import {
+ LoadingOutlined,
+ ReloadOutlined,
+ EditOutlined,
+ CheckOutlined,
+ CloseOutlined,
+ ExportOutlined
+} from '@ant-design/icons'
+import IdText from '../../common/IdText'
+import moment from 'moment'
+
+const { Title, Link } = Typography
+
+const FilamentInfo = () => {
+ const [filamentData, setFilamentData] = useState(null)
+ const [fetchLoading, setFetchLoading] = useState(true)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const location = useLocation()
+ const [messageApi, contextHolder] = message.useMessage()
+ const filamentId = new URLSearchParams(location.search).get('filamentId')
+ const [isEditing, setIsEditing] = useState(false)
+ const [form] = Form.useForm()
+
+ useEffect(() => {
+ if (filamentId) {
+ fetchFilamentDetails()
+ }
+ }, [filamentId])
+
+ useEffect(() => {
+ if (filamentData) {
+ form.setFieldsValue({
+ name: filamentData.name || '',
+ brand: filamentData.brand || '',
+ type: filamentData.type || '',
+ price: filamentData.price || 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 fetchFilamentDetails = async () => {
+ try {
+ setFetchLoading(true)
+ const response = await axios.get(
+ `http://localhost:8080/filaments/${filamentId}`,
+ {
+ headers: {
+ Accept: 'application/json'
+ },
+ withCredentials: true
+ }
+ )
+ setFilamentData(response.data)
+ setError(null)
+ } catch (err) {
+ setError('Failed to fetch filament details')
+ messageApi.error('Failed to fetch filament details')
+ } finally {
+ setFetchLoading(false)
+ }
+ }
+
+ const startEditing = () => {
+ setIsEditing(true)
+ }
+
+ const cancelEditing = () => {
+ // Reset form values to original data
+ if (filamentData) {
+ form.setFieldsValue({
+ name: filamentData.name || '',
+ brand: filamentData.brand || '',
+ type: filamentData.type || '',
+ price: filamentData.price || null,
+ color: filamentData.color || '#000000',
+ diameter: filamentData.diameter || null,
+ density: filamentData.density || null,
+ url: filamentData.url || '',
+ barcode: filamentData.barcode || '',
+ emptySpoolWeight: filamentData.emptySpoolWeight || ''
+ })
+ }
+ setIsEditing(false)
+ }
+
+ const updateFilamentInfo = async () => {
+ try {
+ const values = await form.validateFields()
+ setLoading(true)
+
+ await axios.put(
+ `http://localhost:8080/filaments/${filamentId}`,
+ {
+ name: values.name,
+ brand: values.brand,
+ type: values.type,
+ price: values.price,
+ color: values.color,
+ diameter: values.diameter,
+ density: values.density,
+ url: values.url,
+ barcode: values.barcode,
+ emptySpoolWeight: values.emptySpoolWeight
+ },
+ {
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ withCredentials: true
+ }
+ )
+
+ // 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')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (fetchLoading) {
+ return (
+
+ } />
+
+ )
+ }
+
+ if (error || !filamentData) {
+ return (
+
+ {error || 'Filament not found'}
+ } onClick={fetchFilamentDetails}>
+ Retry
+
+
+ )
+ }
+
+ return (
+
+ {contextHolder}
+
+
+ Filament Information
+
+
+ {isEditing ? (
+ <>
+ }
+ type='primary'
+ onClick={updateFilamentInfo}
+ loading={loading}
+ >
+ }
+ onClick={cancelEditing}
+ disabled={loading}
+ >
+ >
+ ) : (
+ } onClick={startEditing}>
+ )}
+
+
+
+
+
+ )
+}
+
+export default FilamentInfo
diff --git a/src/components/Dashboard/Management/Filaments/NewFilament.jsx b/src/components/Dashboard/Management/Filaments/NewFilament.jsx
new file mode 100644
index 0000000..e6ee00e
--- /dev/null
+++ b/src/components/Dashboard/Management/Filaments/NewFilament.jsx
@@ -0,0 +1,432 @@
+import PropTypes from 'prop-types'
+import React, { useState } from 'react'
+import axios from 'axios'
+import {
+ Form,
+ Input,
+ InputNumber,
+ Button,
+ message,
+ Typography,
+ Select,
+ Flex,
+ Steps,
+ Col,
+ Row,
+ Divider,
+ ColorPicker,
+ Upload,
+ Descriptions,
+ Badge
+} from 'antd'
+import { UploadOutlined, LinkOutlined } from '@ant-design/icons'
+
+const { Title, Text } = Typography
+
+const initialNewFilamentForm = {
+ name: '',
+ brand: '',
+ type: '',
+ price: 0,
+ color: '#FFFFFF',
+ diameter: '1.75',
+ image: null,
+ url: '',
+ barcode: ''
+}
+
+const NewFilament = ({ onOk, reset }) => {
+ const [messageApi, contextHolder] = message.useMessage()
+
+ const [newFilamentLoading, setNewFilamentLoading] = useState(false)
+ const [currentStep, setCurrentStep] = useState(0)
+ const [nextEnabled, setNextEnabled] = useState(false)
+
+ const [newFilamentForm] = Form.useForm()
+ const [newFilamentFormValues, setNewFilamentFormValues] = useState(
+ initialNewFilamentForm
+ )
+
+ const [imageList, setImageList] = useState([])
+
+ const newFilamentFormUpdateValues = Form.useWatch([], newFilamentForm)
+
+ React.useEffect(() => {
+ newFilamentForm
+ .validateFields({
+ validateOnly: true
+ })
+ .then(() => setNextEnabled(true))
+ .catch(() => setNextEnabled(false))
+ }, [newFilamentForm, newFilamentFormUpdateValues])
+
+ const summaryItems = [
+ {
+ key: 'name',
+ label: 'Name',
+ children: newFilamentFormValues.name
+ },
+ {
+ key: 'brand',
+ label: 'Brand',
+ children: newFilamentFormValues.brand
+ },
+ {
+ key: 'type',
+ label: 'Material',
+ children: newFilamentFormValues.type
+ },
+ {
+ key: 'price',
+ label: 'Price',
+ children: '£' + newFilamentFormValues.price + ' per kg'
+ },
+ {
+ key: 'color',
+ label: 'Colour',
+ children: (
+
+ )
+ },
+ {
+ key: 'diameter',
+ label: 'Diameter',
+ children: newFilamentFormValues.diameter + 'mm'
+ },
+ {
+ key: 'density',
+ label: 'Density',
+ children: newFilamentFormValues.diameter + 'g/cm³'
+ },
+ {
+ key: 'image',
+ label: 'Image',
+ children: (
+
+ )
+ },
+ {
+ key: 'url',
+ label: 'URL',
+ children: newFilamentFormValues.url
+ },
+ {
+ key: 'barcode',
+ label: 'Barcode',
+ children: newFilamentFormValues.barcode
+ }
+ ]
+
+ React.useEffect(() => {
+ if (reset) {
+ newFilamentForm.resetFields()
+ }
+ }, [reset, newFilamentForm])
+
+ const handleNewFilament = async () => {
+ setNewFilamentLoading(true)
+ try {
+ await axios.post(
+ `http://localhost:8080/filaments`,
+ newFilamentFormValues,
+ {
+ withCredentials: true // Important for including cookies
+ }
+ )
+ messageApi.success('New filament created successfully.')
+ onOk()
+ } catch (error) {
+ messageApi.error('Error creating new filament: ' + error.message)
+ } finally {
+ setNewFilamentLoading(false)
+ }
+ }
+
+ const getBase64 = (file) => {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.readAsDataURL(file)
+ reader.onload = () => resolve(reader.result)
+ reader.onerror = (error) => reject(error)
+ })
+ }
+
+ const handleImageUpload = async ({ file, fileList }) => {
+ console.log(fileList)
+ if (fileList.length === 0) {
+ setImageList(fileList)
+ newFilamentForm.setFieldsValue({ image: '' })
+ return
+ }
+ const base64 = await getBase64(file)
+ setNewFilamentFormValues((prevValues) => ({
+ ...prevValues,
+ image: base64
+ }))
+ fileList[0].name = 'Filament Image'
+ setImageList(fileList)
+ newFilamentForm.setFieldsValue({ image: base64 })
+ }
+
+ const steps = [
+ {
+ title: 'Details',
+ key: 'details',
+ content: (
+ <>
+
+
+
+
+
+
+
+
+
+
+ {
+ if (!value) return '£'
+ return `£${value}`
+ }}
+ step={0.01}
+ style={{ width: '100%' }}
+ addonAfter='per kg'
+ />
+
+
+
+
+
+ {
+ if (!value) return ''
+ return `${value}`
+ }}
+ step={0.01}
+ style={{ width: '100%' }}
+ addonAfter='g/cm³'
+ />
+
+
+ {
+ if (!value) return ''
+ return `${value}`
+ }}
+ step={0.01}
+ style={{ width: '100%' }}
+ addonAfter='g'
+ />
+
+ >
+ )
+ },
+ {
+ title: 'Optional',
+ key: 'optional',
+ content: (
+ <>
+
+ Optional information:
+
+
+ {
+ return '#' + color.toHex()
+ }}
+ >
+
+
+ (Array.isArray(e) ? e : e && e.fileList)}
+ >
+ false} // Prevent automatic upload
+ onChange={handleImageUpload}
+ >
+ }>Upload
+
+
+
+ } />
+
+
+ } />
+
+ >
+ )
+ },
+ {
+ title: 'Summary',
+ key: 'done',
+ content: (
+
+
+
+ )
+ }
+ ]
+
+ return (
+
+ {contextHolder}
+
+
+
+
+
+
+
+
+
+ New Filament
+
+
+
+
+
+ )
+}
+
+NewFilament.propTypes = {
+ reset: PropTypes.bool.isRequired,
+ onOk: PropTypes.func.isRequired
+}
+
+export default NewFilament
diff --git a/src/components/Dashboard/Management/Parts.jsx b/src/components/Dashboard/Management/Parts.jsx
new file mode 100644
index 0000000..9542b14
--- /dev/null
+++ b/src/components/Dashboard/Management/Parts.jsx
@@ -0,0 +1,235 @@
+// src/gcodefiles.js
+
+import React, { useEffect, useState, useContext, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+import axios from 'axios'
+import moment from 'moment'
+
+import { Table, Button, Flex, Space, Modal, Dropdown, message } from 'antd'
+import { createStyles } from 'antd-style'
+import {
+ LoadingOutlined,
+ PlusOutlined,
+ DownloadOutlined,
+ ReloadOutlined,
+ InfoCircleOutlined
+} from '@ant-design/icons'
+
+import { AuthContext } from '../../Auth/AuthContext'
+
+import IdText from '../common/IdText'
+import NewPart from './Parts/NewPart'
+import PartIcon from '../../Icons/PartIcon'
+
+const useStyle = createStyles(({ css, token }) => {
+ const { antCls } = token
+ return {
+ customTable: css`
+ ${antCls}-table {
+ ${antCls}-table-container {
+ ${antCls}-table-body,
+ ${antCls}-table-content {
+ scrollbar-width: thin;
+ scrollbar-color: #eaeaea transparent;
+ scrollbar-gutter: stable;
+ }
+ }
+ }
+ `
+ }
+})
+
+const Parts = () => {
+ const [messageApi, contextHolder] = message.useMessage()
+ const navigate = useNavigate()
+ const { styles } = useStyle()
+
+ const [partsData, setPartsData] = useState([])
+
+ const [newPartOpen, setNewPartOpen] = useState(false)
+
+ const [loading, setLoading] = useState(true)
+
+ const { authenticated } = useContext(AuthContext)
+
+ const fetchPartsData = useCallback(async () => {
+ try {
+ const response = await axios.get('http://localhost:8080/parts', {
+ params: {
+ page: 1,
+ limit: 25
+ },
+ headers: {
+ Accept: 'application/json'
+ },
+ withCredentials: true // Important for including cookies
+ })
+ setPartsData(response.data)
+ setLoading(false)
+ //setPagination({ ...pagination, total: response.data.totalItems }); // Update total count
+ } catch (error) {
+ if (error.response) {
+ messageApi.error(
+ 'Error updating printer details:',
+ error.response.status
+ )
+ } else {
+ messageApi.error(
+ 'An unexpected error occurred. Please try again later.'
+ )
+ }
+ }
+ }, [messageApi])
+
+ useEffect(() => {
+ if (authenticated) {
+ fetchPartsData()
+ }
+ }, [authenticated, fetchPartsData])
+
+ const getPartActionItems = (id) => {
+ return {
+ items: [
+ {
+ label: 'Info',
+ key: 'info',
+ icon:
+ },
+ {
+ label: 'Download',
+ key: 'download',
+ icon:
+ }
+ ],
+ onClick: ({ key }) => {
+ if (key === 'info') {
+ navigate(`/management/parts/info?partId=${id}`)
+ }
+ }
+ }
+ }
+
+ // Column definitions
+ const columns = [
+ {
+ title: '',
+ dataIndex: '',
+ key: '',
+ width: 40,
+ fixed: 'left',
+ render: () =>
+ },
+ {
+ title: 'Name',
+ dataIndex: 'name',
+ key: 'name',
+ width: 200,
+ fixed: 'left'
+ },
+ {
+ title: 'ID',
+ dataIndex: '_id',
+ key: 'id',
+ width: 165,
+ render: (text) =>
+ },
+ {
+ title: 'Created At',
+ dataIndex: 'createdAt',
+ key: 'createdAt',
+ width: 180,
+ render: (createdAt) => {
+ if (createdAt) {
+ const formattedDate = moment(createdAt).format('YYYY-MM-DD HH:mm:ss')
+ return {formattedDate}
+ } else {
+ return 'n/a'
+ }
+ }
+ },
+ {
+ title: 'Actions',
+ key: 'actions',
+ fixed: 'right',
+ width: 150,
+ render: (text, record) => {
+ return (
+
+ }
+ onClick={() =>
+ navigate(`/management/parts/info?partId=${record._id}`)
+ }
+ />
+
+
+
+
+ )
+ }
+ }
+ ]
+
+ const actionItems = {
+ items: [
+ {
+ label: 'New Part',
+ key: 'newPart',
+ icon:
+ },
+ { type: 'divider' },
+ {
+ label: 'Reload List',
+ key: 'reloadList',
+ icon:
+ }
+ ],
+ onClick: ({ key }) => {
+ if (key === 'reloadList') {
+ fetchPartsData()
+ } else if (key === 'newPart') {
+ setNewPartOpen(true)
+ }
+ }
+ }
+
+ return (
+ <>
+
+ {contextHolder}
+
+
+
+
+
+ }}
+ />
+
+ {
+ setNewPartOpen(false)
+ }}
+ >
+ {
+ setNewPartOpen(false)
+ fetchPartsData()
+ }}
+ reset={newPartOpen}
+ />
+
+ >
+ )
+}
+
+export default Parts
diff --git a/src/components/Dashboard/Management/Parts/NewPart.jsx b/src/components/Dashboard/Management/Parts/NewPart.jsx
new file mode 100644
index 0000000..5235180
--- /dev/null
+++ b/src/components/Dashboard/Management/Parts/NewPart.jsx
@@ -0,0 +1,471 @@
+import PropTypes from 'prop-types'
+import React, { useState, useContext, useEffect, useRef } from 'react'
+import axios from 'axios'
+import {
+ Form,
+ Input,
+ Button,
+ message,
+ Typography,
+ Flex,
+ Steps,
+ Divider,
+ Upload,
+ Descriptions,
+ Modal
+} from 'antd'
+import { DeleteOutlined, EyeOutlined } from '@ant-design/icons'
+import { AuthContext } from '../../../Auth/AuthContext'
+import PartIcon from '../../../Icons/PartIcon'
+import { StlViewer } from 'react-stl-viewer'
+
+const { Dragger } = Upload
+const { Title } = Typography
+
+const initialNewPartsForm = { parts: [{ name: 'Test' }] }
+
+const NewPart = ({ onOk, reset }) => {
+ const [messageApi, contextHolder] = message.useMessage()
+ const [newPartLoading, setNewPartLoading] = useState(false)
+ const [currentStep, setCurrentStep] = useState(0)
+ const [nextEnabled, setNextEnabled] = useState(false)
+
+ const [newPartsForm] = Form.useForm()
+ const [newPartsFormValues, setNewPartsFormValues] =
+ useState(initialNewPartsForm)
+
+ // Store files and their object URLs
+ const [fileList, setFileList] = useState([])
+ const [fileObjectUrls, setFileObjectUrls] = useState({})
+ const [names, setNames] = useState({})
+
+ // Preview modal state
+ const [previewVisible, setPreviewVisible] = useState(false)
+ const [previewFile, setPreviewFile] = useState(null)
+ const [stlLoading, setStlLoading] = useState(false)
+
+ const newPartsFormUpdateValues = Form.useWatch([], newPartsForm)
+
+ const { token, authenticated } = useContext(AuthContext)
+
+ // Timer reference for delayed STL rendering
+ const stlTimerRef = useRef(null)
+
+ // Validate form fields
+ useEffect(() => {
+ if (currentStep === 0) {
+ // For combined upload/files step
+ setNextEnabled(fileList.length > 0)
+ } else {
+ newPartsForm
+ .validateFields({
+ validateOnly: true
+ })
+ .then(() => setNextEnabled(true))
+ .catch(() => setNextEnabled(false))
+ }
+ }, [newPartsForm, newPartsFormUpdateValues, fileList, currentStep])
+
+ // Handle reset
+ useEffect(() => {
+ if (reset) {
+ newPartsForm.resetFields()
+ setFileList([])
+ setFileObjectUrls({})
+ setNames({})
+ setCurrentStep(0)
+ }
+ }, [reset, newPartsForm])
+
+ // Clean up object URLs when component unmounts
+ useEffect(() => {
+ return () => {
+ Object.values(fileObjectUrls).forEach((url) => {
+ URL.revokeObjectURL(url)
+ })
+ if (stlTimerRef.current) {
+ clearTimeout(stlTimerRef.current)
+ }
+ }
+ }, [fileObjectUrls])
+
+ // Create a summary of all parts for the final step
+ const summaryItems = fileList
+ .map((file, index) => ({
+ key: file.uid,
+ label: `Part ${index + 1}`,
+ children: names[file.uid] || file.name.replace(/\.[^/.]+$/, '')
+ }))
+ .concat([
+ {
+ key: 'name',
+ label: 'Product Name',
+ children: newPartsFormValues.name
+ }
+ ])
+
+ // Handle file upload
+ const handleFileUpload = async (files) => {
+ if (!authenticated) {
+ return
+ }
+ setNewPartLoading(true)
+
+ try {
+ // First create the part entries
+ const partsData = []
+
+ for (const file of files) {
+ const partName = names[file.uid] || file.name.replace(/\.[^/.]+$/, '')
+ const partData = {
+ name: partName,
+ partInfo: {}
+ }
+
+ const response = await axios.post(
+ `http://localhost:8080/parts`,
+ partData,
+ {
+ headers: {
+ Authorization: `Bearer ${token}`
+ }
+ }
+ )
+
+ // Now upload the actual file content
+ const formData = new FormData()
+ formData.append('partFile', file)
+ await axios.post(
+ `http://localhost:8080/parts/${response.data._id}/content`,
+ formData,
+ {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ Authorization: `Bearer ${token}`
+ }
+ }
+ )
+
+ partsData.push({
+ id: response.data._id,
+ name: partName
+ })
+ }
+
+ // Create product with all the parts references
+ await axios.post(`http://localhost:8080/products`, newPartsFormValues, {
+ headers: {
+ Authorization: `Bearer ${token}`
+ }
+ })
+
+ messageApi.success(`Parts and product created successfully!`)
+ onOk()
+ } catch (error) {
+ messageApi.error('Error creating parts: ' + error.message)
+ } finally {
+ setNewPartLoading(false)
+ }
+ }
+
+ // Handle file name change
+ const handleFileNameChange = (uid, name) => {
+ setNames((prev) => ({
+ ...prev,
+ [uid]: name
+ }))
+ }
+
+ // Preview STL file
+ const handlePreview = (file) => {
+ setPreviewFile(file)
+ setPreviewVisible(true)
+ setStlLoading(true)
+
+ // Delay the rendering of the STL viewer to fix glitch
+ if (stlTimerRef.current) {
+ clearTimeout(stlTimerRef.current)
+ }
+
+ stlTimerRef.current = setTimeout(() => {
+ setStlLoading(false)
+ }, 300)
+ }
+
+ // Add file to list
+ const handleAddFile = (file) => {
+ // Create object URL for preview
+ const objectUrl = URL.createObjectURL(file)
+
+ setNewPartsFormValues((prev) => ({
+ parts: [
+ ...prev.parts,
+ {
+ name: file.name,
+ size: file.size,
+ objectUrl: objectUrl
+ }
+ ]
+ }))
+
+ console.log(newPartsFormValues)
+ newPartsForm.setFormValues({
+ parts: [
+ ...newPartsFormValues.parts,
+ {
+ name: file.name,
+ size: file.size,
+ objectUrl: objectUrl
+ }
+ ]
+ })
+
+ // Set default name (filename without extension)
+ const defaultName = file.name.replace(/\.[^/.]+$/, '')
+ setNames((prev) => ({
+ ...prev,
+ [file.uid]: defaultName
+ }))
+
+ return false // Prevent default upload behavior
+ }
+
+ // Combined upload and files content for step 1
+ const combinedUploadFilesContent = (
+ <>
+ {fileList.length > 0 && (
+
+
+ {(parts, { remove }) => (
+ <>
+ {parts.map((part) => (
+
+
+
+ handleFileNameChange('file.uid', e.target.value)
+ }
+ />
+
+
+ }
+ onClick={() => handlePreview(part.file)}
+ >
+ ,
+ }
+ onClick={() => remove(part.uid)}
+ />
+
+
+ ))}
+ >
+ )}
+
+
+ )}
+
+
+ {
+ setTimeout(() => {
+ onSuccess('ok')
+ }, 0)
+ }}
+ >
+
+
+
+
+
+ Click or drag 3D Model files here.
+
+
+ Supported file extensions: .stl, .3mf
+
+
+
+
+ >
+ )
+
+ // Steps for the form (now with combined step)
+ const steps = [
+ {
+ title: 'Upload Files',
+ key: 'upload-files',
+ content: combinedUploadFilesContent
+ },
+ {
+ title: 'Details',
+ key: 'details',
+ content: (
+
+
+
+
+
+ )
+ },
+ {
+ title: 'Summary',
+ key: 'done',
+ content: (
+ <>
+
+
+
+ >
+ )
+ }
+ ]
+
+ return (
+
+ {contextHolder}
+
+
+
+
+
+
+
+
+ New Part
+
+
+
+
+ {/* STL Preview Modal */}
+ {
+ setPreviewVisible(false)
+ setPreviewFile(null)
+ if (stlTimerRef.current) {
+ clearTimeout(stlTimerRef.current)
+ }
+ }}
+ style={{ top: 30 }}
+ width={'90%'}
+ >
+
+ {previewFile && !stlLoading && (
+
+
+
+ )}
+ {stlLoading && (
+
+ Loading 3D model...
+
+ )}
+
+
+
+ )
+}
+
+NewPart.propTypes = {
+ reset: PropTypes.bool.isRequired,
+ onOk: PropTypes.func.isRequired
+}
+
+export default NewPart
diff --git a/src/components/Dashboard/Management/Parts/PartInfo.jsx b/src/components/Dashboard/Management/Parts/PartInfo.jsx
new file mode 100644
index 0000000..de8a4b9
--- /dev/null
+++ b/src/components/Dashboard/Management/Parts/PartInfo.jsx
@@ -0,0 +1,273 @@
+import React, { useState, useEffect } from 'react'
+import { useLocation } from 'react-router-dom'
+import axios from 'axios'
+import {
+ Descriptions,
+ Spin,
+ Space,
+ Button,
+ message,
+ Typography,
+ Card,
+ Flex,
+ Form,
+ Input
+} from 'antd'
+import {
+ LoadingOutlined,
+ EditOutlined,
+ ReloadOutlined,
+ CheckOutlined,
+ CloseOutlined
+} from '@ant-design/icons'
+import IdText from '../../common/IdText.jsx'
+import moment from 'moment'
+
+const { Title } = Typography
+import { StlViewer } from 'react-stl-viewer'
+
+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 [partFileObjectId, setPartFileObjectId] = useState(null)
+ const [isEditing, setIsEditing] = useState(false)
+ const [form] = Form.useForm()
+ const [fetchLoading, setFetchLoading] = useState(true)
+
+ useEffect(() => {
+ async function fetchData() {
+ await fetchPartDetails()
+ await fetchPartContent()
+ }
+ if (partId) {
+ fetchData()
+ }
+ }, [partId])
+
+ useEffect(() => {
+ if (partData) {
+ form.setFieldsValue({
+ name: partData.name || ''
+ })
+ }
+ }, [partData, form])
+
+ 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 () => {
+ try {
+ setFetchLoading(true)
+ const response = await axios.get(
+ `http://localhost:8080/parts/${partId}/content`,
+ {
+ headers: {
+ Accept: 'application/json'
+ },
+ withCredentials: true,
+ responseType: 'blob'
+ }
+ )
+
+ setPartFileObjectId(URL.createObjectURL(response.data))
+ setError(null)
+ } catch (err) {
+ setError('Failed to fetch Part content')
+ console.log(err)
+ messageApi.error('Failed to fetch Part content')
+ } finally {
+ setFetchLoading(false)
+ }
+ }
+
+ const startEditing = () => {
+ setIsEditing(true)
+ }
+
+ const cancelEditing = () => {
+ form.setFieldsValue({
+ name: partData?.name || ''
+ })
+ setIsEditing(false)
+ }
+
+ const updateInfo = async () => {
+ try {
+ const values = await form.validateFields()
+ setLoading(true)
+
+ await axios.put(
+ `http://localhost:8080/parts/${partId}`,
+ {
+ name: values.name
+ },
+ {
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ withCredentials: true
+ }
+ )
+
+ // Update the local state with the new name
+ setPartData({ ...partData, name: values.name })
+ 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 {
+ setLoading(false)
+ }
+ }
+
+ if (fetchLoading) {
+ return (
+
+ } />
+
+ )
+ }
+
+ if (error || !partData) {
+ return (
+
+ {error || 'Part not found'}
+ } onClick={fetchPartDetails}>
+ Retry
+
+
+ )
+ }
+
+ return (
+
+ {contextHolder}
+
+
+ Part Information
+
+
+ {isEditing ? (
+ <>
+ }
+ type='primary'
+ onClick={updateInfo}
+ loading={loading}
+ >
+ }
+ onClick={cancelEditing}
+ disabled={loading}
+ >
+ >
+ ) : (
+ } onClick={startEditing}>
+ )}
+
+
+
+
+
+
+
+ Part Preview
+
+
+
+
+
+
+ )
+}
+
+export default PartInfo
diff --git a/src/components/Dashboard/Management/Products.jsx b/src/components/Dashboard/Management/Products.jsx
new file mode 100644
index 0000000..1d4c4ac
--- /dev/null
+++ b/src/components/Dashboard/Management/Products.jsx
@@ -0,0 +1,237 @@
+// src/gcodefiles.js
+
+import React, { useEffect, useState, useContext, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+import axios from 'axios'
+import moment from 'moment'
+
+import { Table, Button, Flex, Space, Modal, Dropdown, message } from 'antd'
+import { createStyles } from 'antd-style'
+import {
+ LoadingOutlined,
+ PlusOutlined,
+ DownloadOutlined,
+ ReloadOutlined,
+ InfoCircleOutlined
+} from '@ant-design/icons'
+
+import { AuthContext } from '../../Auth/AuthContext'
+
+import IdText from '../common/IdText'
+import NewProduct from './Products/NewProduct'
+import ProductIcon from '../../Icons/ProductIcon'
+
+const useStyle = createStyles(({ css, token }) => {
+ const { antCls } = token
+ return {
+ customTable: css`
+ ${antCls}-table {
+ ${antCls}-table-container {
+ ${antCls}-table-body,
+ ${antCls}-table-content {
+ scrollbar-width: thin;
+ scrollbar-color: #eaeaea transparent;
+ scrollbar-gutter: stable;
+ }
+ }
+ }
+ `
+ }
+})
+
+const Products = () => {
+ const [messageApi, contextHolder] = message.useMessage()
+ const navigate = useNavigate()
+ const { styles } = useStyle()
+
+ const [productsData, setProductsData] = useState([])
+
+ const [newProductOpen, setNewProductOpen] = useState(false)
+
+ const [loading, setLoading] = useState(true)
+
+ const { authenticated } = useContext(AuthContext)
+
+ const fetchProductsData = useCallback(async () => {
+ try {
+ const response = await axios.get('http://localhost:8080/products', {
+ params: {
+ page: 1,
+ limit: 25
+ },
+ headers: {
+ Accept: 'application/json'
+ },
+ withCredentials: true // Important for including cookies
+ })
+ setProductsData(response.data)
+ setLoading(false)
+ //setPagination({ ...pagination, total: response.data.totalItems }); // Update total count
+ } catch (error) {
+ if (error.response) {
+ messageApi.error(
+ 'Error updating printer details:',
+ error.response.status
+ )
+ } else {
+ messageApi.error(
+ 'An unexpected error occurred. Please try again later.'
+ )
+ }
+ }
+ }, [messageApi])
+
+ useEffect(() => {
+ if (authenticated) {
+ fetchProductsData()
+ }
+ }, [authenticated, fetchProductsData])
+
+ const getProductActionItems = (id) => {
+ return {
+ items: [
+ {
+ label: 'Info',
+ key: 'info',
+ icon:
+ },
+ {
+ label: 'Download',
+ key: 'download',
+ icon:
+ }
+ ],
+ onClick: ({ key }) => {
+ if (key === 'info') {
+ navigate(`/management/products/info?productId=${id}`)
+ }
+ }
+ }
+ }
+
+ // Column definitions
+ const columns = [
+ {
+ title: '',
+ dataIndex: '',
+ key: '',
+ width: 40,
+ fixed: 'left',
+ render: () =>
+ },
+ {
+ title: 'Name',
+ dataIndex: 'name',
+ key: 'name',
+ width: 200,
+ fixed: 'left'
+ },
+ {
+ title: 'ID',
+ dataIndex: '_id',
+ key: 'id',
+ fixed: 'left',
+ width: 165,
+ render: (text) =>
+ },
+ {
+ title: 'Created At',
+ dataIndex: 'createdAt',
+ key: 'createdAt',
+ width: 180,
+ render: (createdAt) => {
+ if (createdAt) {
+ const formattedDate = moment(createdAt).format('YYYY-MM-DD HH:mm:ss')
+ return {formattedDate}
+ } else {
+ return 'n/a'
+ }
+ }
+ },
+ {
+ title: 'Actions',
+ key: 'actions',
+ fixed: 'right',
+ width: 150,
+ render: (text, record) => {
+ return (
+
+ }
+ onClick={() =>
+ navigate(`/management/products/info?productId=${record._id}`)
+ }
+ />
+
+
+
+
+ )
+ }
+ }
+ ]
+
+ const actionItems = {
+ items: [
+ {
+ label: 'New Product',
+ key: 'newProduct',
+ icon:
+ },
+ { type: 'divider' },
+ {
+ label: 'Reload List',
+ key: 'reloadList',
+ icon:
+ }
+ ],
+ onClick: ({ key }) => {
+ if (key === 'reloadList') {
+ fetchProductsData()
+ } else if (key === 'newProduct') {
+ setNewProductOpen(true)
+ }
+ }
+ }
+
+ return (
+ <>
+
+ {contextHolder}
+
+
+
+
+
+ }}
+ />
+
+ {
+ setNewProductOpen(false)
+ }}
+ destroyOnClose
+ >
+ {
+ setNewProductOpen(false)
+ fetchProductsData()
+ }}
+ reset={newProductOpen}
+ />
+
+ >
+ )
+}
+
+export default Products
diff --git a/src/components/Dashboard/Management/Products/NewProduct.jsx b/src/components/Dashboard/Management/Products/NewProduct.jsx
new file mode 100644
index 0000000..c6d02f7
--- /dev/null
+++ b/src/components/Dashboard/Management/Products/NewProduct.jsx
@@ -0,0 +1,213 @@
+import PropTypes from 'prop-types'
+import React, { useState, useContext } from 'react'
+import axios from 'axios'
+import {
+ Form,
+ Input,
+ Button,
+ message,
+ Typography,
+ Flex,
+ Steps,
+ Divider,
+ Descriptions
+} from 'antd'
+
+import { AuthContext } from '../../../Auth/AuthContext'
+
+const { Title } = Typography
+
+const initialNewProductForm = {
+ productInfo: {},
+ printTimeMins: 0,
+ price: 0
+}
+
+//const chunkSize = 5000
+
+const NewProduct = ({ onOk, reset }) => {
+ const [messageApi, contextHolder] = message.useMessage()
+ const [newProductLoading, setNewProductLoading] = useState(false)
+
+ const [currentStep, setCurrentStep] = useState(0)
+ const [nextEnabled, setNextEnabled] = useState(false)
+
+ const [newProductForm] = Form.useForm()
+ const [newProductFormValues, setNewProductFormValues] = useState(
+ initialNewProductForm
+ )
+
+ const newProductFormUpdateValues = Form.useWatch([], newProductForm)
+
+ const { token, authenticated } = useContext(AuthContext)
+
+ React.useEffect(() => {
+ newProductForm
+ .validateFields({
+ validateOnly: true
+ })
+ .then(() => setNextEnabled(true))
+ .catch(() => setNextEnabled(false))
+ }, [newProductForm, newProductFormUpdateValues])
+
+ const summaryItems = [
+ {
+ key: 'name',
+ label: 'Name',
+ children: newProductFormValues.name
+ }
+ ]
+
+ React.useEffect(() => {
+ if (reset) {
+ newProductForm.resetFields()
+ }
+ }, [reset, newProductForm])
+
+ const handleNewProduct = async () => {
+ if (!authenticated) {
+ return
+ }
+ setNewProductLoading(true)
+ try {
+ await axios.post(`http://localhost:8080/products`, newProductFormValues, {
+ headers: {
+ Authorization: `Bearer ${token}`
+ }
+ })
+
+ messageApi.success(`Product created successfully.`)
+ onOk()
+ } catch (error) {
+ messageApi.error('Error creating new product file: ' + error.message)
+ } finally {
+ setNewProductLoading(false)
+ }
+ }
+
+ const steps = [
+ {
+ title: 'Parts',
+ key: 'parts',
+ content: (
+ <>
+ (Array.isArray(e) ? e : e && e.fileList)}
+ >
+ >
+ )
+ },
+ {
+ title: 'Details',
+ key: 'details',
+ content: (
+
+
+
+
+
+ )
+ },
+ {
+ title: 'Summary',
+ key: 'done',
+ content: (
+ <>
+
+
+
+ >
+ )
+ }
+ ]
+
+ return (
+
+ {contextHolder}
+
+
+
+
+
+
+
+
+ New Product
+
+
+
+
+ )
+}
+
+NewProduct.propTypes = {
+ reset: PropTypes.bool.isRequired,
+ onOk: PropTypes.func.isRequired
+}
+
+export default NewProduct
diff --git a/src/components/Dashboard/Management/Products/ProductInfo.jsx b/src/components/Dashboard/Management/Products/ProductInfo.jsx
new file mode 100644
index 0000000..c77a14f
--- /dev/null
+++ b/src/components/Dashboard/Management/Products/ProductInfo.jsx
@@ -0,0 +1,273 @@
+import React, { useState, useEffect } from 'react'
+import { useLocation } from 'react-router-dom'
+import axios from 'axios'
+import {
+ Descriptions,
+ Spin,
+ Space,
+ Button,
+ message,
+ Typography,
+ Card,
+ Flex,
+ Form,
+ Input
+} from 'antd'
+import {
+ LoadingOutlined,
+ EditOutlined,
+ ReloadOutlined,
+ CheckOutlined,
+ CloseOutlined
+} from '@ant-design/icons'
+import IdText from '../../common/IdText.jsx'
+import moment from 'moment'
+
+const { Title } = Typography
+import { StlViewer } from 'react-stl-viewer'
+
+const ProductInfo = () => {
+ const [productData, setProductData] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const location = useLocation()
+ const [messageApi, contextHolder] = message.useMessage()
+ const productId = new URLSearchParams(location.search).get('productId')
+ const [productFileObjectId, setProductFileObjectId] = useState(null)
+ const [isEditing, setIsEditing] = useState(false)
+ const [form] = Form.useForm()
+ const [fetchLoading, setFetchLoading] = useState(true)
+
+ useEffect(() => {
+ async function fetchData() {
+ await fetchProductDetails()
+ await fetchProductContent()
+ }
+ if (productId) {
+ fetchData()
+ }
+ }, [productId])
+
+ useEffect(() => {
+ if (productData) {
+ form.setFieldsValue({
+ name: productData.name || ''
+ })
+ }
+ }, [productData, form])
+
+ const fetchProductDetails = async () => {
+ try {
+ setFetchLoading(true)
+ const response = await axios.get(
+ `http://localhost:8080/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 fetchProductContent = async () => {
+ try {
+ setFetchLoading(true)
+ const response = await axios.get(
+ `http://localhost:8080/products/${productId}/content`,
+ {
+ headers: {
+ Accept: 'application/json'
+ },
+ withCredentials: true,
+ responseType: 'blob'
+ }
+ )
+
+ setProductFileObjectId(URL.createObjectURL(response.data))
+ setError(null)
+ } catch (err) {
+ setError('Failed to fetch Product content')
+ console.log(err)
+ messageApi.error('Failed to fetch Product content')
+ } finally {
+ setFetchLoading(false)
+ }
+ }
+
+ const startEditing = () => {
+ setIsEditing(true)
+ }
+
+ const cancelEditing = () => {
+ form.setFieldsValue({
+ name: productData?.name || ''
+ })
+ setIsEditing(false)
+ }
+
+ const updateInfo = async () => {
+ try {
+ const values = await form.validateFields()
+ setLoading(true)
+
+ await axios.put(
+ `http://localhost:8080/products/${productId}`,
+ {
+ name: values.name
+ },
+ {
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ withCredentials: true
+ }
+ )
+
+ // Update the local state with the new name
+ setProductData({ ...productData, name: values.name })
+ setIsEditing(false)
+ messageApi.success('Product information updated successfully')
+ } catch (err) {
+ if (err.errorFields) {
+ // This is a form validation error
+ return
+ }
+ console.error('Failed to update product information:', err)
+ messageApi.error('Failed to update product information')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (fetchLoading) {
+ return (
+
+ } />
+
+ )
+ }
+
+ if (error || !productData) {
+ return (
+
+ {error || 'Product not found'}
+ } onClick={fetchProductDetails}>
+ Retry
+
+
+ )
+ }
+
+ return (
+
+ {contextHolder}
+
+
+ Product Information
+
+
+ {isEditing ? (
+ <>
+ }
+ type='primary'
+ onClick={updateInfo}
+ loading={loading}
+ >
+ }
+ onClick={cancelEditing}
+ disabled={loading}
+ >
+ >
+ ) : (
+ } onClick={startEditing}>
+ )}
+
+
+
+
+
+
+
+ Product Preview
+
+
+
+
+
+
+ )
+}
+
+export default ProductInfo
diff --git a/src/components/Dashboard/Management/Vendors.jsx b/src/components/Dashboard/Management/Vendors.jsx
new file mode 100644
index 0000000..e391208
--- /dev/null
+++ b/src/components/Dashboard/Management/Vendors.jsx
@@ -0,0 +1,226 @@
+import React, { useEffect, useState, useContext, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+import axios from 'axios'
+import moment from 'moment'
+import { Table, Button, Flex, Space, Modal, Dropdown, message } from 'antd'
+import { createStyles } from 'antd-style'
+import {
+ LoadingOutlined,
+ PlusOutlined,
+ ReloadOutlined,
+ InfoCircleOutlined,
+ ShopOutlined
+} from '@ant-design/icons'
+import { AuthContext } from '../../Auth/AuthContext'
+import IdText from '../common/IdText'
+import NewVendor from './Vendors/NewVendor'
+
+const useStyle = createStyles(({ css, token }) => {
+ const { antCls } = token
+ return {
+ customTable: css`
+ ${antCls}-table {
+ ${antCls}-table-container {
+ ${antCls}-table-body,
+ ${antCls}-table-content {
+ scrollbar-width: thin;
+ scrollbar-color: #eaeaea transparent;
+ scrollbar-gutter: stable;
+ }
+ }
+ }
+ `
+ }
+})
+
+const Vendors = () => {
+ const [messageApi, contextHolder] = message.useMessage()
+ const navigate = useNavigate()
+ const { styles } = useStyle()
+ const [vendorsData, setVendorsData] = useState([])
+ const [newVendorOpen, setNewVendorOpen] = useState(false)
+ const [loading, setLoading] = useState(true)
+ const { authenticated } = useContext(AuthContext)
+
+ const fetchVendorsData = useCallback(async () => {
+ try {
+ const response = await axios.get('http://localhost:8080/vendors', {
+ params: {
+ page: 1,
+ limit: 25
+ },
+ headers: {
+ Accept: 'application/json'
+ },
+ withCredentials: true
+ })
+ setVendorsData(response.data)
+ setLoading(false)
+ } catch (error) {
+ if (error.response) {
+ messageApi.error('Error fetching vendor data:', error.response.status)
+ } else {
+ messageApi.error(
+ 'An unexpected error occurred. Please try again later.'
+ )
+ }
+ }
+ }, [messageApi])
+
+ useEffect(() => {
+ if (authenticated) {
+ fetchVendorsData()
+ }
+ }, [authenticated, fetchVendorsData])
+
+ const getVendorActionItems = (id) => {
+ return {
+ items: [
+ {
+ label: 'Info',
+ key: 'info',
+ icon:
+ }
+ ],
+ onClick: ({ key }) => {
+ if (key === 'info') {
+ navigate(`/management/vendors/info?vendorId=${id}`)
+ }
+ }
+ }
+ }
+
+ const columns = [
+ {
+ title: '',
+ dataIndex: '',
+ key: '',
+ width: 40,
+ fixed: 'left',
+ render: () =>
+ },
+ {
+ title: 'Name',
+ dataIndex: 'name',
+ key: 'name',
+ width: 200,
+ fixed: 'left'
+ },
+ {
+ title: 'ID',
+ dataIndex: '_id',
+ key: 'id',
+ width: 165,
+ render: (text) =>
+ },
+ {
+ title: 'Website',
+ dataIndex: 'website',
+ key: 'website',
+ width: 200
+ },
+ {
+ title: 'Contact',
+ dataIndex: 'contact',
+ key: 'contact',
+ width: 200
+ },
+ {
+ title: 'Created At',
+ dataIndex: 'createdAt',
+ key: 'createdAt',
+ width: 180,
+ render: (createdAt) => {
+ if (createdAt) {
+ const formattedDate = moment(createdAt).format('YYYY-MM-DD HH:mm:ss')
+ return {formattedDate}
+ } else {
+ return 'n/a'
+ }
+ }
+ },
+ {
+ title: 'Actions',
+ key: 'actions',
+ fixed: 'right',
+ width: 150,
+ render: (text, record) => {
+ return (
+
+ }
+ onClick={() =>
+ navigate(`/management/vendors/info?vendorId=${record._id}`)
+ }
+ />
+
+
+
+
+ )
+ }
+ }
+ ]
+
+ const actionItems = {
+ items: [
+ {
+ label: 'New Vendor',
+ key: 'newVendor',
+ icon:
+ },
+ { type: 'divider' },
+ {
+ label: 'Reload List',
+ key: 'reloadList',
+ icon:
+ }
+ ],
+ onClick: ({ key }) => {
+ if (key === 'reloadList') {
+ fetchVendorsData()
+ } else if (key === 'newVendor') {
+ setNewVendorOpen(true)
+ }
+ }
+ }
+
+ return (
+ <>
+
+ {contextHolder}
+
+
+
+
+
+ }}
+ />
+
+ setNewVendorOpen(false)}
+ footer={null}
+ destroyOnClose
+ width={700}
+ >
+ {
+ setNewVendorOpen(false)
+ fetchVendorsData()
+ }}
+ reset={!newVendorOpen}
+ />
+
+ >
+ )
+}
+
+export default Vendors
diff --git a/src/components/Dashboard/Management/Vendors/NewVendor.jsx b/src/components/Dashboard/Management/Vendors/NewVendor.jsx
new file mode 100644
index 0000000..cf674c3
--- /dev/null
+++ b/src/components/Dashboard/Management/Vendors/NewVendor.jsx
@@ -0,0 +1,205 @@
+import PropTypes from 'prop-types'
+import React, { useState } from 'react'
+import axios from 'axios'
+import {
+ Form,
+ Input,
+ Button,
+ message,
+ Typography,
+ Flex,
+ Steps,
+ Descriptions,
+ Divider
+} from 'antd'
+
+const { Title } = Typography
+
+const initialNewVendorForm = {
+ name: '',
+ website: '',
+ contact: ''
+}
+
+const NewVendor = ({ onOk, reset }) => {
+ const [messageApi, contextHolder] = message.useMessage()
+ const [newVendorLoading, setNewVendorLoading] = useState(false)
+ const [currentStep, setCurrentStep] = useState(0)
+ const [nextEnabled, setNextEnabled] = useState(false)
+ const [newVendorForm] = Form.useForm()
+ const [newVendorFormValues, setNewVendorFormValues] =
+ useState(initialNewVendorForm)
+
+ const newVendorFormUpdateValues = Form.useWatch([], newVendorForm)
+
+ React.useEffect(() => {
+ newVendorForm
+ .validateFields({
+ validateOnly: true
+ })
+ .then(() => setNextEnabled(true))
+ .catch(() => setNextEnabled(false))
+ }, [newVendorForm, newVendorFormUpdateValues])
+
+ const summaryItems = [
+ {
+ key: 'name',
+ label: 'Name',
+ children: newVendorFormValues.name
+ },
+ {
+ key: 'website',
+ label: 'Website',
+ children: newVendorFormValues.website
+ },
+ {
+ key: 'contact',
+ label: 'Contact',
+ children: newVendorFormValues.contact
+ }
+ ]
+
+ React.useEffect(() => {
+ if (reset) {
+ newVendorForm.resetFields()
+ }
+ }, [reset, newVendorForm])
+
+ const handleNewVendor = async () => {
+ setNewVendorLoading(true)
+ try {
+ await axios.post('http://localhost:8080/vendors', newVendorFormValues, {
+ withCredentials: true
+ })
+ messageApi.success('New vendor created successfully.')
+ onOk()
+ } catch (error) {
+ messageApi.error('Error creating new vendor: ' + error.message)
+ } finally {
+ setNewVendorLoading(false)
+ }
+ }
+
+ const steps = [
+ {
+ title: 'Details',
+ key: 'details',
+ content: (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )
+ },
+ {
+ title: 'Summary',
+ key: 'summary',
+ content:
+ }
+ ]
+
+ return (
+
+ {contextHolder}
+
+
+
+
+
+
+
+
+ New Vendor
+
+
+
+
+ )
+}
+
+NewVendor.propTypes = {
+ onOk: PropTypes.func.isRequired,
+ reset: PropTypes.bool
+}
+
+export default NewVendor
diff --git a/src/components/Dashboard/Management/Vendors/VendorInfo.jsx b/src/components/Dashboard/Management/Vendors/VendorInfo.jsx
new file mode 100644
index 0000000..e692fdd
--- /dev/null
+++ b/src/components/Dashboard/Management/Vendors/VendorInfo.jsx
@@ -0,0 +1,247 @@
+import React, { useState, useEffect } from 'react'
+import { useLocation } from 'react-router-dom'
+import axios from 'axios'
+import {
+ Descriptions,
+ Spin,
+ Space,
+ Button,
+ message,
+ Typography,
+ Flex,
+ Form,
+ Input
+} from 'antd'
+import {
+ LoadingOutlined,
+ EditOutlined,
+ ReloadOutlined,
+ CheckOutlined,
+ CloseOutlined
+} from '@ant-design/icons'
+import IdText from '../../common/IdText'
+import moment from 'moment'
+
+const { Title } = Typography
+
+const VendorInfo = () => {
+ const [vendorData, setVendorData] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const location = useLocation()
+ const [messageApi, contextHolder] = message.useMessage()
+ const vendorId = new URLSearchParams(location.search).get('vendorId')
+ const [isEditing, setIsEditing] = useState(false)
+ const [form] = Form.useForm()
+ const [fetchLoading, setFetchLoading] = useState(true)
+
+ useEffect(() => {
+ if (vendorId) {
+ fetchVendorDetails()
+ }
+ }, [vendorId])
+
+ useEffect(() => {
+ if (vendorData) {
+ form.setFieldsValue({
+ name: vendorData.name || '',
+ website: vendorData.website || '',
+ contact: vendorData.contact || ''
+ })
+ }
+ }, [vendorData, form])
+
+ const fetchVendorDetails = async () => {
+ try {
+ setFetchLoading(true)
+ const response = await axios.get(
+ `http://localhost:8080/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)
+ }
+ }
+
+ const startEditing = () => {
+ setIsEditing(true)
+ }
+
+ const cancelEditing = () => {
+ form.setFieldsValue({
+ name: vendorData?.name || '',
+ website: vendorData?.website || '',
+ contact: vendorData?.contact || ''
+ })
+ setIsEditing(false)
+ }
+
+ const updateInfo = async () => {
+ try {
+ const values = await form.validateFields()
+ setLoading(true)
+
+ await axios.put(
+ `http://localhost:8080/vendors/${vendorId}`,
+ {
+ name: values.name,
+ website: values.website,
+ contact: values.contact
+ },
+ {
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ withCredentials: true
+ }
+ )
+
+ setVendorData({ ...vendorData, ...values })
+ setIsEditing(false)
+ messageApi.success('Vendor information updated successfully')
+ } catch (err) {
+ if (err.errorFields) {
+ return
+ }
+ console.error('Failed to update vendor information:', err)
+ messageApi.error('Failed to update vendor information')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (fetchLoading) {
+ return (
+
+ } />
+
+ )
+ }
+
+ if (error || !vendorData) {
+ return (
+
+ {error || 'Vendor not found'}
+ } onClick={fetchVendorDetails}>
+ Retry
+
+
+ )
+ }
+
+ return (
+
+ {contextHolder}
+
+
+ Vendor Information
+
+
+ {isEditing ? (
+ <>
+ }
+ type='primary'
+ onClick={updateInfo}
+ loading={loading}
+ />
+ }
+ onClick={cancelEditing}
+ disabled={loading}
+ />
+ >
+ ) : (
+ } onClick={startEditing} />
+ )}
+
+
+
+
+
+ )
+}
+
+export default VendorInfo
diff --git a/src/components/Dashboard/Production/GCodeFiles.jsx b/src/components/Dashboard/Production/GCodeFiles.jsx
new file mode 100644
index 0000000..b5e2b07
--- /dev/null
+++ b/src/components/Dashboard/Production/GCodeFiles.jsx
@@ -0,0 +1,325 @@
+// src/gcodefiles.js
+
+import React, { useEffect, useState, useContext, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+import axios from 'axios'
+import moment from 'moment'
+
+import {
+ Table,
+ Badge,
+ Button,
+ Flex,
+ Space,
+ Modal,
+ Dropdown,
+ Typography,
+ message
+} from 'antd'
+import { createStyles } from 'antd-style'
+import {
+ LoadingOutlined,
+ PlusOutlined,
+ DownloadOutlined,
+ ReloadOutlined,
+ InfoCircleOutlined
+} from '@ant-design/icons'
+
+import { AuthContext } from '../../Auth/AuthContext'
+
+import NewGCodeFile from './GCodeFiles/NewGCodeFile'
+import IdText from '../common/IdText'
+import GCodeFileIcon from '../../Icons/GCodeFileIcon'
+
+const { Text } = Typography
+
+const useStyle = createStyles(({ css, token }) => {
+ const { antCls } = token
+ return {
+ customTable: css`
+ ${antCls}-table {
+ ${antCls}-table-container {
+ ${antCls}-table-body,
+ ${antCls}-table-content {
+ scrollbar-width: thin;
+ scrollbar-color: #eaeaea transparent;
+ scrollbar-gutter: stable;
+ }
+ }
+ }
+ `
+ }
+})
+
+const GCodeFiles = () => {
+ const [messageApi, contextHolder] = message.useMessage()
+ const navigate = useNavigate()
+ const { styles } = useStyle()
+
+ const [gcodeFilesData, setGCodeFilesData] = useState([])
+
+ const [newGCodeFileOpen, setNewGCodeFileOpen] = useState(false)
+
+ const [loading, setLoading] = useState(true)
+
+ const { authenticated } = useContext(AuthContext)
+
+ const fetchGCodeFilesData = useCallback(async () => {
+ try {
+ const response = await axios.get('http://localhost:8080/gcodefiles', {
+ params: {
+ page: 1,
+ limit: 25
+ },
+ headers: {
+ Accept: 'application/json'
+ },
+ withCredentials: true // Important for including cookies
+ })
+ setGCodeFilesData(response.data)
+ setLoading(false)
+ //setPagination({ ...pagination, total: response.data.totalItems }); // Update total count
+ } catch (error) {
+ if (error.response) {
+ messageApi.error(
+ 'Error updating printer details:',
+ error.response.status
+ )
+ } else {
+ messageApi.error(
+ 'An unexpected error occurred. Please try again later.'
+ )
+ }
+ }
+ }, [messageApi])
+
+ useEffect(() => {
+ if (authenticated) {
+ fetchGCodeFilesData()
+ }
+ }, [authenticated, fetchGCodeFilesData])
+
+ const getGCodeFileActionItems = (id) => {
+ return {
+ items: [
+ {
+ label: 'Info',
+ key: 'info',
+ icon:
+ },
+ {
+ label: 'Download',
+ key: 'download',
+ icon:
+ }
+ ],
+ onClick: ({ key }) => {
+ if (key === 'info') {
+ navigate(`/production/gcodefiles/info?gcodeFileId=${id}`)
+ } else if (key === 'download') {
+ handleDownloadGCode(
+ id,
+ gcodeFilesData.find((file) => file._id === id)?.name + '.gcode'
+ )
+ }
+ }
+ }
+ }
+
+ // Column definitions
+ const columns = [
+ {
+ title: '',
+ dataIndex: '',
+ key: '',
+ width: 40,
+ fixed: 'left',
+ render: () =>
+ },
+ {
+ title: 'Name',
+ dataIndex: 'name',
+ key: 'name',
+ width: 200,
+ fixed: 'left',
+ render: (text) => {text}
+ },
+ {
+ title: 'ID',
+ dataIndex: '_id',
+ key: 'id',
+ width: 165,
+ render: (text) =>
+ },
+ {
+ title: 'Filament',
+ dataIndex: 'filament',
+ key: 'filament',
+ width: 200,
+ render: (filament) => {
+ return
+ }
+ },
+ {
+ title: 'Price / Cost',
+ dataIndex: 'price',
+ key: 'price',
+ width: 120,
+ render: (price) => {
+ return '£' + price.toFixed(2)
+ }
+ },
+ {
+ title: 'Est. Print Time',
+ key: 'estimatedPrintingTimeNormalMode',
+ width: 140,
+ render: (text, record) => {
+ return `${record.gcodeFileInfo.estimatedPrintingTimeNormalMode}`
+ }
+ },
+ {
+ title: 'Created At',
+ dataIndex: 'createdAt',
+ key: 'createdAt',
+ width: 180,
+ render: (createdAt) => {
+ if (createdAt) {
+ const formattedDate = moment(createdAt).format('YYYY-MM-DD HH:mm:ss')
+ return {formattedDate}
+ } else {
+ return 'n/a'
+ }
+ }
+ },
+ {
+ title: 'Actions',
+ key: 'actions',
+ fixed: 'right',
+ width: 150,
+ render: (text, record) => {
+ return (
+
+ }
+ onClick={() =>
+ navigate(
+ `/production/gcodefiles/info?gcodeFileId=${record._id}`
+ )
+ }
+ />
+
+
+
+
+ )
+ }
+ }
+ ]
+
+ const handleDownloadGCode = async (id, fileName) => {
+ if (!authenticated) {
+ return
+ }
+ try {
+ const response = await axios.get(
+ `http://localhost:8080/gcodefiles/${id}/content`,
+ {
+ headers: {
+ Accept: 'application/json'
+ },
+ withCredentials: true // Important for including cookies
+ }
+ )
+
+ setLoading(false)
+
+ const fileURL = window.URL.createObjectURL(new Blob([response.data]))
+ // Create an anchor element and simulate a click to download the file
+ const fileLink = document.createElement('a')
+ fileLink.href = fileURL
+
+ fileLink.setAttribute('download', fileName)
+ document.body.appendChild(fileLink)
+
+ // Simulate click to download the file
+ fileLink.click()
+
+ // Clean up and remove the anchor element
+ fileLink.parentNode.removeChild(fileLink)
+ } catch (error) {
+ if (error.response) {
+ messageApi.error(
+ 'Error updating printer details:',
+ error.response.status
+ )
+ } else {
+ messageApi.error(
+ 'An unexpected error occurred. Please try again later.'
+ )
+ }
+ }
+ }
+
+ const actionItems = {
+ items: [
+ {
+ label: 'New GCodeFile',
+ key: 'newGCodeFile',
+ icon:
+ },
+ { type: 'divider' },
+ {
+ label: 'Reload List',
+ key: 'reloadList',
+ icon:
+ }
+ ],
+ onClick: ({ key }) => {
+ if (key === 'reloadList') {
+ fetchGCodeFilesData()
+ } else if (key === 'newGCodeFile') {
+ setNewGCodeFileOpen(true)
+ }
+ }
+ }
+
+ return (
+ <>
+
+ {contextHolder}
+
+
+
+
+
+ }}
+ />
+
+ {
+ setNewGCodeFileOpen(false)
+ }}
+ >
+ {
+ setNewGCodeFileOpen(false)
+ fetchGCodeFilesData()
+ }}
+ reset={newGCodeFileOpen}
+ />
+
+ >
+ )
+}
+
+export default GCodeFiles
diff --git a/src/components/Dashboard/Production/GCodeFiles/EditGCodeFile.jsx b/src/components/Dashboard/Production/GCodeFiles/EditGCodeFile.jsx
new file mode 100644
index 0000000..4803317
--- /dev/null
+++ b/src/components/Dashboard/Production/GCodeFiles/EditGCodeFile.jsx
@@ -0,0 +1,228 @@
+import React, { useEffect, useState, useContext } from 'react'
+import axios from 'axios'
+import PropTypes from 'prop-types'
+import {
+ Form,
+ Input,
+ InputNumber,
+ Button,
+ message,
+ Spin,
+ Select,
+ Flex,
+ ColorPicker,
+ Upload,
+ Popconfirm
+} from 'antd'
+import {
+ LoadingOutlined,
+ UploadOutlined,
+ LinkOutlined
+} from '@ant-design/icons'
+
+import { AuthContext } from '../../../Auth/AuthContext'
+
+const EditFilament = ({ id, onOk }) => {
+ const [messageApi, contextHolder] = message.useMessage()
+
+ const [dataLoading, setDataLoading] = useState(false)
+ const [editFilamentLoading, setEditFilamentLoading] = useState(false)
+
+ const [imageList, setImageList] = useState([])
+
+ const [editFilamentForm] = Form.useForm()
+ const [editFilamentFormValues, setEditFilamentFormValues] = useState({})
+
+ const { token } = useContext(AuthContext)
+
+ useEffect(() => {
+ // Fetch printer details when the component mounts
+ const fetchFilamentDetails = async () => {
+ if (id) {
+ try {
+ setDataLoading(true)
+ const response = await axios.get(
+ `http://localhost:8080/filaments/${id}`,
+ {
+ headers: {
+ Authorization: `Bearer ${token}`
+ }
+ }
+ )
+ setDataLoading(false)
+ editFilamentForm.setFieldsValue(response.data) // Set form values with fetched data
+ setEditFilamentFormValues(response.data)
+ } catch (error) {
+ messageApi.error('Error fetching printer details:' + error.message)
+ }
+ }
+ }
+ fetchFilamentDetails()
+ }, [id, editFilamentForm, token, messageApi])
+
+ const handleEditFilament = async () => {
+ setEditFilamentLoading(true)
+ try {
+ await axios.put(
+ `http://localhost:8080/filaments/${id}`,
+ editFilamentFormValues,
+ {
+ headers: {
+ Authorization: `Bearer ${token}`
+ }
+ }
+ )
+ messageApi.success('Filament details updated successfully.')
+ onOk()
+ } catch (error) {
+ messageApi.error('Error updating filament details: ' + error.message)
+ } finally {
+ setEditFilamentLoading(false)
+ }
+ }
+
+ const handleDeleteFilament = async () => {
+ try {
+ await axios.delete(`http://localhost:8080/filaments/${id}`, '', {
+ headers: {
+ Authorization: `Bearer ${token}`
+ }
+ })
+ messageApi.success('Filament deleted successfully.')
+ onOk()
+ } catch (error) {
+ messageApi.error('Error updating filament details: ' + error.message)
+ }
+ }
+
+ const handleImageUpload = ({ file, onSuccess }) => {
+ const reader = new FileReader()
+ reader.onload = () => {
+ onSuccess('ok')
+ }
+ reader.readAsDataURL(file)
+ }
+
+ return (
+ <>
+ {contextHolder}
+ }
+ size='large'
+ >
+
+
+
+
+
+
+
+
+
+
+ {
+ if (!value) return '£'
+ return `£${value}`
+ }}
+ step={0.01}
+ style={{ width: '100%' }}
+ addonAfter='per kg'
+ />
+
+
+ {
+ return '#' + color.toHex()
+ }}
+ >
+
+
+
+
+
+
+
+ {
+ setImageList(fileList)
+ }}
+ >
+ }>Upload
+
+
+
+ } />
+
+
+ } />
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+EditFilament.propTypes = {
+ id: PropTypes.string.isRequired,
+ onOk: PropTypes.func.isRequired
+}
+
+export default EditFilament
diff --git a/src/components/Dashboard/Production/GCodeFiles/GCodeFileInfo.jsx b/src/components/Dashboard/Production/GCodeFiles/GCodeFileInfo.jsx
new file mode 100644
index 0000000..0c33d83
--- /dev/null
+++ b/src/components/Dashboard/Production/GCodeFiles/GCodeFileInfo.jsx
@@ -0,0 +1,205 @@
+import React, { useState, useEffect } from 'react'
+import { useLocation } from 'react-router-dom'
+import axios from 'axios'
+import { Descriptions, Spin, Space, Button, message, Badge } from 'antd'
+import { LoadingOutlined, ReloadOutlined } from '@ant-design/icons'
+import IdText from '../../common/IdText.jsx'
+import moment from 'moment'
+import { capitalizeFirstLetter } from '../../utils/Utils.js'
+
+const GCodeFileInfo = () => {
+ const [gcodeFileData, setGCodeFileData] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const location = useLocation()
+ const [messageApi] = message.useMessage()
+ const gcodeFileId = new URLSearchParams(location.search).get('gcodeFileId')
+
+ useEffect(() => {
+ if (gcodeFileId) {
+ fetchFilamentDetails()
+ }
+ }, [gcodeFileId])
+
+ const fetchFilamentDetails = async () => {
+ try {
+ setLoading(true)
+ const response = await axios.get(
+ `http://localhost:8080/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 {
+ setLoading(false)
+ }
+ }
+
+ if (loading) {
+ return (
+
+ } />
+
+ )
+ }
+
+ if (error || !gcodeFileData) {
+ return (
+
+ {error || 'GCodeFile not found'}
+ } onClick={fetchFilamentDetails}>
+ Retry
+
+
+ )
+ }
+
+ return (
+
+
+
+ {gcodeFileData.id ? (
+
+ ) : (
+ 'n/a'
+ )}
+
+
+ {(() => {
+ if (gcodeFileData.createdAt) {
+ return moment(gcodeFileData.createdAt.$date).format(
+ 'YYYY-MM-DD HH:mm:ss'
+ )
+ }
+ return 'N/A'
+ })()}
+
+
+ {gcodeFileData.name || 'n/a'}
+
+
+ {gcodeFileData.gcodeFileInfo.estimatedPrintingTimeNormalMode || 'n/a'}
+
+
+ {gcodeFileData.filament ? (
+
+ ) : (
+ 'n/a'
+ )}
+
+
+ {gcodeFileData.filament ? (
+
+ ) : (
+ 'n/a'
+ )}
+
+
+ {(() => {
+ if (gcodeFileData.gcodeFileInfo.sparseInfillDensity) {
+ return gcodeFileData.gcodeFileInfo.sparseInfillDensity
+ } else {
+ return 'n/a'
+ }
+ })()}
+
+
+ {(() => {
+ if (gcodeFileData.gcodeFileInfo.sparseInfillPattern) {
+ return capitalizeFirstLetter(
+ gcodeFileData.gcodeFileInfo.sparseInfillPattern
+ )
+ } else {
+ return 'n/a'
+ }
+ })()}
+
+
+ {(() => {
+ if (gcodeFileData.gcodeFileInfo.filamentUsedMm) {
+ return `${gcodeFileData.gcodeFileInfo.filamentUsedMm}mm`
+ } else {
+ return 'n/a'
+ }
+ })()}
+
+
+ {(() => {
+ if (gcodeFileData.gcodeFileInfo.filamentUsedG) {
+ return `${gcodeFileData.gcodeFileInfo.filamentUsedG}g`
+ } else {
+ return 'n/a'
+ }
+ })()}
+
+
+ {(() => {
+ if (gcodeFileData.gcodeFileInfo.nozzleTemperature) {
+ return `${gcodeFileData.gcodeFileInfo.nozzleTemperature}°`
+ } else {
+ return 'n/a'
+ }
+ })()}
+
+
+ {(() => {
+ if (gcodeFileData.gcodeFileInfo.hotPlateTemp) {
+ return `${gcodeFileData.gcodeFileInfo.hotPlateTemp}°`
+ } else {
+ return 'n/a'
+ }
+ })()}
+
+
+ {(() => {
+ if (gcodeFileData.gcodeFileInfo.filamentSettingsId) {
+ return `${gcodeFileData.gcodeFileInfo.filamentSettingsId.replaceAll('"', '')}`
+ } else {
+ return 'n/a'
+ }
+ })()}
+
+
+ {(() => {
+ if (gcodeFileData.gcodeFileInfo.printSettingsId) {
+ return `${gcodeFileData.gcodeFileInfo.printSettingsId.replaceAll('"', '')}`
+ } else {
+ return 'n/a'
+ }
+ })()}
+
+
+ {gcodeFileData.gcodeFileInfo.thumbnail ? (
+
+ ) : (
+ 'n/a'
+ )}
+
+
+
+ )
+}
+
+export default GCodeFileInfo
diff --git a/src/components/Dashboard/Production/GCodeFiles/NewGCodeFile.jsx b/src/components/Dashboard/Production/GCodeFiles/NewGCodeFile.jsx
new file mode 100644
index 0000000..06596ef
--- /dev/null
+++ b/src/components/Dashboard/Production/GCodeFiles/NewGCodeFile.jsx
@@ -0,0 +1,501 @@
+import PropTypes from 'prop-types'
+import React, { useState, useContext } from 'react'
+import axios from 'axios'
+import {
+ capitalizeFirstLetter,
+ timeStringToMinutes
+} from '../../utils/Utils.js'
+import {
+ Form,
+ Input,
+ Button,
+ message,
+ Typography,
+ Flex,
+ Steps,
+ Divider,
+ Upload,
+ Descriptions,
+ Checkbox,
+ Spin
+} from 'antd'
+import { LoadingOutlined } from '@ant-design/icons'
+
+import { AuthContext } from '../../../Auth/AuthContext'
+
+import GCodeFileIcon from '../../../Icons/GCodeFileIcon'
+
+import FilamentSelect from '../../common/FilamentSelect'
+
+const { Dragger } = Upload
+
+const { Title } = Typography
+
+const initialNewGCodeFileForm = {
+ gcodeFileInfo: {},
+ name: '',
+ printTimeMins: 0,
+ price: 0,
+ file: null,
+ material: null
+}
+
+//const chunkSize = 5000
+
+const NewGCodeFile = ({ onOk, reset }) => {
+ const [messageApi, contextHolder] = message.useMessage()
+
+ const [newGCodeFileLoading, setNewGCodeFileLoading] = useState(false)
+ const [gcodeParsing, setGcodeParsing] = useState(false)
+
+ const [filamentSelectFilter, setFilamentSelectFilter] = useState(null)
+ const [useFilamentSelectFilter, setUseFilamentSelectFilter] = useState(true)
+
+ const [currentStep, setCurrentStep] = useState(0)
+ const [nextEnabled, setNextEnabled] = useState(false)
+ const [nextLoading, setNextLoading] = useState(false)
+
+ const [newGCodeFileForm] = Form.useForm()
+ const [newGCodeFileFormValues, setNewGCodeFileFormValues] = useState(
+ initialNewGCodeFileForm
+ )
+
+ const [gcodeFile, setGCodeFile] = useState(null)
+
+ const newGCodeFileFormUpdateValues = Form.useWatch([], newGCodeFileForm)
+
+ const { token, authenticated } = useContext(AuthContext)
+ // eslint-disable-next-line
+ const fetchFilamentDetails = async () => {
+ if (!authenticated) {
+ return
+ }
+ if (
+ newGCodeFileFormValues.filament &&
+ newGCodeFileFormValues.gcodeFileInfo
+ ) {
+ try {
+ setNextLoading(true)
+ const response = await axios.get(
+ `http://localhost:8080/filaments/${newGCodeFileFormValues.filament}`,
+ {
+ headers: {
+ Accept: 'application/json'
+ },
+ withCredentials: true // Important for including cookies
+ }
+ )
+ setNextLoading(false)
+
+ const price =
+ (response.data.price / 1000) *
+ newGCodeFileFormValues.gcodeFileInfo.filament_used_g // convert kg to g and multiply
+
+ const printTimeMins = timeStringToMinutes(
+ newGCodeFileFormValues.gcodeFileInfo
+ .estimated_printing_time_normal_mode
+ )
+ setNewGCodeFileFormValues({
+ ...newGCodeFileFormValues,
+ price,
+ printTimeMins
+ })
+ } catch (error) {
+ if (error.response) {
+ messageApi.error(
+ 'Error fetching filament data:',
+ error.response.status
+ )
+ } else {
+ messageApi.error(
+ 'An unexpected error occurred. Please try again later.'
+ )
+ }
+ }
+ }
+ }
+
+ React.useEffect(() => {
+ newGCodeFileForm
+ .validateFields({
+ validateOnly: true
+ })
+ .then(() => setNextEnabled(true))
+ .catch(() => setNextEnabled(false))
+ }, [newGCodeFileForm, newGCodeFileFormUpdateValues])
+
+ const summaryItems = [
+ {
+ key: 'name',
+ label: 'Name',
+ children: newGCodeFileFormValues.name
+ },
+ {
+ key: 'price',
+ label: 'Price / Cost',
+ children: '£' + newGCodeFileFormValues.price.toFixed(2)
+ },
+ {
+ key: 'sparse_infill_density',
+ label: 'Infill Density',
+ children: newGCodeFileFormValues.gcodeFileInfo.sparseInfillDensity
+ },
+ {
+ key: 'sparse_infill_pattern',
+ label: 'Infill Pattern',
+ children: capitalizeFirstLetter(
+ newGCodeFileFormValues.gcodeFileInfo.sparseInfillPattern
+ )
+ },
+ {
+ key: 'layer_height',
+ label: 'Layer Height',
+ children: newGCodeFileFormValues.gcodeFileInfo.layerHeight + 'mm'
+ },
+ {
+ key: 'filamentType',
+ label: 'Filament Material',
+ children: newGCodeFileFormValues.gcodeFileInfo.filamentType
+ },
+ {
+ key: 'filamentUsedG',
+ label: 'Filament Used (g)',
+ children: newGCodeFileFormValues.gcodeFileInfo.filamentUsedG + 'g'
+ },
+ {
+ key: 'filamentVendor',
+ label: 'Filament Brand',
+ children: newGCodeFileFormValues.gcodeFileInfo.filamentVendor
+ },
+
+ {
+ key: 'hotendTemperature',
+ label: 'Hotend Temperature',
+ children: newGCodeFileFormValues.gcodeFileInfo.nozzleTemperature + '°'
+ },
+ {
+ key: 'bedTemperature',
+ label: 'Bed Temperature',
+ children: newGCodeFileFormValues.gcodeFileInfo.hotPlateTemp + '°'
+ },
+ {
+ key: 'estimated_printing_time_normal_mode',
+ label: 'Est. Print Time',
+ children:
+ newGCodeFileFormValues.gcodeFileInfo.estimatedPrintingTimeNormalMode
+ }
+ ]
+
+ React.useEffect(() => {
+ if (reset) {
+ setCurrentStep(0)
+ newGCodeFileForm.resetFields()
+ }
+ }, [reset, newGCodeFileForm])
+
+ const handleNewGCodeFileUpload = async (id) => {
+ setNewGCodeFileLoading(true)
+ const formData = new FormData()
+ formData.append('gcodeFile', gcodeFile)
+ try {
+ await axios.post(
+ `http://localhost:8080/gcodefiles/${id}/content`,
+ formData,
+ {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ Authorization: `Bearer ${token}`
+ }
+ }
+ )
+ messageApi.success('Finished uploading!')
+ resetForm()
+ onOk()
+ } catch (error) {
+ messageApi.error('Error creating new gcode file: ' + error.message)
+ } finally {
+ setNewGCodeFileLoading(false)
+ }
+ }
+
+ const handleNewGCodeFile = async () => {
+ setNewGCodeFileLoading(true)
+ try {
+ const request = await axios.post(
+ `http://localhost:8080/gcodefiles`,
+ newGCodeFileFormValues,
+ {
+ headers: {
+ Authorization: `Bearer ${token}`
+ }
+ }
+ )
+ messageApi.info('New G Code file created successfully. Uploading...')
+ handleNewGCodeFileUpload(request.data._id)
+ } catch (error) {
+ messageApi.error('Error creating new gcode file: ' + error.message)
+ } finally {
+ setNewGCodeFileLoading(false)
+ }
+ }
+
+ const handleGetGCodeFileInfo = async (file) => {
+ try {
+ setGcodeParsing(true)
+ // Create a FormData object to send the file
+ const formData = new FormData()
+ formData.append('gcodeFile', file)
+
+ // Call the API to extract and parse the config block
+ const request = await axios.post(
+ `http://localhost:8080/gcodefiles/content`,
+ formData,
+ {
+ withCredentials: true // Important for including cookies
+ },
+ {
+ headers: {
+ Accept: 'application/json'
+ }
+ }
+ )
+
+ // Parse the API response
+ const parsedConfig = await request.data
+
+ // Update state with the parsed config from API
+ setNewGCodeFileFormValues({
+ ...newGCodeFileFormValues,
+ gcodeFileInfo: parsedConfig
+ })
+
+ console.log(parsedConfig)
+
+ // Update filter settings if filament info is available
+ if (parsedConfig.filament_type && parsedConfig.filament_diameter) {
+ setFilamentSelectFilter({
+ type: parsedConfig.filament_type,
+ diameter: parsedConfig.filament_diameter
+ })
+ }
+ const fileName = file.name.replace(/\.[^/.]+$/, '')
+ newGCodeFileForm.setFieldValue('name', fileName)
+ setNewGCodeFileFormValues((prev) => ({
+ ...prev,
+ name: fileName
+ }))
+ setGCodeFile(file)
+ setGcodeParsing(false)
+ setCurrentStep(currentStep + 1)
+ } catch (error) {
+ console.error('Error getting G-code file info:', error)
+ }
+ }
+
+ const resetForm = () => {
+ newGCodeFileForm.setFieldsValue(initialNewGCodeFileForm)
+ setNewGCodeFileFormValues(initialNewGCodeFileForm)
+ setGCodeFile(null)
+ setGcodeParsing(false)
+ setCurrentStep(0)
+ }
+
+ const steps = [
+ {
+ title: 'Upload',
+ key: 'upload',
+ content: (
+ <>
+ (Array.isArray(e) ? e : e && e.fileList)}
+ >
+ {
+ handleGetGCodeFileInfo(file)
+ setTimeout(() => {
+ onSuccess('ok')
+ }, 0)
+ }}
+ >
+
+ {gcodeParsing == true ? (
+
+ }
+ />
+ ) : (
+ <>
+
+
+
+
+ Click or drag gcode file here.
+
+
+ Supported file extentions: .gcode, .gco, .g
+
+ >
+ )}
+
+
+
+ >
+ )
+ },
+ {
+ title: 'Details',
+ key: 'details',
+ content: (
+ <>
+
+
+
+
+
+
+
+
+
+
+ {
+ setUseFilamentSelectFilter(e.target.checked)
+ }}
+ >
+ Filter
+
+
+
+ >
+ )
+ },
+ {
+ title: 'Summary',
+ key: 'done',
+ content: (
+ <>
+
+
+
+ >
+ )
+ }
+ ]
+
+ return (
+
+ {contextHolder}
+
+
+
+
+
+
+
+
+ New G Code File
+
+
+
+
+ )
+}
+
+NewGCodeFile.propTypes = {
+ reset: PropTypes.bool.isRequired,
+ onOk: PropTypes.func.isRequired
+}
+
+export default NewGCodeFile
diff --git a/src/components/Dashboard/Production/Overview.jsx b/src/components/Dashboard/Production/Overview.jsx
new file mode 100644
index 0000000..30ab559
--- /dev/null
+++ b/src/components/Dashboard/Production/Overview.jsx
@@ -0,0 +1,273 @@
+import React, { useEffect, useState, useContext } from 'react'
+import {
+ Descriptions,
+ Progress,
+ Space,
+ Flex,
+ Alert,
+ Statistic,
+ Typography
+} from 'antd'
+import {
+ PrinterOutlined,
+ LoadingOutlined,
+ CheckCircleOutlined,
+ PlayCircleOutlined
+} from '@ant-design/icons'
+import axios from 'axios'
+import { SocketContext } from '../context/SocketContext'
+
+const { Title } = Typography
+
+const ProductionOverview = () => {
+ const [stats, setStats] = useState({
+ totalPrinters: 0,
+ activePrinters: 0,
+ totalPrintJobs: 0,
+ activePrintJobs: 0,
+ completedPrintJobs: 0,
+ printerStatus: {
+ idle: 0,
+ printing: 0,
+ error: 0,
+ offline: 0
+ }
+ })
+
+ const { socket } = useContext(SocketContext)
+
+ useEffect(() => {
+ const fetchStats = async () => {
+ try {
+ const [printersResponse, printJobsResponse] = await Promise.all([
+ axios.get('/api/printers'),
+ axios.get('/api/print-jobs')
+ ])
+
+ const printers = printersResponse.data
+ const printJobs = printJobsResponse.data
+
+ const printerStatus = printers.reduce((acc, printer) => {
+ acc[printer.status] = (acc[printer.status] || 0) + 1
+ return acc
+ }, {})
+
+ setStats({
+ totalPrinters: printers.length,
+ activePrinters: printers.filter((p) => p.status === 'printing')
+ .length,
+ totalPrintJobs: printJobs.length,
+ activePrintJobs: printJobs.filter((job) => job.status === 'printing')
+ .length,
+ completedPrintJobs: printJobs.filter(
+ (job) => job.status === 'completed'
+ ).length,
+ printerStatus
+ })
+ } catch (error) {
+ console.error('Error fetching production stats:', error)
+ }
+ }
+
+ fetchStats()
+
+ if (socket) {
+ socket.on('printerUpdate', fetchStats)
+ socket.on('printJobUpdate', fetchStats)
+ }
+
+ return () => {
+ if (socket) {
+ socket.off('printerUpdate', fetchStats)
+ socket.off('printJobUpdate', fetchStats)
+ }
+ }
+ }, [socket])
+
+ const getPrinterStatusPercentage = (status) => {
+ const count = stats.printerStatus[status] || 0
+ if (stats.totalPrinters > 0) {
+ return Math.round((count / stats.totalPrinters) * 100)
+ }
+ return 0
+ }
+
+ const getCompletionRate = () => {
+ if (stats.totalPrintJobs > 0) {
+ return Math.round((stats.completedPrintJobs / stats.totalPrintJobs) * 100)
+ }
+ return 0
+ }
+
+ return (
+
+
+
+ Overview
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+
+
+ Printer Statistics
+
+
+
+
+
+ Total Printers
+
+ }
+ >
+ {stats.totalPrinters}
+
+
+ Active Printers
+
+ }
+ >
+ {stats.activePrinters}
+
+
+
+
+
+
+ Job Statistics
+
+
+
+
+
+ Total Print Jobs
+
+ }
+ >
+ {stats.totalPrintJobs}
+
+
+ Active Print Jobs
+
+ }
+ >
+ {stats.activePrintJobs}
+
+
+ Completed Print Jobs
+
+ }
+ >
+ {stats.completedPrintJobs}
+
+
+ 'Completion Rate'}
+ />
+
+
+
+
+
+
+ )
+}
+
+export default ProductionOverview
diff --git a/src/components/Dashboard/Production/PrintJobs.jsx b/src/components/Dashboard/Production/PrintJobs.jsx
new file mode 100644
index 0000000..df502fa
--- /dev/null
+++ b/src/components/Dashboard/Production/PrintJobs.jsx
@@ -0,0 +1,381 @@
+// src/PrintJobs.js
+
+import React, { useEffect, useState, useCallback, useContext } from 'react'
+import { useNavigate } from 'react-router-dom'
+import axios from 'axios'
+import moment from 'moment'
+import {
+ Table,
+ Button,
+ Flex,
+ Space,
+ Modal,
+ Dropdown,
+ message,
+ notification,
+ Input,
+ Typography
+} from 'antd'
+import { createStyles } from 'antd-style'
+import {
+ EditOutlined,
+ PlusOutlined,
+ LoadingOutlined,
+ InfoCircleOutlined,
+ PlayCircleOutlined,
+ ReloadOutlined,
+ FilterOutlined,
+ CloseOutlined,
+ CheckCircleOutlined,
+ CloseCircleOutlined,
+ PauseCircleOutlined,
+ QuestionCircleOutlined
+} from '@ant-design/icons'
+
+import { AuthContext } from '../../Auth/AuthContext'
+import { SocketContext } from '../context/SocketContext'
+import NewPrintJob from './PrintJobs/NewPrintJob'
+import JobState from '../common/JobState'
+import SubJobCounter from '../common/SubJobCounter'
+import IdText from '../common/IdText'
+
+const { Text } = Typography
+
+const useStyle = createStyles(({ css, token }) => {
+ const { antCls } = token
+ return {
+ customTable: css`
+ ${antCls}-table {
+ ${antCls}-table-container {
+ ${antCls}-table-body,
+ ${antCls}-table-content {
+ scrollbar-width: thin;
+ scrollbar-color: #eaeaea transparent;
+ scrollbar-gutter: stable;
+ }
+ }
+ }
+ `
+ }
+})
+
+const PrintJobs = () => {
+ const { styles } = useStyle()
+ const [messageApi, contextHolder] = message.useMessage()
+ const [notificationApi, notificationContextHolder] =
+ notification.useNotification()
+ const navigate = useNavigate()
+ const [printJobsData, setPrintJobsData] = useState([])
+
+ const [showFilters, setShowFilters] = useState(false)
+ const [filters, setFilters] = useState({
+ id: '',
+ state: ''
+ })
+
+ const [newPrintJobOpen, setNewPrintJobOpen] = useState(false)
+ const [loading, setLoading] = useState(true)
+
+ const { authenticated } = useContext(AuthContext)
+ const { socket } = useContext(SocketContext)
+
+ const handleDeployPrintJob = (printJobId) => {
+ if (socket) {
+ messageApi.info(`Print job ${printJobId} deployment initiated`)
+ socket.emit('server.job_queue.deploy', { printJobId }, (response) => {
+ if (response == false) {
+ notificationApi.error({
+ message: 'Print job deployment failed',
+ description: 'Please try again later'
+ })
+ } else {
+ notificationApi.success({
+ message: 'Print job deployment initiated',
+ description: 'Please wait for the print job to start'
+ })
+ }
+ })
+ navigate(`/production/printjobs/info?printJobId=${printJobId}`)
+ } else {
+ messageApi.error('Socket connection not available')
+ }
+ }
+
+ const fetchPrintJobsData = useCallback(async () => {
+ if (!authenticated) {
+ return
+ }
+ try {
+ const response = await axios.get('http://localhost:8080/printjobs', {
+ params: {
+ page: 1,
+ limit: 25
+ },
+ headers: {
+ Accept: 'application/json'
+ },
+ withCredentials: true
+ })
+ setLoading(false)
+ setPrintJobsData(response.data)
+ } catch (error) {
+ setLoading(false)
+ if (error.response) {
+ messageApi.error(
+ 'Error fetching print jobs data:',
+ error.response.status
+ )
+ } else {
+ messageApi.error(
+ 'An unexpected error occurred. Please try again later.'
+ )
+ }
+ }
+ }, [authenticated, messageApi])
+
+ useEffect(() => {
+ // Fetch initial data
+ if (authenticated) {
+ fetchPrintJobsData()
+ }
+ }, [authenticated, fetchPrintJobsData])
+
+ const handleFilterChange = (field, value) => {
+ setFilters((prev) => ({
+ ...prev,
+ [field]: value
+ }))
+ }
+
+ const filteredData = printJobsData.filter((printJob) => {
+ const matchesId = printJob.id
+ .toLowerCase()
+ .includes(filters.id.toLowerCase())
+ const matchesState = printJob.state.type
+ .toLowerCase()
+ .includes(filters.state.toLowerCase())
+ return matchesId && matchesState
+ })
+
+ // Column definitions
+ const columns = [
+ {
+ title: '',
+ dataIndex: '',
+ key: '',
+ width: 40,
+ fixed: 'left',
+ render: () =>
+ },
+ {
+ title: 'GCode File Name',
+ dataIndex: 'gcodeFile',
+ key: 'gcodeFileName',
+ width: 200,
+ fixed: 'left',
+ render: (gcodeFile) => {gcodeFile.name}
+ },
+ {
+ title: 'ID',
+ dataIndex: 'id',
+ key: 'id',
+ width: 165,
+ render: (text) =>
+ },
+ {
+ title: 'State',
+ key: 'state',
+ width: 240,
+ render: (record) => {
+ return
+ }
+ },
+ {
+ title: ,
+ key: 'complete',
+ width: 70,
+ render: (record) => {
+ return
+ }
+ },
+ {
+ title: ,
+ key: 'queued',
+ width: 70,
+ render: (record) => {
+ return
+ }
+ },
+ {
+ title: ,
+ key: 'failed',
+ width: 70,
+ render: (record) => {
+ return
+ }
+ },
+ {
+ title: ,
+ key: 'draft',
+ width: 70,
+ render: (record) => {
+ return
+ }
+ },
+ {
+ title: 'Started At',
+ dataIndex: 'startedAt',
+ key: 'startedAt',
+ width: 180,
+ render: (startedAt) => {
+ if (startedAt) {
+ const formattedDate = moment(startedAt).format('YYYY-MM-DD HH:mm:ss')
+ return {formattedDate}
+ } else {
+ return 'n/a'
+ }
+ }
+ },
+ {
+ title: 'Actions',
+ key: 'operation',
+ fixed: 'right',
+ width: 150,
+ render: (record) => {
+ return (
+
+ {record.state.type === 'draft' ? (
+ }
+ onClick={() => handleDeployPrintJob(record.id)}
+ />
+ ) : (
+ }
+ onClick={() =>
+ navigate(`/production/printjobs/info?printJobId=${record.id}`)
+ }
+ />
+ )}
+
+
+
+
+ )
+ }
+ }
+ ]
+
+ const getPrintJobActionItems = (printJobId) => {
+ return {
+ items: [
+ {
+ label: 'Info',
+ key: 'info',
+ icon:
+ },
+ {
+ label: 'Edit',
+ key: 'edit',
+ icon:
+ }
+ ],
+ onClick: ({ key }) => {
+ if (key === 'edit') {
+ showNewPrintJobModal(printJobId)
+ } else if (key === 'info') {
+ navigate(`/production/printjobs/info?printJobId=${printJobId}`)
+ }
+ }
+ }
+ }
+
+ const actionItems = {
+ items: [
+ {
+ label: 'New Print Job',
+ key: 'newPrintJob',
+ icon:
+ },
+ { type: 'divider' },
+ {
+ label: 'Reload List',
+ key: 'reloadList',
+ icon:
+ }
+ ],
+ onClick: ({ key }) => {
+ if (key === 'newPrintJob') {
+ showNewPrintJobModal()
+ } else if (key === 'reloadList') {
+ fetchPrintJobsData()
+ }
+ }
+ }
+
+ const showNewPrintJobModal = () => {
+ setNewPrintJobOpen(true)
+ }
+
+ return (
+ <>
+ {notificationContextHolder}
+
+ {contextHolder}
+
+
+
+
+ : }
+ onClick={() => setShowFilters(!showFilters)}
+ />
+ {showFilters && (
+ <>
+ handleFilterChange('id', e.target.value)}
+ style={{ width: 200 }}
+ />
+ handleFilterChange('state', e.target.value)}
+ style={{ width: 200 }}
+ />
+ >
+ )}
+
+
+ }}
+ scroll={{ y: 'calc(100vh - 270px)' }}
+ />
+
+ {
+ setNewPrintJobOpen(false)
+ }}
+ >
+ {
+ setNewPrintJobOpen(false)
+ fetchPrintJobsData()
+ }}
+ reset={newPrintJobOpen}
+ />
+
+ >
+ )
+}
+
+export default PrintJobs
diff --git a/src/components/Dashboard/Production/PrintJobs/NewPrintJob.jsx b/src/components/Dashboard/Production/PrintJobs/NewPrintJob.jsx
new file mode 100644
index 0000000..bfd2651
--- /dev/null
+++ b/src/components/Dashboard/Production/PrintJobs/NewPrintJob.jsx
@@ -0,0 +1,253 @@
+import React, { useState } from 'react'
+import axios from 'axios'
+import {
+ Form,
+ Button,
+ message,
+ Typography,
+ Flex,
+ Steps,
+ Col,
+ Row,
+ Divider,
+ Checkbox,
+ Descriptions,
+ InputNumber
+} from 'antd'
+import PropTypes from 'prop-types'
+
+import GCodeFileSelect from '../../common/GCodeFileSelect'
+import PrinterSelect from '../../common/PrinterSelect'
+
+const { Title, Text } = Typography
+
+const initialNewPrintJobForm = {}
+
+const NewPrintJob = ({ onOk, reset }) => {
+ NewPrintJob.propTypes = {
+ onOk: PropTypes.func.isRequired,
+ reset: PropTypes.bool.isRequired
+ }
+
+ const [messageApi, contextHolder] = message.useMessage()
+ const [newPrintJobLoading, setNewPrintJobLoading] = useState(false)
+ const [currentStep, setCurrentStep] = useState(0)
+ const [nextEnabled, setNextEnabled] = useState(false)
+ const [newPrintJobForm] = Form.useForm()
+ const [newPrintJobFormValues, setNewPrintJobFormValues] = useState(
+ initialNewPrintJobForm
+ )
+ const [useAnyPrinter, setUseAnyPrinter] = useState(true)
+
+ const newPrintJobFormUpdateValues = Form.useWatch([], newPrintJobForm)
+
+ React.useEffect(() => {
+ newPrintJobForm
+ .validateFields({
+ validateOnly: true
+ })
+ .then(() => setNextEnabled(true))
+ .catch(() => setNextEnabled(false))
+ }, [newPrintJobForm, newPrintJobFormUpdateValues])
+
+ const summaryItems = [
+ {
+ key: 'quantity',
+ label: 'Quantity',
+ children: newPrintJobFormValues.quantity
+ }
+ ]
+
+ if (!useAnyPrinter && newPrintJobFormValues.printers) {
+ const printerList = newPrintJobFormValues.printers
+
+ summaryItems.splice(2, 0, {
+ key: 'printer',
+ label: 'Printers',
+ children: `${printerList.length} printer(s) selected`
+ })
+ }
+
+ React.useEffect(() => {
+ if (reset) {
+ newPrintJobForm.resetFields()
+ }
+ }, [reset, newPrintJobForm])
+
+ const handleUseAnyPrinterChecked = (e) => {
+ const checked = e.target.checked
+ setUseAnyPrinter(checked)
+ if (checked === true) {
+ newPrintJobForm.resetFields(['printer'])
+ setNewPrintJobFormValues({ ...newPrintJobFormValues, printer: null })
+ }
+ }
+
+ const handleNewPrintJob = async () => {
+ setNewPrintJobLoading(true)
+ try {
+ await axios.post(
+ `http://localhost:8080/printjobs`,
+ newPrintJobFormValues,
+ {
+ headers: {
+ Accept: 'application/json'
+ },
+ withCredentials: true // Important for including cookies
+ }
+ )
+ messageApi.success('New print job created successfully.')
+ onOk()
+ } catch (error) {
+ messageApi.error('Error creating new print job: ' + error.message)
+ } finally {
+ setNewPrintJobLoading(false)
+ }
+ }
+
+ const steps = [
+ {
+ title: 'Required',
+ key: 'required',
+ content: (
+ <>
+
+ Please select a G Code File:
+
+
+
+
+
+
+
+
+
+
+ Use any printer configured.
+
+
+
+
+
+ >
+ )
+ },
+ {
+ title: 'Summary',
+ key: 'done',
+ content: (
+
+
+
+ )
+ }
+ ]
+
+ return (
+
+ {contextHolder}
+
+
+
+
+
+
+
+
+
+ New PrintJob
+
+
+
+
+
+ )
+}
+
+export default NewPrintJob
diff --git a/src/components/Dashboard/Production/PrintJobs/PrintJobInfo.jsx b/src/components/Dashboard/Production/PrintJobs/PrintJobInfo.jsx
new file mode 100644
index 0000000..eabfdcd
--- /dev/null
+++ b/src/components/Dashboard/Production/PrintJobs/PrintJobInfo.jsx
@@ -0,0 +1,174 @@
+import React, { useState, useEffect, useContext } from 'react'
+import { useLocation } from 'react-router-dom'
+import axios from 'axios'
+import {
+ Descriptions,
+ Spin,
+ Space,
+ Button,
+ message,
+ Progress,
+ Typography
+} from 'antd'
+import { LoadingOutlined, ReloadOutlined } from '@ant-design/icons'
+import moment from 'moment'
+import JobState from '../../common/JobState'
+import IdText from '../../common/IdText'
+import SubJobsTree from '../../common/SubJobsTree'
+import { SocketContext } from '../../context/SocketContext'
+
+const { Title } = Typography
+
+const PrintJobInfo = () => {
+ const [printJobData, setPrintJobData] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const location = useLocation()
+ const [messageApi] = message.useMessage()
+ const printJobId = new URLSearchParams(location.search).get('printJobId')
+ const { socket } = useContext(SocketContext)
+
+ useEffect(() => {
+ if (printJobId) {
+ fetchPrintJobDetails()
+ }
+ }, [printJobId])
+
+ useEffect(() => {
+ if (socket && printJobId) {
+ socket.on('notify_job_update', (updateData) => {
+ if (updateData.id === printJobId) {
+ setPrintJobData((prevData) => {
+ if (!prevData) return prevData
+ return {
+ ...prevData,
+ state: updateData.state,
+ ...updateData
+ }
+ })
+ }
+ })
+ }
+
+ return () => {
+ if (socket) {
+ socket.off('notify_job_update')
+ }
+ }
+ }, [socket, printJobId])
+
+ const fetchPrintJobDetails = async () => {
+ try {
+ setLoading(true)
+ const response = await axios.get(
+ `http://localhost:8080/printjobs/${printJobId}`,
+ {
+ headers: {
+ Accept: 'application/json'
+ },
+ withCredentials: true // Important for including cookies
+ }
+ )
+ setPrintJobData(response.data)
+ setError(null)
+ } catch (err) {
+ setError('Failed to fetch print job details')
+ messageApi.error('Failed to fetch print job details')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (loading) {
+ return (
+
+ } />
+
+ )
+ }
+
+ if (error || !printJobData) {
+ return (
+
+ {error || 'Print job not found'}
+ } onClick={fetchPrintJobDetails}>
+ Retry
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {printJobData.gcodeFile?.name || 'Not specified'}
+
+
+
+
+
+ {printJobData.quantity || 1}
+
+
+ {(() => {
+ if (printJobData.createdat) {
+ return moment(printJobData.createdat.$date).format(
+ 'YYYY-MM-DD HH:mm:ss'
+ )
+ }
+ return 'N/A'
+ })()}
+
+
+ {(() => {
+ if (printJobData.started_at) {
+ return moment(printJobData.started_at.$date).format(
+ 'YYYY-MM-DD HH:mm:ss'
+ )
+ }
+ return 'N/A'
+ })()}
+
+ {printJobData.state.type === 'printing' && (
+
+
+
+ )}
+
+ {printJobData.printers?.length > 0 ? (
+ {printJobData.printers.length} printers assigned
+ ) : (
+ 'Any available printer'
+ )}
+
+
+
+ Sub Job Information
+
+
+
+ )
+}
+
+export default PrintJobInfo
diff --git a/src/components/Dashboard/Production/Printers.jsx b/src/components/Dashboard/Production/Printers.jsx
new file mode 100644
index 0000000..fe7b75d
--- /dev/null
+++ b/src/components/Dashboard/Production/Printers.jsx
@@ -0,0 +1,329 @@
+// src/Printers.js
+
+import React, { useEffect, useState, useContext, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+import axios from 'axios'
+import {
+ Table,
+ Button,
+ message,
+ Dropdown,
+ Space,
+ Flex,
+ Input,
+ Tag,
+ Modal
+} from 'antd'
+import { createStyles } from 'antd-style'
+import {
+ InfoCircleOutlined,
+ EditOutlined,
+ ControlOutlined,
+ LoadingOutlined,
+ ReloadOutlined,
+ FilterOutlined,
+ CloseOutlined,
+ PlusOutlined,
+ PrinterOutlined
+} from '@ant-design/icons'
+
+import { AuthContext } from '../../Auth/AuthContext'
+import PrinterState from '../common/PrinterState'
+import NewPrinter from './Printers/NewPrinter'
+import IdText from '../common/IdText'
+
+const useStyle = createStyles(({ css, token }) => {
+ const { antCls } = token
+ return {
+ customTable: css`
+ ${antCls}-table {
+ ${antCls}-table-container {
+ ${antCls}-table-body,
+ ${antCls}-table-content {
+ scrollbar-width: thin;
+ scrollbar-color: #eaeaea transparent;
+ scrollbar-gutter: stable;
+ }
+ }
+ }
+ `
+ }
+})
+
+const Printers = () => {
+ const { styles } = useStyle()
+ const [printerData, setPrinterData] = useState([])
+
+ const [messageApi] = message.useMessage()
+ const [showFilters, setShowFilters] = useState(false)
+
+ const { authenticated } = useContext(AuthContext)
+ const [loading, setLoading] = useState(false)
+ const [filters, setFilters] = useState({
+ printerName: '',
+ host: '',
+ tags: ''
+ })
+
+ const [newPrinterOpen, setNewPrinterOpen] = useState(false)
+
+ const navigate = useNavigate()
+
+ const fetchPrintersData = useCallback(async () => {
+ try {
+ const response = await axios.get('http://localhost:8080/printers', {
+ params: {
+ page: 1,
+ limit: 25
+ },
+ headers: {
+ Accept: 'application/json'
+ },
+ withCredentials: true // Important for including cookies
+ })
+ setLoading(false)
+ setPrinterData(response.data)
+ } catch (error) {
+ if (error.response) {
+ messageApi.error('Error fetching printer data:', error.response.status)
+ } else {
+ messageApi.error(
+ 'An unexpected error occurred. Please try again later.'
+ )
+ }
+ }
+ }, [messageApi])
+
+ const handleFilterChange = (field, value) => {
+ setFilters((prev) => ({
+ ...prev,
+ [field]: value
+ }))
+ }
+
+ const getPrinterActionItems = (printerId) => {
+ return {
+ items: [
+ {
+ label: 'Control',
+ key: 'control',
+ icon:
+ },
+ {
+ type: 'divider'
+ },
+ {
+ label: 'Info',
+ key: 'info',
+ icon:
+ },
+ {
+ label: 'Edit',
+ key: 'edit',
+ icon:
+ }
+ ],
+ onClick: ({ key }) => {
+ if (key === 'info') {
+ navigate(`/production/printers/info?printerId=${printerId}`)
+ } else if (key === 'control') {
+ navigate(`/production/printers/control?printerId=${printerId}`)
+ }
+ }
+ }
+ }
+
+ useEffect(() => {
+ if (authenticated) {
+ // Fetch initial data
+ fetchPrintersData()
+ }
+ }, [fetchPrintersData, authenticated])
+
+ const filteredData = printerData.filter((printer) => {
+ const matchesName = printer.printerName
+ .toLowerCase()
+ .includes(filters.printerName.toLowerCase())
+ const matchesHost = printer.moonraker.host
+ .toLowerCase()
+ .includes(filters.host.toLowerCase())
+ const matchesTags =
+ !filters.tags ||
+ (printer.tags &&
+ printer.tags.some((tag) =>
+ tag.toLowerCase().includes(filters.tags.toLowerCase())
+ ))
+ return matchesName && matchesHost && matchesTags
+ })
+
+ const actionItems = {
+ items: [
+ {
+ label: 'New Printer',
+ key: 'newPrinter',
+ icon:
+ },
+ { type: 'divider' },
+ {
+ label: 'Reload List',
+ key: 'reloadList',
+ icon:
+ }
+ ],
+ onClick: ({ key }) => {
+ if (key === 'reloadList') {
+ fetchPrintersData()
+ } else if (key === 'newPrinter') {
+ setNewPrinterOpen(true)
+ }
+ }
+ }
+
+ // Column definitions
+ const columns = [
+ {
+ title: '',
+ dataIndex: '',
+ key: '',
+ width: 40,
+ fixed: 'left',
+ render: () =>
+ },
+ {
+ title: 'Name',
+ dataIndex: 'printerName',
+ key: 'printerName',
+ width: 200,
+ fixed: 'left'
+ },
+ {
+ title: 'ID',
+ dataIndex: 'id',
+ key: 'id',
+ width: 165,
+ render: (text) =>
+ },
+
+ {
+ title: 'State',
+ key: 'state',
+ width: 240,
+ render: (record) => {
+ return (
+
+ )
+ }
+ },
+ {
+ title: 'Tags',
+ dataIndex: 'tags',
+ key: 'tags',
+ width: 170,
+ render: (tags) => {
+ if (!tags || !Array.isArray(tags)) return null
+ return (
+
+ {tags.map((tag, index) => (
+
+ {tag}
+
+ ))}
+
+ )
+ }
+ },
+ {
+ title: 'Actions',
+ key: 'operation',
+ fixed: 'right',
+ width: 150,
+ render: (record) => {
+ return (
+
+ }
+ onClick={() =>
+ navigate(`/production/printers/control?printerId=${record.id}`)
+ }
+ />
+
+
+
+
+ )
+ }
+ }
+ ]
+
+ return (
+ <>
+
+
+
+
+
+ : }
+ onClick={() => setShowFilters(!showFilters)}
+ />
+ {showFilters && (
+ <>
+
+ handleFilterChange('printerName', e.target.value)
+ }
+ style={{ width: 200 }}
+ />
+ handleFilterChange('host', e.target.value)}
+ style={{ width: 200 }}
+ />
+ handleFilterChange('tags', e.target.value)}
+ style={{ width: 200 }}
+ />
+ >
+ )}
+
+
+ }}
+ scroll={{ y: 'calc(100vh - 270px)' }}
+ />
+ {
+ setNewPrinterOpen(false)
+ }}
+ >
+ {
+ setNewPrinterOpen(false)
+ fetchPrintersData()
+ }}
+ reset={newPrinterOpen}
+ />
+
+
+ >
+ )
+}
+
+export default Printers
diff --git a/src/components/Dashboard/Production/Printers/ChangeFillament.jsx b/src/components/Dashboard/Production/Printers/ChangeFillament.jsx
new file mode 100644
index 0000000..ca6363d
--- /dev/null
+++ b/src/components/Dashboard/Production/Printers/ChangeFillament.jsx
@@ -0,0 +1,333 @@
+import React, { useState, useContext, useRef } from 'react'
+import axios from 'axios'
+import {
+ Form,
+ Input,
+ Button,
+ message,
+ Typography,
+ Flex,
+ Steps,
+ Col,
+ Row,
+ Divider,
+ Upload,
+ Descriptions
+} from 'antd'
+
+import { AuthContext } from '../../Auth/AuthContext'
+
+import GCodeFileIcon from '../../Icons/GCodeFileIcon'
+
+import FilamentSelect from '../common/FilamentSelect'
+import PrinterSelect from '../common/PrinterSelect'
+
+const { Dragger } = Upload
+
+const { Title, Text } = Typography
+
+const initialNewGCodeFileForm = {
+ name: '',
+ brand: '',
+ type: '',
+ price: 0,
+ color: '#FFFFFF',
+ diameter: '1.75',
+ image: null,
+ url: '',
+ barcode: ''
+}
+
+const chunkSize = 5000
+
+const NewGCodeFile = ({ onOk, reset }) => {
+ const [messageApi, contextHolder] = message.useMessage()
+
+ const [newGCodeFileLoading, setNewGCodeFileLoading] = useState(false)
+ const [currentStep, setCurrentStep] = useState(0)
+ const [nextEnabled, setNextEnabled] = useState(false)
+
+ const [newGCodeFileForm] = Form.useForm()
+ const [newGCodeFileFormValues, setNewGCodeFileFormValues] = useState(
+ initialNewGCodeFileForm
+ )
+
+ const [imageList, setImageList] = useState([])
+
+ const [gcode, setGCode] = useState('')
+
+ const newGCodeFileFormUpdateValues = Form.useWatch([], newGCodeFileForm)
+
+ const { token } = useContext(AuthContext)
+
+ const gcodePreviewRef = useRef(null)
+
+ React.useEffect(() => {
+ newGCodeFileForm
+ .validateFields({
+ validateOnly: true
+ })
+ .then(() => setNextEnabled(true))
+ .catch(() => setNextEnabled(false))
+ }, [newGCodeFileForm, newGCodeFileFormUpdateValues])
+
+ const summaryItems = [
+ {
+ key: 'name',
+ label: 'Name',
+ children: newGCodeFileFormValues.name
+ },
+ {
+ key: 'brand',
+ label: 'Brand',
+ children: newGCodeFileFormValues.brand
+ },
+ {
+ key: 'type',
+ label: 'Material',
+ children: () => {
+ if (newGCodeFileFormValues.filament != null) {
+ return '1 selected.'
+ } else {
+ return '0 selected.'
+ }
+ }
+ },
+ {
+ key: 'price',
+ label: 'Price',
+ children: '£' + newGCodeFileFormValues.price + ' per kg'
+ }
+ ]
+
+ React.useEffect(() => {
+ if (reset) {
+ newGCodeFileForm.resetFields()
+ }
+ }, [reset, newGCodeFileForm])
+
+ const handleNewGCodeFile = async () => {
+ setNewGCodeFileLoading(true)
+ try {
+ await axios.post(
+ `http://localhost:8080/gcodefiles`,
+ newGCodeFileFormValues,
+ {
+ headers: {
+ Authorization: `Bearer ${token}`
+ }
+ }
+ )
+ messageApi.success('New G Code file created successfully.')
+ onOk()
+ } catch (error) {
+ messageApi.error('Error creating new gcode file: ' + error.message)
+ } finally {
+ setNewGCodeFileLoading(false)
+ }
+ }
+
+ const getBase64 = (file) => {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.readAsDataURL(file)
+ reader.onload = () => resolve(reader.result)
+ reader.onerror = (error) => reject(error)
+ })
+ }
+
+ const handleGCodeUpload = (file) => {
+ const reader = new FileReader()
+ reader.onload = () => {
+ console.log(reader.result)
+ setGCode(reader.result)
+ }
+ reader.readAsText(file)
+ }
+
+ const steps = [
+ {
+ title: 'Details',
+ key: 'details',
+ content: (
+ <>
+
+ Please provide the following information:
+
+
+
+
+
+
+
+
+ >
+ )
+ },
+ {
+ title: 'Upload',
+ key: 'upload',
+ content: (
+ <>
+ (Array.isArray(e) ? e : e && e.fileList)}
+ >
+ {
+ handleGCodeUpload(file)
+ setTimeout(() => {
+ onSuccess('ok')
+ }, 0)
+ }}
+ >
+
+
+
+
+ Click or gcode instruction file here.
+
+
+ Supported file extentions: .gcode, .gco, .g
+
+
+
+ >
+ )
+ },
+ {
+ title: 'Targets',
+ key: 'targets',
+ content: (
+ <>
+
+
+ Please provide at least one target to deploy this G Code file:
+
+
+
+
+
+ >
+ )
+ },
+ {
+ title: 'Summary',
+ key: 'done',
+ content: (
+
+
+
+ )
+ }
+ ]
+
+ return (
+
+ {contextHolder}
+
+
+
+
+
+
+
+
+
+ New G Code File
+
+
+
+
+
+ )
+}
+
+export default NewGCodeFile
diff --git a/src/components/Dashboard/Production/Printers/ControlPrinter.jsx b/src/components/Dashboard/Production/Printers/ControlPrinter.jsx
new file mode 100644
index 0000000..453c4b1
--- /dev/null
+++ b/src/components/Dashboard/Production/Printers/ControlPrinter.jsx
@@ -0,0 +1,357 @@
+import React, { useState, useContext, useCallback, useEffect } from 'react'
+import axios from 'axios'
+import { useLocation } from 'react-router-dom'
+import {
+ Button,
+ message,
+ Spin,
+ Flex,
+ Card,
+ Dropdown,
+ Space,
+ Descriptions,
+ Progress
+} from 'antd'
+import {
+ LoadingOutlined,
+ PlayCircleOutlined,
+ ExclamationCircleOutlined,
+ ReloadOutlined,
+ EditOutlined,
+ PauseCircleOutlined,
+ CloseCircleOutlined
+} from '@ant-design/icons'
+
+import { SocketContext } from '../../context/SocketContext'
+
+import PrinterTemperaturePanel from '../../common/PrinterTemperaturePanel'
+import PrinterMovementPanel from '../../common/PrinterMovementPanel'
+import PrinterState from '../../common/PrinterState'
+import { AuthContext } from '../../../Auth/AuthContext'
+import PrinterSubJobsTree from '../../common/PrinterJobsTree'
+import IdText from '../../common/IdText'
+
+// Helper function to parse query parameters
+const useQuery = () => {
+ return new URLSearchParams(useLocation().search)
+}
+
+const ControlPrinter = () => {
+ const [messageApi] = message.useMessage()
+ const query = useQuery()
+ const printerId = query.get('printerId')
+
+ const [printerData, setPrinterData] = useState(null)
+ const [initialized, setInitialized] = useState(false)
+
+ const { socket } = useContext(SocketContext)
+ const { authenticated } = useContext(AuthContext)
+
+ // Fetch printer details when the component mounts
+ const fetchPrinterDetails = useCallback(async () => {
+ if (printerId) {
+ try {
+ const response = await axios.get(
+ `http://localhost:8080/printers/${printerId}`,
+ {
+ headers: {
+ Accept: 'application/json'
+ },
+ withCredentials: true // Important for including cookies
+ }
+ )
+
+ setPrinterData(response.data)
+ } catch (error) {
+ if (error.response) {
+ messageApi.error(
+ 'Error fetching printer data:',
+ error.response.status
+ )
+ } else {
+ messageApi.error(
+ 'An unexpected error occurred. Please try again later.'
+ )
+ }
+ }
+ }
+ }, [printerId, messageApi])
+
+ // Add WebSocket event listener for real-time updates
+ useEffect(() => {
+ if (socket && !initialized && printerId) {
+ setInitialized(true)
+ socket.on('notify_printer_update', (statusUpdate) => {
+ setPrinterData((prevData) => {
+ if (statusUpdate?.id === printerId) {
+ return {
+ ...prevData,
+ ...statusUpdate
+ }
+ }
+ return prevData
+ })
+ })
+ }
+ return () => {
+ if (socket && initialized) {
+ socket.off('notify_printer_update')
+ }
+ }
+ }, [socket, initialized, printerId])
+
+ function handleEmergencyStop() {
+ console.log('Emergency stop button clicked')
+ socket.emit('printer.emergency_stop', { printerId })
+ }
+
+ useEffect(() => {
+ if (authenticated) {
+ fetchPrinterDetails()
+ }
+ }, [authenticated, fetchPrinterDetails])
+
+ const actionItems = {
+ items: [
+ {
+ label: 'Resume Print',
+ key: 'resumePrint',
+ icon:
+ },
+ {
+ label: 'Pause Print',
+ key: 'pausePrint',
+ icon:
+ },
+ {
+ label: 'Cancel Print',
+ key: 'cancelPrint',
+ icon:
+ },
+ {
+ type: 'divider'
+ },
+ {
+ label: 'Start Queue',
+ key: 'startQueue',
+ disabled:
+ printerData?.state?.type === 'printing' ||
+ printerData?.state?.type === 'deploying' ||
+ printerData?.state?.type === 'paused' ||
+ printerData?.state?.type === 'error',
+
+ icon:
+ },
+ {
+ label: 'Pause Queue',
+ key: 'pauseQueue',
+ icon:
+ },
+ {
+ type: 'divider'
+ },
+ {
+ label: 'Restart Host',
+ key: 'restartHost',
+ icon:
+ },
+ {
+ label: 'Restart Firmware',
+ key: 'restartFirmware',
+ icon:
+ },
+ {
+ type: 'divider'
+ },
+ {
+ label: 'Edit Printer',
+ key: 'edit',
+ icon:
+ }
+ ],
+ onClick: ({ key }) => {
+ if (key === 'restartHost') {
+ socket.emit('printer.restart', { printerId })
+ } else if (key === 'restartFirmware') {
+ socket.emit('printer.firmware_restart', { printerId })
+ } else if (key === 'resumePrint') {
+ socket.emit('printer.print.resume', { printerId })
+ } else if (key === 'pausePrint') {
+ socket.emit('printer.print.pause', { printerId })
+ } else if (key === 'cancelPrint') {
+ socket.emit('printer.print.cancel', { printerId })
+ } else if (key === 'startQueue') {
+ socket.emit('server.job_queue.start', { printerId })
+ } else if (key === 'pauseQueue') {
+ socket.emit('server.job_queue.pause', { printerId })
+ }
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {printerData ? (
+
+ ) : (
+ } size='small' />
+ )}
+
+
+ }
+ danger
+ onClick={handleEmergencyStop}
+ >
+
+ ) : (
+
+ )
+ }
+ disabled={
+ !(
+ printerData?.state?.type == 'printing' ||
+ printerData?.state?.type == 'paused'
+ )
+ }
+ onClick={() => {
+ if (printerData?.state?.type === 'paused') {
+ socket.emit('printer.print.resume', { printerId })
+ } else {
+ socket.emit('printer.print.pause', { printerId })
+ }
+ }}
+ >
+ }
+ disabled={
+ printerData?.state?.type === 'printing' ||
+ printerData?.state?.type === 'deploying' ||
+ printerData?.state?.type === 'paused' ||
+ printerData?.state?.type === 'error'
+ }
+ onClick={() => {
+ socket.emit('server.job_queue.start', { printerId })
+ }}
+ >
+
+
+
+ {printerData ? (
+
+
+
+
+ {printerData.printerName}
+
+
+ {printerData.currentJob?.id ? (
+
+ ) : (
+ 'n/a'
+ )}
+
+
+ {printerData.currentJob?.gcodeFile?.name || 'n/a'}
+
+
+ {printerData.currentJob?.gcodeFile ? (
+
+ ) : (
+ 'n/a'
+ )}
+
+
+
+ {(() => {
+ if (
+ printerData.currentJob?.gcodeFile?.gcodeFileInfo
+ .estimatedPrintingTimeNormalMode
+ ) {
+ return `${
+ printerData.currentJob.gcodeFile.gcodeFileInfo
+ .estimatedPrintingTimeNormalMode
+ }`
+ }
+ return 'n/a'
+ })()}
+
+
+
+ {(() => {
+ if (
+ printerData?.currentJob?.gcodeFile.gcodeFileInfo
+ .printSettingsId
+ ) {
+ return `${printerData.currentJob.gcodeFile.gcodeFileInfo.printSettingsId.replaceAll('"', '')}`
+ } else {
+ return 'n/a'
+ }
+ })()}
+
+
+ {printerData.currentSubJob?.state.type === 'printing' && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
} size='large' />
+ )}
+
+
+ >
+ )
+}
+
+export default ControlPrinter
diff --git a/src/components/Dashboard/Production/Printers/NewPrinter.jsx b/src/components/Dashboard/Production/Printers/NewPrinter.jsx
new file mode 100644
index 0000000..e10097b
--- /dev/null
+++ b/src/components/Dashboard/Production/Printers/NewPrinter.jsx
@@ -0,0 +1,565 @@
+import React, { useState, useContext, useEffect, useCallback } from 'react'
+import axios from 'axios'
+import {
+ Form,
+ Button,
+ message,
+ Typography,
+ Flex,
+ Steps,
+ Divider,
+ Input,
+ Select,
+ Space,
+ Descriptions,
+ List,
+ InputNumber,
+ notification,
+ Progress,
+ Modal,
+ Radio
+} from 'antd'
+import {
+ SearchOutlined,
+ SettingOutlined,
+ EditOutlined
+} from '@ant-design/icons'
+import PropTypes from 'prop-types'
+import { SocketContext } from '../../context/SocketContext'
+
+const { Title } = Typography
+
+const initialNewPrinterForm = {
+ moonraker: {
+ protocol: 'ws',
+ host: '',
+ port: '',
+ apiKey: ''
+ }
+}
+
+const NewPrinter = ({ onOk, reset }) => {
+ NewPrinter.propTypes = {
+ onOk: PropTypes.func.isRequired,
+ reset: PropTypes.bool.isRequired
+ }
+
+ const { socket } = useContext(SocketContext)
+ const [messageApi, contextHolder] = message.useMessage()
+ const [notificationApi, notificationContextHolder] =
+ notification.useNotification()
+ const [newPrinterLoading, setNewPrinterLoading] = useState(false)
+ const [currentStep, setCurrentStep] = useState(0)
+ const [nextEnabled, setNextEnabled] = useState(false)
+ const [newPrinterForm] = Form.useForm()
+ const [newPrinterFormValues, setNewPrinterFormValues] = useState(
+ initialNewPrinterForm
+ )
+ const [discoveredPrinters, setDiscoveredPrinters] = useState([])
+ const [discovering, setDiscovering] = useState(false)
+ const [showManualSetup, setShowManualSetup] = useState(false)
+ const [scanPort, setScanPort] = useState(7125)
+ const [scanProtocol, setScanProtocol] = useState('ws')
+ const [editingHostname, setEditingHostname] = useState(null)
+ const [hostnameInput, setHostnameInput] = useState('')
+ const [initialized, setInitialized] = useState(false)
+
+ const newPrinterFormUpdateValues = Form.useWatch([], newPrinterForm)
+
+ useEffect(() => {
+ newPrinterForm
+ .validateFields({
+ validateOnly: true
+ })
+ .then(() => {
+ if (currentStep === 0) {
+ const moonraker = newPrinterForm.getFieldValue('moonraker')
+ setNextEnabled(
+ !!(moonraker?.protocol && moonraker?.host && moonraker?.port)
+ )
+ } else if (currentStep === 1) {
+ const printerName = newPrinterForm.getFieldValue('printerName')
+ setNextEnabled(!!printerName)
+ } else {
+ setNextEnabled(true)
+ }
+ })
+ .catch(() => setNextEnabled(false))
+ }, [newPrinterForm, newPrinterFormUpdateValues, currentStep])
+
+ const summaryItems = [
+ {
+ key: 'name',
+ label: 'Name',
+ children: newPrinterFormValues.printerName
+ },
+ {
+ key: 'protocol',
+ label: 'Protocol',
+ children: newPrinterFormValues.moonraker?.protocol
+ },
+ {
+ key: 'host',
+ label: 'Host',
+ children: newPrinterFormValues.moonraker?.host
+ },
+ {
+ key: 'port',
+ label: 'Port',
+ children: newPrinterFormValues.moonraker?.port
+ }
+ ]
+
+ useEffect(() => {
+ if (reset) {
+ newPrinterForm.resetFields()
+ }
+ }, [reset, newPrinterForm])
+
+ const handlePrinterSelect = (printer) => {
+ newPrinterForm.setFieldsValue({
+ moonraker: {
+ protocol: printer.protocol,
+ host: printer.host,
+ port: printer.port
+ }
+ })
+ setNewPrinterFormValues({
+ ...newPrinterFormValues,
+ moonraker: {
+ protocol: printer.protocol,
+ host: printer.host,
+ port: printer.port
+ }
+ })
+ }
+
+ const handleHostnameEdit = (printer, newHostname) => {
+ if (newHostname && newHostname.trim() !== '') {
+ const updatedPrinter = {
+ ...printer,
+ host: newHostname.trim()
+ }
+ setDiscoveredPrinters((prev) =>
+ prev.map((p) => (p.host === printer.host ? updatedPrinter : p))
+ )
+ setEditingHostname(null)
+ setHostnameInput('')
+ }
+ }
+
+ const showEditHostnameDialog = (printer) => {
+ setEditingHostname(printer.host)
+ setHostnameInput(printer.host)
+ }
+
+ const handleNewPrinter = async () => {
+ setNewPrinterLoading(true)
+ try {
+ await axios.post(
+ 'http://localhost:8080/printers',
+ {
+ ...newPrinterFormValues
+ },
+ {
+ headers: {
+ Accept: 'application/json'
+ },
+ withCredentials: true
+ }
+ )
+ messageApi.success('New printer added successfully.')
+ onOk()
+ } catch (error) {
+ messageApi.error('Error adding new printer: ' + error.message)
+ } finally {
+ setNewPrinterLoading(false)
+ }
+ }
+
+ const notifyScanNetworkFound = useCallback(
+ (data) => {
+ const newPrinter = {
+ protocol: scanProtocol,
+ host: data.hostname || data.ip,
+ port: scanPort
+ }
+ notificationApi.info({
+ message: 'Printer Found',
+ description: `Printer found: ${data.hostname || data.ip}!`
+ })
+ setDiscoveredPrinters((prev) => [...prev, newPrinter])
+ },
+ [scanProtocol, scanPort, notificationApi]
+ )
+
+ const notifyScanNetworkComplete = useCallback(
+ (data) => {
+ setDiscovering(false)
+ notificationApi.destroy('network-scan')
+ if (data == false) {
+ messageApi.error('Error discovering printers!')
+ } else {
+ messageApi.success('Finished discovering printers!')
+ }
+ },
+ [messageApi, notificationApi]
+ )
+
+ const notifyScanNetworkProgress = useCallback(
+ (data) => {
+ notificationApi.info({
+ message: 'Scanning Network',
+ description: (
+
+
+ Scanning IP: {data.currentIP}
+
+
+
+ ),
+ duration: 0,
+ key: 'network-scan',
+ icon: null,
+ placement: 'bottomRight',
+ style: {
+ width: 360
+ },
+ className: 'network-scan-notification',
+ closeIcon: null,
+ onClose: () => {},
+ btn: null
+ })
+ },
+ [notificationApi]
+ )
+
+ const discoverPrinters = useCallback(() => {
+ if (!discovering) {
+ setDiscovering(true)
+ setDiscoveredPrinters([])
+ messageApi.info('Discovering printers...')
+ socket.off('notify_scan_network_found')
+ socket.off('notify_scan_network_progress')
+ socket.off('notify_scan_network_complete')
+
+ socket.on('notify_scan_network_found', notifyScanNetworkFound)
+ socket.on('notify_scan_network_progress', notifyScanNetworkProgress)
+ socket.on('notify_scan_network_complete', notifyScanNetworkComplete)
+
+ socket.emit('bridge.scan_network.start', {
+ port: scanPort,
+ protocol: scanProtocol
+ })
+ }
+ }, [
+ discovering,
+ socket,
+ scanPort,
+ scanProtocol,
+ messageApi,
+ notifyScanNetworkFound,
+ notifyScanNetworkProgress,
+ notifyScanNetworkComplete
+ ])
+
+ useEffect(() => {
+ setInitialized(true)
+ if (!initialized) {
+ discoverPrinters()
+ }
+ }, [initialized, discoverPrinters])
+
+ const stopDiscovery = () => {
+ if (discovering) {
+ setDiscovering(false)
+ notificationApi.destroy('network-scan')
+ messageApi.info('Stopping discovery...')
+ socket.off('notify_scan_network_found')
+ socket.off('notify_scan_network_progress')
+ socket.off('notify_scan_network_complete')
+ socket.emit('bridge.scan_network.stop', (response) => {
+ if (response == false) {
+ messageApi.error('Error stopping discovery!')
+ }
+ })
+ }
+ }
+
+ const handlePortChange = (value) => {
+ stopDiscovery()
+ setScanPort(value)
+ }
+
+ const handleProtocolChange = (value) => {
+ stopDiscovery()
+ setScanProtocol(value)
+ }
+
+ const steps = [
+ {
+ title: 'Discovery',
+ key: 'discovery',
+ content: (
+ <>
+
+ {!showManualSetup ? (
+ <>
+
+
+
+
+ }
+ onClick={discoverPrinters}
+ loading={discovering}
+ >
+ {discovering ? 'Discovering...' : 'Discover'}
+
+
+ }
+ onClick={() => setShowManualSetup(true)}
+ >
+ Manual Setup
+
+
+ (
+ handlePrinterSelect(printer)}
+ />
+ ]}
+ >
+
+ {printer.host}
+ {!printer.hostname && (
+ }
+ onClick={() => showEditHostnameDialog(printer)}
+ />
+ )}
+
+ }
+ description={`Protocol: ${printer.protocol}, Port: ${printer.port}`}
+ />
+
+ )}
+ />
+ {
+ const printer = discoveredPrinters.find(
+ (p) => p.host === editingHostname
+ )
+ if (printer) {
+ handleHostnameEdit(printer, hostnameInput)
+ }
+ }}
+ onCancel={() => {
+ setEditingHostname(null)
+ setHostnameInput('')
+ }}
+ >
+
+ setHostnameInput(e.target.value)}
+ placeholder='Enter host'
+ autoFocus
+ />
+
+
+ >
+ ) : (
+ <>
+
+ }
+ onClick={() => setShowManualSetup(false)}
+ >
+ Back to Discovery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ >
+ )
+ },
+ {
+ title: 'Required',
+ key: 'required',
+ content: (
+ <>
+
+
+
+ >
+ )
+ },
+ {
+ title: 'Summary',
+ key: 'summary',
+ content: (
+
+
+
+ )
+ }
+ ]
+
+ return (
+
+ {contextHolder}
+ {notificationContextHolder}
+
+
+
+
+
+
+
+
+
+ New Printer
+
+
+
+
+ )
+}
+
+export default NewPrinter
diff --git a/src/components/Dashboard/Production/Printers/PrinterInfo.jsx b/src/components/Dashboard/Production/Printers/PrinterInfo.jsx
new file mode 100644
index 0000000..6f0060b
--- /dev/null
+++ b/src/components/Dashboard/Production/Printers/PrinterInfo.jsx
@@ -0,0 +1,359 @@
+import React, { useState, useEffect } from 'react'
+import { useLocation } from 'react-router-dom'
+import axios from 'axios'
+import {
+ Descriptions,
+ Spin,
+ Space,
+ Button,
+ message,
+ Tag,
+ Typography,
+ Flex,
+ Form,
+ Input,
+ InputNumber,
+ Select
+} from 'antd'
+import {
+ LoadingOutlined,
+ ReloadOutlined,
+ EditOutlined,
+ CheckOutlined,
+ CloseOutlined,
+ PlusOutlined
+} from '@ant-design/icons'
+import PrinterState from '../../common/PrinterState'
+import IdText from '../../common/IdText'
+import PrinterSubJobsList from '../../common/PrinterJobsTree'
+
+const { Title } = Typography
+
+const PrinterInfo = () => {
+ const [printerData, setPrinterData] = useState(null)
+ const [fetchLoading, setFetchLoading] = useState(true)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const location = useLocation()
+ const printerId = new URLSearchParams(location.search).get('printerId')
+ const [messageApi, contextHolder] = message.useMessage()
+ const [isEditing, setIsEditing] = useState(false)
+ const [form] = Form.useForm()
+
+ useEffect(() => {
+ if (printerId) {
+ fetchPrinterDetails()
+ }
+ }, [printerId])
+
+ useEffect(() => {
+ if (printerData) {
+ form.setFieldsValue(printerData)
+ }
+ }, [printerData, form])
+
+ const fetchPrinterDetails = async () => {
+ try {
+ setFetchLoading(true)
+ const response = await axios.get(
+ `http://localhost:8080/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 = () => {
+ setIsEditing(true)
+ }
+
+ const cancelEditing = () => {
+ setIsEditing(false)
+ fetchPrinterDetails()
+ }
+
+ const updatePrinterInfo = async () => {
+ try {
+ const values = await form.validateFields()
+ setLoading(true)
+
+ await axios.put(`http://localhost:8080/printers/${printerId}`, values, {
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ withCredentials: true
+ })
+ setPrinterData((prev) => ({ ...prev, ...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 {
+ setLoading(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', '')
+ }
+ }
+ }
+
+ if (fetchLoading) {
+ return (
+
+ } />
+
+ )
+ }
+
+ if (error || !printerData) {
+ return (
+
+ {error || 'Printer not found'}
+ } onClick={fetchPrinterDetails}>
+ Retry
+
+
+ )
+ }
+
+ return (
+
+ {contextHolder}
+
+
+ Printer Information
+
+
+ {isEditing ? (
+ <>
+ }
+ type='primary'
+ onClick={updatePrinterInfo}
+ loading={loading}
+ >
+ }
+ onClick={cancelEditing}
+ disabled={loading}
+ >
+ >
+ ) : (
+ } onClick={startEditing}>
+ )}
+
+
+
+
+
+
+ Printer Jobs
+
+
+
+ )
+}
+
+export default PrinterInfo
diff --git a/src/components/Dashboard/common/FilamentSelect.jsx b/src/components/Dashboard/common/FilamentSelect.jsx
new file mode 100644
index 0000000..3d43077
--- /dev/null
+++ b/src/components/Dashboard/common/FilamentSelect.jsx
@@ -0,0 +1,170 @@
+// FilamentSelect.js
+import { TreeSelect, Badge } from 'antd'
+import React, { useEffect, useState, useContext, useRef } from 'react'
+import PropTypes from 'prop-types'
+import axios from 'axios'
+import { AuthContext } from '../../Auth/AuthContext'
+
+const propertyOrder = ['diameter', 'type', 'brand']
+
+const FilamentSelect = ({ onChange, filter, useFilter }) => {
+ const [filamentsTreeData, setFilamentsTreeData] = useState([])
+ const { token } = useContext(AuthContext)
+ const tokenRef = useRef(token)
+ const [loading, setLoading] = useState(true)
+
+ const fetchFilamentsData = async (property, filter) => {
+ setLoading(true)
+ try {
+ const response = await axios.get('http://localhost:8080/filaments', {
+ params: {
+ ...filter,
+ property
+ },
+ headers: {
+ Authorization: `Bearer ${tokenRef.current}`
+ }
+ })
+ setLoading(false)
+ return response.data
+ //setPagination({ ...pagination, total: response.data.totalItems }); // Update total count
+ } catch (err) {
+ console.error(err)
+ }
+ }
+
+ const getFilter = (node) => {
+ var filter = {}
+ var currentId = node.id
+ while (currentId != 0) {
+ const currentNode = filamentsTreeData.filter(
+ (treeData) => treeData['id'] === currentId
+ )[0]
+ filter[propertyOrder[currentNode.propertyId]] =
+ currentNode.value.split('-')[0]
+ currentId = currentNode.pId
+ }
+ return filter
+ }
+
+ const generateFilamentTreeNodes = async (node = null, filter = null) => {
+ if (!node) {
+ return
+ }
+
+ if (filter === null) {
+ filter = getFilter(node)
+ }
+
+ const filamentData = await fetchFilamentsData(null, filter)
+
+ let newNodeList = []
+
+ for (var i = 0; i < filamentData.length; i++) {
+ const filament = filamentData[i]
+ const random = Math.random().toString(36).substring(2, 6)
+
+ const newNode = {
+ id: random,
+ pId: node.id,
+ value: filament._id,
+ key: filament._id,
+ title: ,
+ isLeaf: true
+ }
+
+ newNodeList.push(newNode)
+ }
+
+ setFilamentsTreeData(filamentsTreeData.concat(newNodeList))
+ }
+
+ const generateFilamentCategoryTreeNodes = async (node = null) => {
+ var filter = {}
+
+ var propertyId = 0
+
+ if (!node) {
+ node = {}
+ node.id = 0
+ } else {
+ filter = getFilter(node)
+ propertyId = node.propertyId + 1
+ }
+
+ const propertyName = propertyOrder[propertyId]
+
+ const propertyData = await fetchFilamentsData(propertyName, filter)
+
+ const newNodeList = []
+
+ for (var i = 0; i < propertyData.length; i++) {
+ const property = propertyData[i][propertyName]
+ const random = Math.random().toString(36).substring(2, 6)
+
+ const newNode = {
+ id: random,
+ pId: node.id,
+ value: property + '-' + random,
+ key: property + '-' + random,
+ propertyId: propertyId,
+ title: property,
+ isLeaf: false,
+ selectable: false
+ }
+
+ newNodeList.push(newNode)
+ }
+
+ setFilamentsTreeData(filamentsTreeData.concat(newNodeList))
+ }
+
+ const handleFilamentsTreeLoad = async (node) => {
+ if (node) {
+ if (node.propertyId !== propertyOrder.length - 1) {
+ await generateFilamentCategoryTreeNodes(node)
+ } else {
+ await generateFilamentTreeNodes(node) // End of properties
+ }
+ } else {
+ await generateFilamentCategoryTreeNodes(null) // First property
+ }
+ }
+
+ useEffect(() => {
+ setFilamentsTreeData([])
+ }, [token, filter, useFilter])
+
+ useEffect(() => {
+ if (filamentsTreeData.length === 0) {
+ if (useFilter === true) {
+ generateFilamentTreeNodes({ id: 0 }, filter)
+ } else {
+ handleFilamentsTreeLoad(null)
+ }
+ }
+ }, [filamentsTreeData])
+
+ return (
+
+ )
+}
+
+FilamentSelect.propTypes = {
+ onChange: PropTypes.func.isRequired,
+ filter: PropTypes.object,
+ useFilter: PropTypes.bool
+}
+
+FilamentSelect.defaultProps = {
+ filter: {},
+ useFilter: false
+}
+
+export default FilamentSelect
diff --git a/src/components/Dashboard/common/GCodeFileSelect.jsx b/src/components/Dashboard/common/GCodeFileSelect.jsx
new file mode 100644
index 0000000..c605842
--- /dev/null
+++ b/src/components/Dashboard/common/GCodeFileSelect.jsx
@@ -0,0 +1,206 @@
+// GCodeFileSelect.js
+import PropTypes from 'prop-types'
+import { TreeSelect, Badge, Space, message } from 'antd'
+import React, { useEffect, useState, useContext } from 'react'
+import axios from 'axios'
+import GCodeFileIcon from '../../Icons/GCodeFileIcon'
+import { AuthContext } from '../../Auth/AuthContext'
+
+const propertyOrder = ['filament.diameter', 'filament.type', 'filament.brand']
+
+const GCodeFileSelect = ({ onChange, filter, useFilter }) => {
+ const [gcodeFilesTreeData, setGCodeFilesTreeData] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [searchValue, setSearchValue] = useState('')
+ const [messageApi] = message.useMessage()
+
+ const { authenticated } = useContext(AuthContext)
+
+ const fetchGCodeFilesData = async (property, filter, search) => {
+ if (!authenticated) {
+ return
+ }
+ setLoading(true)
+ try {
+ const response = await axios.get('http://localhost:8080/gcodefiles', {
+ params: {
+ ...filter,
+ search,
+ property
+ },
+ headers: {
+ Accept: 'application/json'
+ },
+ withCredentials: true // Important for including cookies
+ })
+ setLoading(false)
+ return response.data
+ // setPagination({ ...pagination, total: response.data.totalItems }); // Update total count
+ } catch (error) {
+ if (error.response) {
+ // For other errors, show a message
+ messageApi.error('Error fetching GCode files:', error.response.status)
+ } else {
+ messageApi.error(
+ 'An unexpected error occurred. Please try again later.'
+ )
+ }
+ }
+ }
+
+ const getFilter = (node) => {
+ const filter = {}
+ let currentId = node.id
+ while (currentId != 0) {
+ const currentNode = gcodeFilesTreeData.filter(
+ (treeData) => treeData['id'] === currentId
+ )[0]
+ filter[propertyOrder[currentNode.propertyId]] =
+ currentNode.value.split('-')[0]
+ currentId = currentNode.pId
+ }
+ return filter
+ }
+
+ const generateGCodeFileTreeNodes = async (node = null, filter = null) => {
+ if (!node) {
+ return
+ }
+
+ if (filter === null) {
+ filter = getFilter(node)
+ }
+
+ let search = null
+ if (searchValue != '') {
+ search = searchValue
+ }
+
+ const gcodeFileData = await fetchGCodeFilesData(null, filter, search)
+
+ let newNodeList = []
+
+ for (var i = 0; i < gcodeFileData.length; i++) {
+ const gcodeFile = gcodeFileData[i]
+ const random = Math.random().toString(36).substring(2, 6)
+
+ const newNode = {
+ id: random,
+ pId: node.id,
+ value: gcodeFile._id,
+ key: gcodeFile._id,
+ title: (
+
+
+
+
+ ),
+ isLeaf: true
+ }
+
+ newNodeList.push(newNode)
+ }
+ return newNodeList
+ }
+
+ const generateGCodeFileCategoryTreeNodes = async (node = null) => {
+ var filter = {}
+
+ var propertyId = 0
+
+ if (!node) {
+ node = {}
+ node.id = 0
+ } else {
+ filter = getFilter(node)
+ propertyId = node.propertyId + 1
+ }
+
+ const propertyName = propertyOrder[propertyId]
+
+ const propertyData = await fetchGCodeFilesData(propertyName, filter)
+
+ const newNodeList = []
+
+ for (var i = 0; i < propertyData.length; i++) {
+ const property =
+ propertyData[i][propertyName.split('.')[0]][propertyName.split('.')[1]]
+ const random = Math.random().toString(36).substring(2, 6)
+
+ const newNode = {
+ id: random,
+ pId: node.id,
+ value: property + '-' + random,
+ key: property + '-' + random,
+ propertyId: propertyId,
+ title: property,
+ isLeaf: false,
+ selectable: false
+ }
+
+ newNodeList.push(newNode)
+ }
+
+ return newNodeList
+ }
+
+ const handleGCodeFilesTreeLoad = async (node) => {
+ if (node) {
+ if (node.propertyId !== propertyOrder.length - 1) {
+ setGCodeFilesTreeData(
+ gcodeFilesTreeData.concat(
+ await generateGCodeFileCategoryTreeNodes(node)
+ )
+ )
+ } else {
+ setGCodeFilesTreeData(
+ gcodeFilesTreeData.concat(await generateGCodeFileTreeNodes(node))
+ ) // End of properties
+ }
+ } else {
+ setGCodeFilesTreeData(await generateGCodeFileCategoryTreeNodes(null)) // First property
+ }
+ }
+
+ const handleGCodeFilesSearch = (value) => {
+ setSearchValue(value)
+ setGCodeFilesTreeData(null)
+ }
+
+ useEffect(() => {
+ setGCodeFilesTreeData([])
+ }, [filter, useFilter])
+
+ useEffect(() => {
+ if (gcodeFilesTreeData === null) {
+ if (useFilter === true || searchValue != '') {
+ setGCodeFilesTreeData(generateGCodeFileTreeNodes({ id: 0 }, filter))
+ } else {
+ handleGCodeFilesTreeLoad(null)
+ }
+ }
+ }, [gcodeFilesTreeData])
+
+ return (
+
+ )
+}
+
+GCodeFileSelect.propTypes = {
+ onChange: PropTypes.func.isRequired,
+ filter: PropTypes.string.isRequired,
+ useFilter: PropTypes.bool.isRequired
+}
+
+export default GCodeFileSelect
diff --git a/src/components/Dashboard/common/IdText.jsx b/src/components/Dashboard/common/IdText.jsx
new file mode 100644
index 0000000..1321d45
--- /dev/null
+++ b/src/components/Dashboard/common/IdText.jsx
@@ -0,0 +1,127 @@
+// PrinterSelect.js
+import React from 'react'
+import PropTypes from 'prop-types'
+import { Flex, Typography, Button, Tooltip, message } from 'antd'
+import { useNavigate } from 'react-router-dom'
+import { CopyOutlined } from '@ant-design/icons'
+
+const { Text, Link } = Typography
+
+const IdText = ({
+ id,
+ type,
+ showCopy = true,
+ longId = true,
+ showHyperlink = false
+}) => {
+ const [messageApi, contextHolder] = message.useMessage()
+ const navigate = useNavigate()
+
+ var prefix = 'UNK'
+ var hyperlink = '#'
+
+ switch (type) {
+ case 'printer':
+ prefix = 'PRN'
+ hyperlink = `/production/printers/info?printerId=${id}`
+ break
+ case 'filament':
+ prefix = 'FIL'
+ hyperlink = `/management/filaments/info?filamentId=${id}`
+ break
+ case 'spool':
+ prefix = 'SPL'
+ hyperlink = `/inventory/spool/info?spoolId=${id}`
+ break
+ case 'gcodeFile':
+ prefix = 'GCF'
+ hyperlink = `/production/gcodefiles/info?gcodeFileId=${id}`
+ break
+ case 'job':
+ prefix = 'JOB'
+ hyperlink = `/production/printjobs/info?printJobId=${id}`
+ break
+ case 'part':
+ prefix = 'PRT'
+ hyperlink = `/management/parts/info?partId=${id}`
+ break
+ case 'product':
+ prefix = 'PRD'
+ hyperlink = `/management/products/info?productId=${id}`
+ break
+ case 'vendor':
+ prefix = 'VEN'
+ hyperlink = `/management/vendors/info?vendorId=${id}`
+ break
+ case 'subjob':
+ prefix = 'SJB'
+ hyperlink = `#`
+ break
+ default:
+ hyperlink = `#`
+ prefix = 'UNK'
+ }
+
+ id = id.toString().toUpperCase()
+ var displayId = prefix + ':' + id
+ var copyId = prefix + ':' + id
+
+ if (longId == false) {
+ displayId = prefix + ':' + id.toString().slice(-6)
+ }
+
+ return (
+
+ {contextHolder}
+
+ {showHyperlink && (
+ {
+ if (showHyperlink) {
+ navigate(hyperlink)
+ }
+ }}
+ >
+
+ {displayId}
+
+
+ )}
+
+ {!showHyperlink && (
+
+ {displayId}
+
+ )}
+ {showCopy && (
+
+ }
+ type='text'
+ style={{ height: '22px' }}
+ onClick={() => {
+ navigator.clipboard
+ .writeText(copyId)
+ .then(() => {
+ messageApi.success('ID copied to clipboard')
+ })
+ .catch(() => {
+ messageApi.error('Failed to copy ID')
+ })
+ }}
+ />
+
+ )}
+
+ )
+}
+
+IdText.propTypes = {
+ id: PropTypes.string,
+ type: PropTypes.string,
+ showCopy: PropTypes.bool,
+ longId: PropTypes.bool,
+ showHyperlink: PropTypes.bool
+}
+
+export default IdText
diff --git a/src/components/Dashboard/common/InventorySidebar.jsx b/src/components/Dashboard/common/InventorySidebar.jsx
new file mode 100644
index 0000000..bf1e21b
--- /dev/null
+++ b/src/components/Dashboard/common/InventorySidebar.jsx
@@ -0,0 +1,59 @@
+import React, { useState, useEffect } from 'react'
+import { Link, useLocation } from 'react-router-dom'
+import { Layout, Menu } from 'antd'
+import {
+ DashboardOutlined,
+ InboxOutlined,
+ HistoryOutlined
+} from '@ant-design/icons'
+
+const { Sider } = Layout
+
+const InventorySidebar = () => {
+ const location = useLocation()
+ const [selectedKey, setSelectedKey] = useState('inventory')
+
+ useEffect(() => {
+ const pathParts = location.pathname.split('/').filter(Boolean)
+ if (pathParts.length > 1) {
+ setSelectedKey(pathParts[1]) // Return the section (inventory/management)
+ }
+ }, [location.pathname])
+
+ const items = [
+ {
+ key: 'overview',
+ label: Overview,
+ icon:
+ },
+ {
+ key: 'spools',
+ label: Spools,
+ icon:
+ },
+ {
+ key: 'stock',
+ label: Stock,
+ icon:
+ },
+ {
+ key: 'history',
+ label: History,
+ icon:
+ }
+ ]
+
+ return (
+
+
+
+ )
+}
+
+export default InventorySidebar
diff --git a/src/components/Dashboard/common/JobState.jsx b/src/components/Dashboard/common/JobState.jsx
new file mode 100644
index 0000000..779edff
--- /dev/null
+++ b/src/components/Dashboard/common/JobState.jsx
@@ -0,0 +1,116 @@
+import PropTypes from 'prop-types'
+import { Badge, Progress, Flex, Typography, Tag, Space } from 'antd'
+import React, { useState, useContext, useEffect } from 'react'
+import { SocketContext } from '../context/SocketContext'
+import IdText from './IdText'
+
+const JobState = ({
+ job,
+ showProgress = true,
+ showStatus = true,
+ showId = true,
+ showQuantity = true
+}) => {
+ const { socket } = useContext(SocketContext)
+ const [badgeStatus, setBadgeStatus] = useState('default')
+ const [badgeText, setBadgeText] = useState('Unknown')
+ const [currentState, setCurrentState] = useState(
+ job?.state || { type: 'unknown', progress: 0 }
+ )
+ const [initialized, setInitialized] = useState(false)
+ const { Text } = Typography
+ useEffect(() => {
+ if (socket && !initialized && job?.id) {
+ setInitialized(true)
+ socket.on('notify_job_update', (statusUpdate) => {
+ if (statusUpdate?.id === job.id && statusUpdate?.state) {
+ setCurrentState(statusUpdate.state)
+ }
+ })
+ }
+ return () => {
+ if (socket && initialized) {
+ socket.off('notify_job_update')
+ }
+ }
+ }, [socket, initialized, job?.id])
+
+ useEffect(() => {
+ switch (currentState?.type) {
+ case 'draft':
+ setBadgeStatus('default')
+ setBadgeText('Draft')
+ break
+ case 'printing':
+ setBadgeStatus('processing')
+ setBadgeText('Printing')
+ break
+ case 'complete':
+ setBadgeStatus('success')
+ setBadgeText('Complete')
+ break
+ case 'failed':
+ setBadgeStatus('error')
+ setBadgeText('Failed')
+ break
+ case 'queued':
+ setBadgeStatus('warning')
+ setBadgeText('Queued')
+ break
+ case 'paused':
+ setBadgeStatus('warning')
+ setBadgeText('Paused')
+ break
+ default:
+ setBadgeStatus('default')
+ setBadgeText('Unknown')
+ }
+ }, [currentState])
+
+ return (
+
+ {showId && (
+ <>
+ {'Sub Job '}
+
+ >
+ )}
+ {showQuantity && (Quantity: {job.quantity})}
+ {showStatus && (
+
+
+
+
+ {badgeText}
+
+
+
+ )}
+ {showProgress &&
+ (currentState.type === 'printing' ||
+ currentState.type === 'processing') ? (
+
+ ) : null}
+
+ )
+}
+
+JobState.propTypes = {
+ job: PropTypes.shape({
+ id: PropTypes.string,
+ quantity: PropTypes.number,
+ state: PropTypes.shape({
+ type: PropTypes.string,
+ progress: PropTypes.number
+ })
+ }),
+ showProgress: PropTypes.bool,
+ showQuantity: PropTypes.bool,
+ showId: PropTypes.bool,
+ showStatus: PropTypes.bool
+}
+
+export default JobState
diff --git a/src/components/Dashboard/common/ManagementSidebar.jsx b/src/components/Dashboard/common/ManagementSidebar.jsx
new file mode 100644
index 0000000..e59244a
--- /dev/null
+++ b/src/components/Dashboard/common/ManagementSidebar.jsx
@@ -0,0 +1,98 @@
+import React, { useState, useEffect } from 'react'
+import { Link, useLocation } from 'react-router-dom'
+import { Layout, Menu, Flex, Button } from 'antd'
+import {
+ SettingOutlined,
+ AuditOutlined,
+ ShopOutlined,
+ BlockOutlined,
+ MenuFoldOutlined,
+ MenuUnfoldOutlined
+} from '@ant-design/icons'
+
+import FilamentIcon from '../../Icons/FilamentIcon'
+import PartIcon from '../../Icons/PartIcon'
+import ProductIcon from '../../Icons/ProductIcon'
+
+const { Sider } = Layout
+
+const ManagementSidebar = () => {
+ const location = useLocation()
+ const [selectedKey, setSelectedKey] = useState('production')
+ const [collapsed, setCollapsed] = useState(false)
+
+ useEffect(() => {
+ const pathParts = location.pathname.split('/').filter(Boolean)
+ if (pathParts.length > 1) {
+ setSelectedKey(pathParts[1]) // Return the section (production/management)
+ }
+ }, [location.pathname])
+
+ const items = [
+ {
+ key: 'filaments',
+ label: Filaments,
+ icon:
+ },
+ {
+ key: 'parts',
+ label: Parts,
+ icon:
+ },
+ {
+ key: 'products',
+ label: Products,
+ icon:
+ },
+ {
+ key: 'vendors',
+ label: Vendors,
+ icon:
+ },
+ {
+ key: 'materials',
+ label: Materials,
+ icon:
+ },
+ {
+ key: 'settings',
+ label: Settings,
+ icon:
+ },
+ {
+ key: 'audit',
+ label: Audit Log,
+ icon:
+ }
+ ]
+ return (
+
+
+
+
+ : }
+ style={{ flexGrow: 1 }}
+ onClick={() => {
+ setCollapsed(!collapsed)
+ }}
+ />
+
+
+
+ )
+}
+
+export default ManagementSidebar
diff --git a/src/components/Dashboard/common/NotificationCenter.jsx b/src/components/Dashboard/common/NotificationCenter.jsx
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/Dashboard/common/PartSelect.jsx b/src/components/Dashboard/common/PartSelect.jsx
new file mode 100644
index 0000000..69d6d7a
--- /dev/null
+++ b/src/components/Dashboard/common/PartSelect.jsx
@@ -0,0 +1,170 @@
+// PartSelect.js
+import { TreeSelect, Badge } from 'antd'
+import React, { useEffect, useState, useContext, useRef } from 'react'
+import PropTypes from 'prop-types'
+import axios from 'axios'
+import { AuthContext } from '../../Auth/AuthContext'
+
+const propertyOrder = ['diameter', 'type', 'brand']
+
+const PartSelect = ({ onChange, filter, useFilter }) => {
+ const [partsTreeData, setPartsTreeData] = useState([])
+ const { token } = useContext(AuthContext)
+ const tokenRef = useRef(token)
+ const [loading, setLoading] = useState(true)
+
+ const fetchPartsData = async (property, filter) => {
+ setLoading(true)
+ try {
+ const response = await axios.get('http://localhost:8080/parts', {
+ params: {
+ ...filter,
+ property
+ },
+ headers: {
+ Authorization: `Bearer ${tokenRef.current}`
+ }
+ })
+ setLoading(false)
+ return response.data
+ //setPagination({ ...pagination, total: response.data.totalItems }); // Update total count
+ } catch (err) {
+ console.error(err)
+ }
+ }
+
+ const getFilter = (node) => {
+ var filter = {}
+ var currentId = node.id
+ while (currentId != 0) {
+ const currentNode = partsTreeData.filter(
+ (treeData) => treeData['id'] === currentId
+ )[0]
+ filter[propertyOrder[currentNode.propertyId]] =
+ currentNode.value.split('-')[0]
+ currentId = currentNode.pId
+ }
+ return filter
+ }
+
+ const generatePartTreeNodes = async (node = null, filter = null) => {
+ if (!node) {
+ return
+ }
+
+ if (filter === null) {
+ filter = getFilter(node)
+ }
+
+ const partData = await fetchPartsData(null, filter)
+
+ let newNodeList = []
+
+ for (var i = 0; i < partData.length; i++) {
+ const part = partData[i]
+ const random = Math.random().toString(36).substring(2, 6)
+
+ const newNode = {
+ id: random,
+ pId: node.id,
+ value: part._id,
+ key: part._id,
+ title: ,
+ isLeaf: true
+ }
+
+ newNodeList.push(newNode)
+ }
+
+ setPartsTreeData(partsTreeData.concat(newNodeList))
+ }
+
+ const generatePartCategoryTreeNodes = async (node = null) => {
+ var filter = {}
+
+ var propertyId = 0
+
+ if (!node) {
+ node = {}
+ node.id = 0
+ } else {
+ filter = getFilter(node)
+ propertyId = node.propertyId + 1
+ }
+
+ const propertyName = propertyOrder[propertyId]
+
+ const propertyData = await fetchPartsData(propertyName, filter)
+
+ const newNodeList = []
+
+ for (var i = 0; i < propertyData.length; i++) {
+ const property = propertyData[i][propertyName]
+ const random = Math.random().toString(36).substring(2, 6)
+
+ const newNode = {
+ id: random,
+ pId: node.id,
+ value: property + '-' + random,
+ key: property + '-' + random,
+ propertyId: propertyId,
+ title: property,
+ isLeaf: false,
+ selectable: false
+ }
+
+ newNodeList.push(newNode)
+ }
+
+ setPartsTreeData(partsTreeData.concat(newNodeList))
+ }
+
+ const handlePartsTreeLoad = async (node) => {
+ if (node) {
+ if (node.propertyId !== propertyOrder.length - 1) {
+ await generatePartCategoryTreeNodes(node)
+ } else {
+ await generatePartTreeNodes(node) // End of properties
+ }
+ } else {
+ await generatePartCategoryTreeNodes(null) // First property
+ }
+ }
+
+ useEffect(() => {
+ setPartsTreeData([])
+ }, [token, filter, useFilter])
+
+ useEffect(() => {
+ if (partsTreeData.length === 0) {
+ if (useFilter === true) {
+ generatePartTreeNodes({ id: 0 }, filter)
+ } else {
+ handlePartsTreeLoad(null)
+ }
+ }
+ }, [partsTreeData])
+
+ return (
+
+ )
+}
+
+PartSelect.propTypes = {
+ onChange: PropTypes.func.isRequired,
+ filter: PropTypes.object,
+ useFilter: PropTypes.bool
+}
+
+PartSelect.defaultProps = {
+ filter: {},
+ useFilter: false
+}
+
+export default PartSelect
diff --git a/src/components/Dashboard/common/PrinterJobsTree.jsx b/src/components/Dashboard/common/PrinterJobsTree.jsx
new file mode 100644
index 0000000..5c02f28
--- /dev/null
+++ b/src/components/Dashboard/common/PrinterJobsTree.jsx
@@ -0,0 +1,194 @@
+import PropTypes from 'prop-types'
+import { Card, Tree, Spin, Space, Button, message, Typography } from 'antd'
+import { LoadingOutlined, ReloadOutlined } from '@ant-design/icons'
+import React, { useState, useEffect, useContext } from 'react'
+import SubJobState from './SubJobState'
+import { SocketContext } from '../context/SocketContext'
+import axios from 'axios'
+import JobState from './JobState'
+const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
+ const [subJobs, setSubJobs] = useState(initialSubJobs || [])
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const { socket } = useContext(SocketContext)
+ const [messageApi] = message.useMessage()
+ const [expandedKeys, setExpandedKeys] = useState([])
+ const [treeData, setTreeData] = useState([])
+
+ const { Text } = Typography
+
+ const buildTreeData = (subJobsData) => {
+ if (!subJobsData?.length) {
+ setTreeData([])
+ setExpandedKeys([])
+ return
+ }
+
+ // Group subjobs by printJob
+ const printJobGroups = subJobsData.reduce((acc, subJob) => {
+ const printJobId = subJob.printJob._id
+ if (!acc[printJobId]) {
+ acc[printJobId] = {
+ printJob: subJob.printJob,
+ subJobs: []
+ }
+ }
+ acc[printJobId].subJobs.push(subJob)
+ return acc
+ }, {})
+
+ // Create tree nodes for each printJob
+ const printJobNodes = Object.values(printJobGroups).map(
+ ({ printJob, subJobs }) => {
+ setExpandedKeys((prev) => [...prev, `printjob-${printJob._id}`])
+ return {
+ title: (
+
+ Print Job
+
+ {printJob._id.substring(printJob._id.length - 6)}
+
+ printJob.quantity
+ >
+ }
+ />
+ ),
+ key: `printjob-${printJob._id}`,
+ children: subJobs.map((subJob) => ({
+ title: (
+
+ ),
+ key: `subjob-${subJob._id}`,
+ isLeaf: true
+ }))
+ }
+ }
+ )
+
+ setTreeData(printJobNodes)
+ }
+
+ useEffect(() => {
+ buildTreeData(subJobs)
+ }, [subJobs])
+
+ useEffect(() => {
+ const initializeData = async () => {
+ if (!initialSubJobs) {
+ try {
+ setLoading(true)
+ const response = await axios.get('http://localhost:8080/printjobs', {
+ headers: { Accept: 'application/json' },
+ withCredentials: true
+ })
+ if (response.data?.subJobs) {
+ setSubJobs(response.data.subJobs)
+ }
+ } catch (err) {
+ setError('Failed to fetch sub jobs')
+ messageApi.error('Failed to fetch sub jobs')
+ } finally {
+ setLoading(false)
+ }
+ } else {
+ setSubJobs(initialSubJobs)
+ }
+ }
+
+ initializeData()
+
+ // Add socket.io event listener for subjob updates
+ if (socket) {
+ socket.on('notify_subjob_update', (updateData) => {
+ if (updateData.subJobId) {
+ setSubJobs((prevSubJobs) =>
+ prevSubJobs.map((subJob) => {
+ if (subJob._id === updateData.id) {
+ return {
+ ...subJob,
+ state: updateData.state,
+ subJobId: updateData.subJobId
+ }
+ }
+ return subJob
+ })
+ )
+ }
+ })
+ }
+
+ return () => {
+ if (socket) {
+ socket.off('notify_subjob_update')
+ }
+ }
+ }, [initialSubJobs, socket])
+
+ if (loading) {
+ return (
+
+ } />
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ {error}
+ } onClick={() => setError(null)}>
+ Retry
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
+
+PrinterJobsTree.propTypes = {
+ subJobs: PropTypes.arrayOf(
+ PropTypes.shape({
+ state: PropTypes.object.isRequired,
+ _id: PropTypes.string.isRequired,
+ printer: PropTypes.string.isRequired,
+ printJob: PropTypes.shape({
+ state: PropTypes.object.isRequired,
+ _id: PropTypes.string.isRequired,
+ printers: PropTypes.arrayOf(PropTypes.string).isRequired,
+ createdAt: PropTypes.string.isRequired,
+ updatedAt: PropTypes.string.isRequired,
+ startedAt: PropTypes.string.isRequired,
+ gcodeFile: PropTypes.string.isRequired,
+ quantity: PropTypes.number.isRequired,
+ subJobs: PropTypes.arrayOf(PropTypes.string).isRequired
+ }).isRequired,
+ subJobId: PropTypes.string.isRequired,
+ number: PropTypes.number.isRequired,
+ createdAt: PropTypes.string.isRequired,
+ updatedAt: PropTypes.string.isRequired
+ })
+ )
+}
+
+export default PrinterJobsTree
diff --git a/src/components/Dashboard/common/PrinterMovementPanel.jsx b/src/components/Dashboard/common/PrinterMovementPanel.jsx
new file mode 100644
index 0000000..13f88cb
--- /dev/null
+++ b/src/components/Dashboard/common/PrinterMovementPanel.jsx
@@ -0,0 +1,256 @@
+// PrinterMovementPanel.js
+import React, { useContext, useState } from 'react'
+import {
+ Flex,
+ Space,
+ InputNumber,
+ Button,
+ Radio,
+ Dropdown,
+ Card,
+ message // eslint-disable-line
+} from 'antd'
+import {
+ ArrowUpOutlined,
+ ArrowLeftOutlined,
+ HomeOutlined,
+ ArrowRightOutlined,
+ ArrowDownOutlined
+} from '@ant-design/icons'
+import { SocketContext } from '../context/SocketContext'
+import UnloadIcon from '../../Icons/UnloadIcon'
+import PropTypes from 'prop-types'
+import LevelBedIcon from '../../Icons/LevelBedIcon'
+
+const PrinterMovementPanel = ({ printerId }) => {
+ const [posValue, setPosValue] = useState(10)
+ const [rateValue, setRateValue] = useState(1000)
+ const { socket } = useContext(SocketContext)
+
+ //const messageApi = message.useMessage()
+
+ const handlePosRadioChange = (e) => {
+ const value = e.target.value
+ setPosValue(value) // Update posValue state when radio button changes
+ }
+
+ const handlePosInputChange = (value) => {
+ setPosValue(value) // Update posValue state when input changes
+ }
+
+ const handleRateInputChange = (value) => {
+ setRateValue(value) // Update rateValue state when input changes
+ }
+
+ const handleHomeAxisClick = (axis) => {
+ if (socket) {
+ console.log('Homeing Axis:', axis)
+ socket.emit('printer.gcode.script', {
+ printerId,
+ script: `G28 ${axis}`
+ })
+ }
+ }
+
+ const handleMoveAxisClick = (axis, minus) => {
+ const distanceValue = !minus ? posValue * -1 : posValue
+ if (socket) {
+ console.log('Moving Axis:', axis, distanceValue)
+ socket.emit('printer.gcode.script', {
+ printerId,
+ script: `_CLIENT_LINEAR_MOVE ${axis}=${distanceValue} F=${rateValue}`
+ })
+ }
+ //sendCommand('moveAxis', { axis, pos, rate })
+ }
+
+ const handleLevelBedClick = () => {
+ //sendCommand('levelBed')
+ }
+
+ const handleUnloadFilamentClick = () => {
+ if (socket) {
+ socket.emit('printer.gcode.script', {
+ printerId,
+ script: `UNLOAD_FILAMENT TEMP=`
+ })
+ }
+ }
+
+ const homeAxisButtonItems = [
+ {
+ key: 'homeXYZ',
+ label: 'Home XYZ',
+ onClick: () => handleHomeAxisClick('ALL')
+ },
+ {
+ key: 'homeXY',
+ label: 'Home XY',
+ onClick: () => handleHomeAxisClick('X Y')
+ },
+ {
+ key: 'homeX',
+ label: 'Home X',
+ onClick: () => handleHomeAxisClick('X')
+ },
+ {
+ key: 'homeY',
+ label: 'Home Y',
+ onClick: () => handleHomeAxisClick('Y')
+ },
+ {
+ key: 'homeZ',
+ label: 'Home Z',
+ onClick: () => handleHomeAxisClick('Z')
+ }
+ ]
+
+ return (
+
+
+
+
+
+ }
+ onClick={() => {
+ handleMoveAxisClick('Y', false)
+ }}
+ />
+
+ }
+ onClick={() => {
+ handleMoveAxisClick('X', false)
+ }}
+ />
+
+ }>
+
+ }
+ onClick={() => {
+ handleMoveAxisClick('X', true)
+ }}
+ />
+
+ }
+ onClick={() => {
+ handleMoveAxisClick('Y', true)
+ }}
+ >
+
+
+
+
+ }
+ onClick={() => {
+ handleMoveAxisClick('Z', true)
+ }}
+ />
+ }
+ onClick={() => {
+ handleLevelBedClick()
+ }}
+ />
+ }
+ onClick={() => {
+ handleMoveAxisClick('Z', false)
+ }}
+ >
+
+
+
+
+ }
+ onClick={() => {
+ handleMoveAxisClick('E', true)
+ }}
+ />
+ }
+ onClick={() => {
+ handleUnloadFilamentClick()
+ }}
+ />
+ }
+ onClick={() => {
+ handleMoveAxisClick('E', false)
+ }}
+ >
+
+
+
+
+
+ 0.1
+ 1
+ 10
+ 100
+
+
+
+ `${value} mm`}
+ parser={(value) => value?.replace(' mm', '')}
+ onChange={handlePosInputChange}
+ placeholder='10 mm'
+ name='posInput'
+ style={{ flexGrow: 1 }}
+ />
+ `${value} mm/s`}
+ parser={(value) => value?.replace(' mm/s', '')}
+ onChange={handleRateInputChange}
+ placeholder='100 mm/s'
+ name='rateInput'
+ style={{ flexGrow: 1 }}
+ />
+
+
+
+
+ )
+}
+
+PrinterMovementPanel.propTypes = {
+ printerId: PropTypes.string.isRequired
+}
+
+export default PrinterMovementPanel
diff --git a/src/components/Dashboard/common/PrinterSelect.jsx b/src/components/Dashboard/common/PrinterSelect.jsx
new file mode 100644
index 0000000..030a730
--- /dev/null
+++ b/src/components/Dashboard/common/PrinterSelect.jsx
@@ -0,0 +1,115 @@
+// PrinterSelect.js
+import PropTypes from 'prop-types'
+import { TreeSelect, message, Tag } from 'antd'
+import React, { useEffect, useState, useContext } from 'react'
+import axios from 'axios'
+import PrinterState from './PrinterState'
+import { AuthContext } from '../../Auth/AuthContext'
+
+const PrinterSelect = ({ onChange, disabled, checkable }) => {
+ const [printersData, setPrintersData] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [messageApi] = message.useMessage()
+
+ const { authenticated } = useContext(AuthContext)
+
+ const fetchPrintersData = async () => {
+ if (!authenticated) {
+ return
+ }
+ setLoading(true)
+
+ try {
+ const response = await axios.get('http://localhost:8080/printers', {
+ headers: {
+ Accept: 'application/json'
+ },
+ withCredentials: true // Important for including cookies
+ })
+ setLoading(false)
+ return response.data
+ } catch (error) {
+ if (error.response) {
+ // For other errors, show a message
+ messageApi.error('Error fetching printers data:', error.response.status)
+ } else {
+ messageApi.error(
+ 'An unexpected error occurred. Please try again later.'
+ )
+ }
+ }
+ }
+
+ const generatePrinterItems = async () => {
+ const printerData = await fetchPrintersData()
+
+ // Create a map to store tags and their printers
+ const tagMap = new Map()
+
+ // Add printers to their respective tag groups
+ printerData.forEach((printer) => {
+ if (printer.tags && printer.tags.length > 0) {
+ printer.tags.forEach((tag) => {
+ if (!tagMap.has(tag)) {
+ tagMap.set(tag, [])
+ }
+ tagMap.get(tag).push(printer)
+ })
+ } else {
+ // If no tags, add to "Untagged" group
+ if (!tagMap.has('Untagged')) {
+ tagMap.set('Untagged', [])
+ }
+ tagMap.get('Untagged').push(printer)
+ }
+ })
+
+ // Convert the map to tree data structure
+ const treeData = Array.from(tagMap.entries()).map(([tag, printers]) => ({
+ title: tag === 'Untagged' ? tag : {tag},
+ value: `tag-${tag}`,
+ key: `tag-${tag}`,
+ children: printers.map((printer) => ({
+ title: (
+
+ ),
+ value: printer._id,
+ key: printer._id
+ }))
+ }))
+
+ setPrintersData(treeData)
+ }
+
+ useEffect(() => {
+ if (printersData.length === 0) {
+ generatePrinterItems()
+ }
+ }, [])
+
+ return (
+
+ )
+}
+
+PrinterSelect.propTypes = {
+ onChange: PropTypes.func.isRequired,
+ disabled: PropTypes.bool.isRequired,
+ checkable: PropTypes.bool
+}
+
+export default PrinterSelect
diff --git a/src/components/Dashboard/common/PrinterState.jsx b/src/components/Dashboard/common/PrinterState.jsx
new file mode 100644
index 0000000..e0dc1e4
--- /dev/null
+++ b/src/components/Dashboard/common/PrinterState.jsx
@@ -0,0 +1,189 @@
+// PrinterSelect.js
+import PropTypes from 'prop-types'
+import { Badge, Progress, Flex, Space, Tag, Typography, Button } from 'antd'
+import React, { useState, useContext, useEffect } from 'react'
+import { SocketContext } from '../context/SocketContext'
+import {
+ CloseOutlined,
+ PauseOutlined,
+ CaretRightOutlined
+} from '@ant-design/icons'
+
+const PrinterState = ({
+ printer,
+ showProgress = true,
+ showStatus = true,
+ showPrinterName = true,
+ showControls = true
+}) => {
+ const { socket } = useContext(SocketContext)
+ const [badgeStatus, setBadgeStatus] = useState('unknown')
+ const [badgeText, setBadgeText] = useState('Unknown')
+ const [currentState, setCurrentState] = useState(
+ printer?.state || {
+ type: 'unknown',
+ progress: 0
+ }
+ )
+ const [initialized, setInitialized] = useState(false)
+ const { Text } = Typography
+
+ useEffect(() => {
+ if (socket && !initialized && printer?.id) {
+ setInitialized(true)
+ socket.on('notify_printer_update', (statusUpdate) => {
+ if (statusUpdate?.id === printer.id && statusUpdate?.state) {
+ setCurrentState(statusUpdate.state)
+ }
+ })
+ }
+ return () => {
+ if (socket && initialized) {
+ socket.off('notify_printer_update')
+ }
+ }
+ }, [socket, initialized, printer?.id])
+
+ useEffect(() => {
+ switch (currentState.type) {
+ case 'online':
+ setBadgeStatus('success')
+ setBadgeText('Online')
+ break
+ case 'standby':
+ setBadgeStatus('success')
+ setBadgeText('Standby')
+ break
+ case 'complete':
+ setBadgeStatus('success')
+ setBadgeText('Complete')
+ break
+ case 'offline':
+ setBadgeStatus('default')
+ setBadgeText('Offline')
+ break
+ case 'shutdown':
+ setBadgeStatus('default')
+ setBadgeText('Shutdown')
+ break
+ case 'initializing':
+ setBadgeStatus('warning')
+ setBadgeText('Initializing')
+ break
+ case 'printing':
+ setBadgeStatus('processing')
+ setBadgeText('Printing')
+ break
+ case 'paused':
+ setBadgeStatus('warning')
+ setBadgeText('Paused')
+ break
+ case 'cancelled':
+ setBadgeStatus('warning')
+ setBadgeText('Cancelled')
+ break
+ case 'loading':
+ setBadgeStatus('processing')
+ setBadgeText('Uploading')
+ break
+ case 'processing':
+ setBadgeStatus('processing')
+ setBadgeText('Processing')
+ break
+ case 'ready':
+ setBadgeStatus('success')
+ setBadgeText('Ready')
+ break
+ case 'error':
+ setBadgeStatus('error')
+ setBadgeText('Error')
+ break
+ default:
+ setBadgeStatus('default')
+ setBadgeText(currentState.type)
+ }
+ }, [currentState])
+
+ return (
+
+ {showPrinterName && {printer.printerName}}
+ {showStatus && (
+
+
+
+
+ {badgeText}
+
+
+
+ )}
+ {showProgress &&
+ (currentState.type === 'printing' ||
+ currentState.type === 'deploying') ? (
+
+ ) : null}
+ {showControls && currentState.type === 'printing' ? (
+
+
+
+ ) : null}
+
+ )
+}
+
+PrinterState.propTypes = {
+ printer: PropTypes.shape({
+ id: PropTypes.string,
+ printerName: PropTypes.string,
+ state: PropTypes.shape({
+ type: PropTypes.string,
+ progress: PropTypes.number
+ })
+ }),
+ showProgress: PropTypes.bool,
+ showStatus: PropTypes.bool,
+ showPrinterName: PropTypes.bool,
+ showControls: PropTypes.bool
+}
+
+export default PrinterState
diff --git a/src/components/Dashboard/common/PrinterTemperaturePanel.jsx b/src/components/Dashboard/common/PrinterTemperaturePanel.jsx
new file mode 100644
index 0000000..16766d6
--- /dev/null
+++ b/src/components/Dashboard/common/PrinterTemperaturePanel.jsx
@@ -0,0 +1,304 @@
+// PrinterTemperaturePanel.js
+import React, { useContext, useState, useEffect } from 'react'
+import {
+ Progress,
+ Typography,
+ Spin,
+ Flex,
+ Space,
+ Collapse,
+ InputNumber,
+ Button
+} from 'antd'
+import { LoadingOutlined } from '@ant-design/icons'
+import { SocketContext } from '../context/SocketContext'
+import styled from 'styled-components'
+import PropTypes from 'prop-types'
+
+const { Text } = Typography
+const { Panel } = Collapse
+
+const CustomCollapse = styled(Collapse)`
+ .ant-collapse-header {
+ padding: 0 !important;
+ }
+ .ant-collapse-content-box {
+ padding-left: 0 !important;
+ padding-right: 0 !important;
+ padding-bottom: 0 !important;
+ }
+`
+
+const PrinterTemperaturePanel = ({
+ printerId,
+ showControls = true,
+ showMoreInfo = true
+}) => {
+ const [temperatureData, setTemperatureData] = useState({
+ hotEnd: {},
+ heatedBed: {}
+ })
+
+ // const [loading, setLoading] = React.useState(false)
+ const [hotEndTemperature, setHotEndTemperature] = useState(
+ temperatureData?.hotEnd?.target || 0
+ )
+ const [heatedBedTemperature, setHeatedBedTemperature] = useState(
+ temperatureData?.heatedBed?.target || 0
+ )
+ const { socket } = useContext(SocketContext)
+
+ const [initialized, setInitialized] = useState(false)
+
+ useEffect(() => {
+ const params = {
+ printerId,
+ objects: {
+ extruder: null,
+ heater_bed: null // eslint-disable-line
+ }
+ }
+ const notifyStatusUpdate = (statusUpdate) => {
+ var temperatureObject = {
+ ...temperatureData
+ }
+ if (statusUpdate?.extruder?.temperature !== undefined) {
+ temperatureObject.hotEnd.current = statusUpdate?.extruder?.temperature
+ }
+
+ if (statusUpdate?.heater_bed?.temperature !== undefined) {
+ temperatureObject.heatedBed.current =
+ statusUpdate?.heater_bed?.temperature
+ }
+
+ if (statusUpdate?.extruder?.target !== undefined) {
+ temperatureObject.hotEnd.target = statusUpdate?.extruder?.target
+ setHotEndTemperature(statusUpdate?.extruder?.target)
+ }
+
+ if (statusUpdate?.heater_bed?.target !== undefined) {
+ temperatureObject.heatedBed.target = statusUpdate?.heater_bed?.target
+ setHeatedBedTemperature(statusUpdate?.heater_bed?.target)
+ }
+
+ if (statusUpdate?.extruder?.power !== undefined) {
+ temperatureObject.hotEnd.power = statusUpdate?.extruder?.power
+ }
+
+ if (statusUpdate?.heater_bed?.power !== undefined) {
+ temperatureObject.heatedBed.power = statusUpdate?.heater_bed?.power
+ }
+
+ setTemperatureData(temperatureObject)
+ }
+ if (!initialized && socket) {
+ setInitialized(true)
+
+ socket.on('connect', () => {
+ console.log('Connected to socket!')
+ socket.emit('printer.objects.subscribe', params)
+ socket.emit('printer.objects.query', params)
+ })
+
+ console.log('Subscribing to temperature data')
+ socket.emit('printer.objects.subscribe', params)
+ socket.emit('printer.objects.query', params)
+ socket.on('notify_status_update', notifyStatusUpdate)
+ }
+ return () => {
+ if (socket && initialized) {
+ console.log('Unsubscribing...')
+ socket.off('notify_status_update', notifyStatusUpdate)
+ socket.emit('printer.objects.unsubscribe', params)
+ }
+
+ // Cleanup code here, like:
+ // - Removing event listeners
+ // - Clearing timers
+ // - Closing sockets
+ }
+ }, [socket, initialized, printerId])
+
+ const handleSetTemperatureClick = (target, value) => {
+ if (socket) {
+ console.log('printer.gcode.script', target, value)
+ socket.emit('printer.gcode.script', {
+ printerId,
+ script: `SET_HEATER_TEMPERATURE HEATER=${target} TARGET=${value}`
+ })
+ }
+ }
+
+ const moreInfoItems = [
+ {
+ key: '1',
+ label: 'More Temperature Data',
+ children: (
+ <>
+
+
+ Hot End Power:{' '}
+ {Math.round((temperatureData.hotEnd.power || 0) * 100)}%
+
+
+
+
+
+ Bed Power:{' '}
+ {Math.round((temperatureData.heatedBed.power || 0) * 100)}%
+
+
+
+
+ {typeof temperatureData.pindaTemp !== 'undefined' && (
+
+ Pinda Temp: {temperatureData.pindaTemp}°C
+
+ )}
+
+ {typeof temperatureData.ambiantActual !== 'undefined' && (
+
+ Ambient Actual: {temperatureData.ambiantActual}°C
+
+ )}
+
+ >
+ )
+ }
+ ]
+
+ return (
+
+ {temperatureData ? (
+
+ {temperatureData.hotEnd && (
+
+
+ Hot End: {temperatureData.hotEnd.current}°C /{' '}
+ {temperatureData.hotEnd.target}°C
+
+
+ {showControls === true && (
+
+
+ setHotEndTemperature(value)}
+ onPressEnter={() =>
+ handleSetTemperatureClick('extruder', hotEndTemperature)
+ }
+ />
+
+
+
+
+ )}
+
+ )}
+
+ {temperatureData.heatedBed && (
+
+
+ Heated Bed: {temperatureData.heatedBed.current}°C /{' '}
+ {temperatureData.heatedBed.target}°C
+
+
+ {showControls === true && (
+
+
+ setHeatedBedTemperature(value)}
+ onPressEnter={() =>
+ handleSetTemperatureClick(
+ 'heater_bed',
+ heatedBedTemperature
+ )
+ }
+ />
+
+
+
+
+ )}
+
+ )}
+ {showMoreInfo === true && (
+
+ )}
+
+ ) : (
+
+ } size='large' />
+
+ )}
+
+ )
+}
+
+PrinterTemperaturePanel.propTypes = {
+ printerId: PropTypes.string.isRequired,
+ showControls: PropTypes.bool,
+ showMoreInfo: PropTypes.bool
+}
+
+export default PrinterTemperaturePanel
diff --git a/src/components/Dashboard/common/ProductionSidebar.jsx b/src/components/Dashboard/common/ProductionSidebar.jsx
new file mode 100644
index 0000000..e7e47e8
--- /dev/null
+++ b/src/components/Dashboard/common/ProductionSidebar.jsx
@@ -0,0 +1,78 @@
+import React, { useState, useEffect } from 'react'
+import { Link, useLocation } from 'react-router-dom'
+import { Layout, Menu, Flex, Button } from 'antd'
+import {
+ DashboardOutlined,
+ PrinterOutlined,
+ PlayCircleOutlined,
+ MenuFoldOutlined,
+ MenuUnfoldOutlined
+} from '@ant-design/icons'
+import GCodeFileIcon from '../../Icons/GCodeFileIcon'
+
+const { Sider } = Layout
+
+const ProductionSidebar = () => {
+ const location = useLocation()
+ const [selectedKey, setSelectedKey] = useState('production')
+ const [collapsed, setCollapsed] = useState(false)
+
+ useEffect(() => {
+ const pathParts = location.pathname.split('/').filter(Boolean)
+ if (pathParts.length > 1) {
+ setSelectedKey(pathParts[1]) // Return the section (production/management)
+ }
+ }, [location.pathname])
+ const items = [
+ {
+ key: 'overview',
+ label: Overview,
+ icon:
+ },
+ {
+ key: 'printers',
+ label: Printers,
+ icon:
+ },
+ {
+ key: 'printjobs',
+ label: Print Jobs,
+ icon:
+ },
+ {
+ key: 'gcodefiles',
+ label: G Code Files,
+ icon:
+ }
+ ]
+ return (
+
+
+
+
+ : }
+ style={{ flexGrow: 1 }}
+ onClick={() => {
+ setCollapsed(!collapsed)
+ }}
+ />
+
+
+
+ )
+}
+
+export default ProductionSidebar
diff --git a/src/components/Dashboard/common/SubJobCounter.jsx b/src/components/Dashboard/common/SubJobCounter.jsx
new file mode 100644
index 0000000..2fe35de
--- /dev/null
+++ b/src/components/Dashboard/common/SubJobCounter.jsx
@@ -0,0 +1,119 @@
+import PropTypes from 'prop-types'
+import { Typography, Tag } from 'antd' // eslint-disable-line
+import {
+ CheckCircleOutlined,
+ PauseCircleOutlined,
+ QuestionCircleOutlined,
+ PlayCircleOutlined,
+ CloseCircleOutlined
+} from '@ant-design/icons' // eslint-disable-line
+import React, { useState, useContext, useEffect } from 'react'
+import { SocketContext } from '../context/SocketContext'
+
+const SubJobCounter = ({
+ job,
+ showIcon = true,
+ state = { type: 'complete' }
+}) => {
+ const { socket } = useContext(SocketContext)
+ const [initialized, setInitialized] = useState(false)
+ var badgeStatus = 'unknown'
+ var badgeIcon =
+ const [subJobs, setSubJobs] = useState(job.subJobs)
+
+ const [count, setCount] = useState(0)
+
+ useEffect(() => {
+ if (socket && !initialized && job?.id) {
+ setInitialized(true)
+ console.log('on notify_subjob_update')
+ socket.on('notify_subjob_update', (statusUpdate) => {
+ for (const subJob of job.subJobs) {
+ if (statusUpdate?.id === subJob.id && statusUpdate?.state) {
+ console.log('statusUpdate', statusUpdate)
+ setSubJobs((prev) => [...prev, statusUpdate])
+ }
+ }
+ })
+ }
+ return () => {
+ if (socket && initialized) {
+ console.log('off notify_subjob_update')
+ socket.off('notify_subjob_update')
+ }
+ }
+ }, [socket, initialized, job?.subJobs, job?.id])
+
+ switch (state.type) {
+ case 'draft':
+ badgeStatus = 'default'
+ badgeIcon =
+ break
+ case 'printing':
+ badgeStatus = 'processing'
+ badgeIcon =
+ break
+ case 'complete':
+ badgeStatus = 'success'
+ badgeIcon =
+ break
+ case 'failed':
+ badgeStatus = 'error'
+ badgeIcon =
+ break
+ case 'queued':
+ badgeStatus = 'warning'
+ badgeIcon =
+ break
+ case 'paused':
+ badgeStatus = 'warning'
+ badgeIcon =
+ break
+ case 'cancelled':
+ badgeIcon =
+ break
+ default:
+ badgeStatus = 'default'
+ }
+
+ useEffect(() => {
+ setCount(0)
+ for (let subJob of subJobs) {
+ if (subJob.state.type === state.type) {
+ setCount((prevCount) => prevCount + 1)
+ }
+ }
+ }, [subJobs, state.type])
+
+ return (
+
+ {count.toString()}
+
+ )
+}
+
+SubJobCounter.propTypes = {
+ state: PropTypes.shape({
+ type: PropTypes.string
+ }),
+ job: PropTypes.shape({
+ id: PropTypes.string,
+ subJobs: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.string,
+ subJobId: PropTypes.string,
+ state: PropTypes.shape({
+ type: PropTypes.string,
+ progress: PropTypes.number
+ })
+ })
+ )
+ }),
+ showIcon: PropTypes.bool
+}
+
+export default SubJobCounter
diff --git a/src/components/Dashboard/common/SubJobState.jsx b/src/components/Dashboard/common/SubJobState.jsx
new file mode 100644
index 0000000..d636892
--- /dev/null
+++ b/src/components/Dashboard/common/SubJobState.jsx
@@ -0,0 +1,204 @@
+import PropTypes from 'prop-types'
+import { Badge, Progress, Flex, Button, Space, Tag } from 'antd' // eslint-disable-line
+import {
+ CloseOutlined,
+ DeleteOutlined,
+ PauseOutlined,
+ CaretRightOutlined
+} from '@ant-design/icons' // eslint-disable-line
+import React, { useState, useContext, useEffect } from 'react'
+import { SocketContext } from '../context/SocketContext'
+import IdText from './IdText'
+
+const SubJobState = ({
+ subJob,
+ showStatus = true,
+ showId = true,
+ showProgress = true,
+ showControls = true //eslint-disable-line
+}) => {
+ const { socket } = useContext(SocketContext)
+ const [badgeStatus, setBadgeStatus] = useState('unknown')
+ const [badgeText, setBadgeText] = useState('Unknown')
+ const [currentState, setCurrentState] = useState(
+ subJob?.state || {
+ type: 'unknown',
+ progress: 0
+ }
+ )
+ const [initialized, setInitialized] = useState(false)
+
+ useEffect(() => {
+ if (socket && !initialized && subJob?.id) {
+ setInitialized(true)
+ console.log('on notify_subjob_update')
+ socket.on('notify_subjob_update', (statusUpdate) => {
+ if (statusUpdate?.id === subJob.id && statusUpdate?.state) {
+ console.log('statusUpdate', statusUpdate)
+ setCurrentState(statusUpdate.state)
+ }
+ })
+ }
+ return () => {
+ if (socket && initialized) {
+ console.log('off notify_subjob_update')
+ socket.off('notify_subjob_update')
+ }
+ }
+ }, [socket, initialized, subJob?.id])
+
+ useEffect(() => {
+ switch (currentState.type) {
+ case 'draft':
+ setBadgeStatus('default')
+ setBadgeText('Draft')
+ break
+ case 'printing':
+ setBadgeStatus('processing')
+ setBadgeText('Printing')
+ break
+ case 'complete':
+ setBadgeStatus('success')
+ setBadgeText('Complete')
+ break
+ case 'failed':
+ setBadgeStatus('error')
+ setBadgeText('Failed')
+ break
+ case 'queued':
+ setBadgeStatus('warning')
+ setBadgeText('Queued')
+ break
+ case 'paused':
+ setBadgeStatus('warning')
+ setBadgeText('Paused')
+ break
+ case 'cancelled':
+ setBadgeStatus('error')
+ setBadgeText('Cancelled')
+ break
+ default:
+ setBadgeStatus('default')
+ setBadgeText('Unknown')
+ }
+ }, [currentState])
+
+ return (
+
+ {showId && (
+ <>
+ {'Sub Job '}
+
+ >
+ )}
+ {showStatus && (
+
+
+
+
+ {badgeText}
+
+
+
+ )}
+ {showProgress &&
+ (currentState.type === 'printing' ||
+ currentState.type === 'processing') ? (
+
+ ) : null}
+ {showControls &&
+ (currentState.type === 'printing' || currentState.type === 'paused') ? (
+
+
+
+ ) : null}
+ {showControls && currentState.type === 'queued' ? (
+
+ )
+}
+
+SubJobState.propTypes = {
+ subJob: PropTypes.shape({
+ id: PropTypes.string,
+ subJobId: PropTypes.string,
+ printer: PropTypes.string,
+ number: PropTypes.number,
+ state: PropTypes.shape({
+ type: PropTypes.string,
+ progress: PropTypes.number
+ })
+ }),
+ showProgress: PropTypes.bool,
+ showControls: PropTypes.bool,
+ showId: PropTypes.bool,
+ showStatus: PropTypes.bool
+}
+
+export default SubJobState
diff --git a/src/components/Dashboard/common/SubJobsTree.jsx b/src/components/Dashboard/common/SubJobsTree.jsx
new file mode 100644
index 0000000..e588c15
--- /dev/null
+++ b/src/components/Dashboard/common/SubJobsTree.jsx
@@ -0,0 +1,204 @@
+// PrinterSelect.js
+import PropTypes from 'prop-types'
+import { Tree, Card, Spin, Space, Button, message } from 'antd'
+import { LoadingOutlined, ReloadOutlined } from '@ant-design/icons'
+import React, { useState, useEffect, useContext, useCallback } from 'react'
+import PrinterState from './PrinterState'
+import axios from 'axios'
+import { SocketContext } from '../context/SocketContext'
+import SubJobState from './SubJobState'
+const SubJobsTree = ({ printJobData }) => {
+ const [treeData, setTreeData] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const { socket } = useContext(SocketContext)
+ const [messageApi] = message.useMessage()
+ const [expandedKeys, setExpandedKeys] = useState([])
+ const [currentPrintJobData, setCurrentPrintJobData] = useState(null)
+
+ const buildTreeData = useCallback(
+ (jobData) => {
+ if (!jobData?.subJobs?.length) {
+ setTreeData([])
+ setExpandedKeys([])
+ return
+ }
+
+ // Create tree nodes for each printer
+ const printerNodes = jobData.printers.map((printerData) => {
+ // Find subjobs for this printer
+ const printerSubJobs = jobData.subJobs.filter(
+ (subJob) => subJob.printer === printerData.id
+ )
+ setExpandedKeys((prev) => [...prev, `printer-${printerData.id}`])
+ return {
+ title: printerData.state ? (
+
+ ) : (
+ } />
+ ),
+ key: `printer-${printerData.id}`,
+ children: printerSubJobs.map((subJob) => {
+ return {
+ title: (
+
+ ),
+ key: `subjob-${subJob._id}`,
+ isLeaf: true
+ }
+ })
+ }
+ })
+
+ setTreeData(printerNodes)
+ },
+ [expandedKeys]
+ )
+
+ useEffect(() => {
+ buildTreeData(currentPrintJobData)
+ }, [currentPrintJobData])
+
+ useEffect(() => {
+ const initializeData = async () => {
+ if (!printJobData) {
+ try {
+ setLoading(true)
+ const response = await axios.get('http://localhost:8080/printjobs', {
+ headers: { Accept: 'application/json' },
+ withCredentials: true
+ })
+ if (response.data) {
+ setCurrentPrintJobData(response.data)
+ }
+ } catch (err) {
+ setError('Failed to fetch print job details')
+ messageApi.error('Failed to fetch print job details')
+ } finally {
+ setLoading(false)
+ }
+ } else {
+ setCurrentPrintJobData(printJobData)
+ }
+ }
+
+ initializeData()
+
+ // Add socket.io event listener for deployment updates
+ if (socket) {
+ socket.on('notify_deployment_update', (updateData) => {
+ console.log('Received deployment update:', updateData)
+ setCurrentPrintJobData((prevData) => {
+ if (!prevData) return prevData
+
+ // Handle printer updates
+ if (updateData.printerId) {
+ return {
+ ...prevData,
+ printers: prevData.printers.map((printer) => {
+ if (
+ printer.id === updateData.printerId &&
+ updateData.state == 'deploying'
+ ) {
+ return {
+ ...printer,
+ deploymentProgress: updateData.progress
+ }
+ } else if (
+ printer.id === updateData.printerId &&
+ updateData.state == 'complete'
+ ) {
+ return {
+ ...printer,
+ deploymentProgress: undefined
+ }
+ }
+ return printer
+ })
+ }
+ }
+
+ return prevData
+ })
+ })
+ socket.on('notify_subjob_update', (updateData) => {
+ // Handle sub-job updates
+ if (updateData.subJobId) {
+ console.log('Received subjob update:', updateData)
+ setCurrentPrintJobData((prevData) => {
+ if (!prevData) return prevData
+ return {
+ ...prevData,
+ // eslint-disable-next-line camelcase
+ subJobs: prevData.subJobs.map((subJob) => {
+ if (subJob._id === updateData.id) {
+ return {
+ ...subJob,
+ state: updateData.state,
+ subJobId: updateData.subJobId
+ }
+ }
+ return subJob
+ })
+ }
+ })
+ }
+ })
+ }
+
+ return () => {
+ if (socket) {
+ socket.off('notify_deployment_update')
+ }
+ }
+ }, [printJobData, socket])
+
+ if (loading) {
+ return (
+
+ } />
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ {error}
+ }
+ onClick={() => buildTreeData(printJobData)}
+ >
+ Retry
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
+
+SubJobsTree.propTypes = {
+ printJobData: PropTypes.object.isRequired
+}
+
+export default SubJobsTree
diff --git a/src/components/Dashboard/context/SpotlightContext.js b/src/components/Dashboard/context/SpotlightContext.js
new file mode 100644
index 0000000..d97b367
--- /dev/null
+++ b/src/components/Dashboard/context/SpotlightContext.js
@@ -0,0 +1,376 @@
+import { Input, Flex, List, Typography, Modal, Spin, message, Form } from 'antd'
+import React, { createContext, useEffect, useState, useRef } from 'react'
+import axios from 'axios'
+import {
+ LoadingOutlined,
+ PrinterOutlined,
+ PlayCircleOutlined
+} from '@ant-design/icons'
+import PropTypes from 'prop-types'
+import PrinterState from '../common/PrinterState'
+import JobState from '../common/JobState'
+import IdText from '../common/IdText'
+
+const SpotlightContext = createContext()
+
+const SpotlightProvider = ({ children }) => {
+ const { Text } = Typography
+ const [showModal, setShowModal] = useState(false)
+ const [loading, setLoading] = useState(false)
+ const [query, setQuery] = useState('')
+ const [listData, setListData] = useState([])
+ const [messageApi, contextHolder] = message.useMessage()
+ const [inputPrefix, setInputPrefix] = useState('')
+
+ // Refs for throttling/debouncing
+ const lastFetchTime = useRef(0)
+ const pendingQuery = useRef(null)
+ const fetchTimeoutRef = useRef(null)
+ const inputRef = useRef(null)
+ const formRef = useRef(null)
+
+ const showSpotlight = (defaultQuery = '') => {
+ setQuery(defaultQuery)
+ setShowModal(true)
+
+ // Set prefix based on default query if provided
+ if (defaultQuery) {
+ detectAndSetPrefix(defaultQuery)
+ checkAndFetchData(defaultQuery)
+ } else {
+ setInputPrefix('')
+ }
+
+ // Focus will be handled in useEffect for proper timing after modal renders
+ }
+
+ const fetchData = async (searchQuery) => {
+ if (!searchQuery || !searchQuery.trim()) return
+
+ try {
+ // Update last fetch time
+ lastFetchTime.current = Date.now()
+ // Clear any pending queries
+ pendingQuery.current = null
+
+ setLoading(true)
+ setListData([])
+ const response = await axios.get(
+ `http://localhost:8080/spotlight/${encodeURIComponent(searchQuery.trim())}`,
+ {
+ headers: {
+ Accept: 'application/json'
+ },
+ withCredentials: true
+ }
+ )
+ setLoading(false)
+ setListData(response.data)
+
+ // Check if there's a pending query after this fetch completes
+ if (pendingQuery.current !== null) {
+ const timeToNextFetch = Math.max(
+ 0,
+ 1000 - (Date.now() - lastFetchTime.current)
+ )
+ scheduleNextFetch(timeToNextFetch)
+ }
+ } catch (error) {
+ setLoading(false)
+ messageApi.error('An error occurred while fetching data.')
+ console.error('Spotlight fetch error:', error)
+ }
+ }
+
+ const checkAndFetchData = (searchQuery) => {
+ // Store the latest query
+ pendingQuery.current = searchQuery
+
+ // Calculate time since last fetch
+ const now = Date.now()
+ const timeSinceLastFetch = now - lastFetchTime.current
+
+ // If we've waited at least 1 second since last fetch, fetch immediately
+ if (timeSinceLastFetch >= 1000) {
+ if (fetchTimeoutRef.current) {
+ clearTimeout(fetchTimeoutRef.current)
+ fetchTimeoutRef.current = null
+ }
+ fetchData(searchQuery)
+ } else {
+ // Otherwise, schedule fetch for when 1 second has passed
+ if (!fetchTimeoutRef.current) {
+ const timeToWait = 1000 - timeSinceLastFetch
+ scheduleNextFetch(timeToWait)
+ }
+ // We don't need to do anything if a fetch is already scheduled
+ // as the latest query is already stored in pendingQuery
+ }
+ }
+
+ const scheduleNextFetch = (delay) => {
+ if (fetchTimeoutRef.current) {
+ clearTimeout(fetchTimeoutRef.current)
+ }
+
+ fetchTimeoutRef.current = setTimeout(() => {
+ fetchTimeoutRef.current = null
+ if (pendingQuery.current !== null) {
+ fetchData(pendingQuery.current)
+ }
+ }, delay)
+ }
+
+ // Detect and set the appropriate prefix based on input
+ const detectAndSetPrefix = (text) => {
+ if (!text || text.trim() === '') {
+ setInputPrefix('')
+ return
+ }
+
+ console.log('Detecting prefix')
+ const upperText = text.toUpperCase()
+
+ if (upperText.startsWith('JOB:')) {
+ setInputPrefix('JOB:')
+ return true
+ } else if (upperText.startsWith('PRN:')) {
+ setInputPrefix('PRN:')
+ return true
+ } else if (upperText.startsWith('FIL:')) {
+ setInputPrefix('FIL')
+ return true
+ } else if (upperText.startsWith('GCF:')) {
+ setInputPrefix('GCF:')
+ return true
+ }
+
+ // Default behavior if no match
+ setInputPrefix('')
+ return false
+ }
+
+ const handleSpotlightChange = (formData) => {
+ const newQuery = formData.query || ''
+ setQuery(newQuery)
+
+ // Detect and set the appropriate prefix
+ detectAndSetPrefix(inputPrefix + newQuery)
+
+ // Check if we need to fetch data
+ checkAndFetchData(inputPrefix + newQuery)
+ }
+
+ // Focus the input element
+ const focusInput = () => {
+ setTimeout(() => {
+ if (inputRef.current) {
+ const input = inputRef.current.input
+ if (input) {
+ input.focus()
+ }
+ }
+ }, 50)
+ }
+
+ // Custom handler for input changes to handle prefix logic
+ const handleInputChange = (e) => {
+ const value = e.target.value
+
+ // If the input is empty or being cleared
+ if (!value || value.trim() === '') {
+ // Only clear the prefix if the input is completely empty
+ if (value === '') {
+ console.log('Clearning prefix')
+ setInputPrefix('')
+ }
+ if (formRef.current) {
+ formRef.current.setFieldsValue({ query: value })
+ }
+ }
+ // If the user is typing and it doesn't have a prefix yet
+ else if (!inputPrefix) {
+ console.log('No prefix')
+ // Check for prefixes at the beginning of the input
+ const upperValue = value.toUpperCase()
+
+ if (upperValue.startsWith('JOB:') || upperValue.startsWith('PRN:')) {
+ const parts = upperValue.split(':')
+ const prefix = parts[0] + ':'
+ const restOfInput = value.substring(prefix.length)
+
+ // Set the prefix and update the input without the prefix
+ setInputPrefix(prefix)
+ if (formRef.current) {
+ formRef.current.setFieldsValue({ query: restOfInput })
+ // Ensure input gets focus after prefix is set
+ focusInput()
+ }
+ return
+ }
+ }
+ }
+
+ // Handle key down events for backspace behavior
+ const handleKeyDown = (e) => {
+ // If backspace is pressed and there's a prefix but the input is empty
+
+ if (e.key === 'Backspace' && inputPrefix && query == inputPrefix) {
+ console.log('Query', query)
+ // Clear the prefix
+ setInputPrefix('')
+ // Prevent the default backspace behavior in this case
+ e.preventDefault()
+ }
+ }
+
+ // Add keyboard shortcut listener
+ useEffect(() => {
+ const handleKeyPress = (e) => {
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'p') {
+ e.preventDefault() // Prevent browser's default behavior
+ showSpotlight()
+ }
+ }
+
+ // Add event listener
+ window.addEventListener('keydown', handleKeyPress)
+
+ // Clean up
+ return () => {
+ window.removeEventListener('keydown', handleKeyPress)
+ }
+ }, [])
+
+ // Focus and select text in input when modal becomes visible
+ useEffect(() => {
+ if (showModal && inputRef.current) {
+ // Use a small timeout to ensure the modal is fully rendered and visible
+ setTimeout(() => {
+ const input = inputRef.current.input
+ if (input) {
+ input.focus()
+ input.select() // Select all text
+ }
+ }, 50)
+ }
+ }, [showModal])
+
+ // Focus input when inputPrefix changes
+ useEffect(() => {
+ if (showModal) {
+ focusInput()
+ }
+ }, [inputPrefix, showModal])
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ if (fetchTimeoutRef.current) {
+ clearTimeout(fetchTimeoutRef.current)
+ }
+ }
+ }, [])
+
+ return (
+
+ {contextHolder}
+ setShowModal(false)}
+ closeIcon={null}
+ footer={null}
+ styles={{ content: { backgroundColor: 'transparent' } }}
+ >
+
+
+ }
+ spinning={loading}
+ size='small'
+ />
+ }
+ onChange={handleInputChange}
+ onKeyDown={handleKeyDown}
+ />
+
+
+
+ {listData.length > 0 && (
+ (
+
+
+
+ {item.printer ? (
+
+ ) : null}
+ {item.job ? (
+
+ ) : null}
+
+
+ {item.name}
+
+ {item.printer ? (
+
+
+
+
+ ) : null}
+ {item.job ? (
+
+ {item.job.state.type ? (
+
+ ) : null}
+
+
+
+ ) : null}
+
+
+ }
+ />
+ ENTER
+
+ )}
+ >
+ )}
+
+
+ {children}
+
+ )
+}
+
+SpotlightProvider.propTypes = {
+ children: PropTypes.node.isRequired
+}
+
+export { SpotlightProvider, SpotlightContext }
diff --git a/src/components/Dashboard/utils/GCode.js b/src/components/Dashboard/utils/GCode.js
new file mode 100644
index 0000000..e147d20
--- /dev/null
+++ b/src/components/Dashboard/utils/GCode.js
@@ -0,0 +1,30 @@
+export default class GCode {
+ constructor(configString) {
+ this.configString = configString
+ }
+
+ async parse(onProgress) {
+ return new Promise((resolve, reject) => {
+ const worker = new Worker('../gcode-worker.js')
+
+ worker.onmessage = (event) => {
+ const { type, progress, configObject } = event.data
+
+ if (type === 'progress') {
+ // Report progress to the caller
+ if (onProgress) onProgress(progress)
+ } else if (type === 'result') {
+ resolve(configObject)
+ worker.terminate()
+ }
+ }
+
+ worker.onerror = (error) => {
+ reject(error)
+ worker.terminate()
+ }
+
+ worker.postMessage({ configString: this.configString })
+ })
+ }
+}
diff --git a/src/components/Dashboard/utils/Utils.js b/src/components/Dashboard/utils/Utils.js
new file mode 100644
index 0000000..36a908f
--- /dev/null
+++ b/src/components/Dashboard/utils/Utils.js
@@ -0,0 +1,31 @@
+export function capitalizeFirstLetter(string) {
+ try {
+ return string[0].toUpperCase() + string.slice(1)
+ } catch {
+ return ''
+ }
+}
+
+export function timeStringToMinutes(timeString) {
+ // Extract hours, minutes, and seconds using a regular expression
+ const regex = /(\d+h)?\s*(\d+m)?\s*(\d+s)?/
+ const matches = timeString.match(regex)
+
+ // Initialize hours, minutes, and seconds to 0
+ let hours = 0
+ let minutes = 0
+ let seconds = 0
+
+ // If matches are found, extract the values
+ if (matches) {
+ if (matches[1]) hours = parseInt(matches[1])
+ if (matches[2]) minutes = parseInt(matches[2])
+ if (matches[3]) seconds = parseInt(matches[3])
+ }
+
+ // Convert everything to minutes
+ const totalMinutes = hours * 60 + minutes + seconds / 60
+
+ // Return the integer value of total minutes
+ return Math.floor(totalMinutes)
+}
diff --git a/src/components/Icons/FilamentIcon.jsx b/src/components/Icons/FilamentIcon.jsx
new file mode 100644
index 0000000..92067cc
--- /dev/null
+++ b/src/components/Icons/FilamentIcon.jsx
@@ -0,0 +1,7 @@
+import React from 'react'
+import Icon from '@ant-design/icons'
+import { ReactComponent as CustomIconSvg } from '../../assets/icons/filamenticon.svg'
+
+const FilamentIcon = (props) =>
+
+export default FilamentIcon
diff --git a/src/components/Icons/LevelBedIcon.jsx b/src/components/Icons/LevelBedIcon.jsx
new file mode 100644
index 0000000..6d45c42
--- /dev/null
+++ b/src/components/Icons/LevelBedIcon.jsx
@@ -0,0 +1,7 @@
+import React from 'react'
+import Icon from '@ant-design/icons'
+import { ReactComponent as CustomIconSvg } from '../../assets/icons/levelbedicon.svg'
+
+const LevelBedIcon = (props) =>
+
+export default LevelBedIcon
diff --git a/src/components/Icons/NewWindowIcon.jsx b/src/components/Icons/NewWindowIcon.jsx
new file mode 100644
index 0000000..4e42a99
--- /dev/null
+++ b/src/components/Icons/NewWindowIcon.jsx
@@ -0,0 +1,7 @@
+import React from 'react'
+import Icon from '@ant-design/icons'
+import { ReactComponent as CustomIconSvg } from '../../assets/icons/newwindowicon.svg'
+
+const NewWindowIcon = (props) =>
+
+export default NewWindowIcon
diff --git a/src/components/Icons/PartIcon.jsx b/src/components/Icons/PartIcon.jsx
new file mode 100644
index 0000000..4644308
--- /dev/null
+++ b/src/components/Icons/PartIcon.jsx
@@ -0,0 +1,7 @@
+import React from 'react'
+import Icon from '@ant-design/icons'
+import { ReactComponent as CustomIconSvg } from '../../assets/icons/particon.svg'
+
+const PartIcon = (props) =>
+
+export default PartIcon
diff --git a/src/components/Icons/ProductIcon.jsx b/src/components/Icons/ProductIcon.jsx
new file mode 100644
index 0000000..cd82c6e
--- /dev/null
+++ b/src/components/Icons/ProductIcon.jsx
@@ -0,0 +1,7 @@
+import React from 'react'
+import Icon from '@ant-design/icons'
+import { ReactComponent as CustomIconSvg } from '../../assets/icons/producticon.svg'
+
+const ProductIcon = (props) =>
+
+export default ProductIcon
diff --git a/src/components/Icons/UnloadIcon.jsx b/src/components/Icons/UnloadIcon.jsx
new file mode 100644
index 0000000..747361e
--- /dev/null
+++ b/src/components/Icons/UnloadIcon.jsx
@@ -0,0 +1,7 @@
+import React from 'react'
+import Icon from '@ant-design/icons'
+import { ReactComponent as CustomIconSvg } from '../../assets/icons/unloadicon.svg'
+
+const UnloadIcon = (props) =>
+
+export default UnloadIcon