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. + +
+ +
+ ) +} + +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 ( + + + + + }} + /> + + { + 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: ( + <> + + + + + + + + + + } + placeholder='https://example.com' + /> + + + ) + }, + { + title: 'Summary', + key: 'summary', + content: ( + <> + + Please review the information: + + + + ) + } + ] + + return ( + <> + {contextHolder} +
{ + setEditSpoolFormValues(allValues) + }} + > + { + setCurrentStep(current) + }} + /> + + {steps[currentStep].content} + + + {currentStep > 0 && ( + + )} + {currentStep < steps.length - 1 && ( + + )} + {currentStep === steps.length - 1 && ( + + )} + + + + ) +} + +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: + + + + + + + + + + + } + placeholder='https://example.com' + /> + + + ) + }, + { + title: 'Summary', + key: 'summary', + content: ( + <> + + Please review the information: + + + + ) + } + ] + + return ( + <> + {contextHolder} +
{ + setNewSpoolFormValues(allValues) + }} + > + { + setCurrentStep(current) + }} + /> + + {steps[currentStep].content} + + + {currentStep > 0 && ( + + )} + {currentStep < steps.length - 1 && ( + + )} + {currentStep === steps.length - 1 && ( + + )} + + + + ) +} + +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 ( + + + + + ) + } + } + ] + + 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'}

+ +
+ ) + } + + return ( +
+ {contextHolder} + + + Filament Information + + + {isEditing ? ( + <> + + + + ) : ( + + )} + + + +
+ + {/* Read-only fields */} + + {filamentData.id ? ( + + ) : ( + 'n/a' + )} + + + {(() => { + if (filamentData.createdAt) { + return moment(filamentData.createdAt.$date).format( + 'YYYY-MM-DD HH:mm:ss' + ) + } + return 'N/A' + })()} + + + {/* Editable fields */} + + {isEditing ? ( + + + + ) : ( + filamentData.name || 'n/a' + )} + + + + {isEditing ? ( + + + + ) : ( + filamentData.brand || 'n/a' + )} + + + + {isEditing ? ( + + + + ) : ( + filamentData.type || 'n/a' + )} + + + + {isEditing ? ( + + + + ) : filamentData.price ? ( + `£${filamentData.price} per kg` + ) : ( + 'n/a' + )} + + + + {isEditing ? ( + + + + ) : filamentData.color ? ( + + ) : ( + 'n/a' + )} + + + + {isEditing ? ( + + + + ) : filamentData.diameter ? ( + `${filamentData.diameter}mm` + ) : ( + 'n/a' + )} + + + + {isEditing ? ( + + + + ) : filamentData.density ? ( + `${filamentData.density}g/cm³` + ) : ( + 'n/a' + )} + + + + {isEditing ? ( + + + + ) : filamentData.emptySpoolWeight ? ( + `${filamentData.emptySpoolWeight}g` + ) : ( + 'n/a' + )} + + + + {isEditing ? ( + + + + ) : filamentData.url ? ( + + {new URL(filamentData.url).hostname + ' '} + + + ) : ( + 'n/a' + )} + + + + {isEditing ? ( + + + + ) : ( + filamentData.barcode || 'n/a' + )} + + + +
+ ) +} + +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} + > + + + + + } /> + + + } /> + + + ) + }, + { + title: 'Summary', + key: 'done', + content: ( + + + + ) + } + ] + + return ( + + {contextHolder} + + + + + + + + + + New Filament + +
+ setNewFilamentFormValues((prevValues) => ({ + ...prevValues, + ...changedValues + })) + } + initialValues={initialNewFilamentForm} + > + {steps[currentStep].content} + + + + {currentStep < steps.length - 1 && ( + + )} + {currentStep === steps.length - 1 && ( + + )} + + +
+ + + ) +} + +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 ( + + + + + ) + } + } + ] + + 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) + } + /> + + + + , + + {currentStep < steps.length - 1 && ( + + )} + {currentStep === steps.length - 1 && ( + + )} + + + + + {/* 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'}

+ +
+ ) + } + + return ( +
+ {contextHolder} + + + Part Information + + + {isEditing ? ( + <> + + + + ) : ( + + )} + + + +
+ + + {partData.id ? ( + + ) : ( + 'n/a' + )} + + + {(() => { + if (partData.createdAt) { + return moment(partData.createdAt.$date).format( + 'YYYY-MM-DD HH:mm:ss' + ) + } + return 'N/A' + })()} + + + {isEditing ? ( + + + + ) : ( + partData.name || 'n/a' + )} + + + + + + + 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 ( + + + + + ) + } + } + ] + + 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 + +
+ setNewProductFormValues((prevValues) => ({ + ...prevValues, + ...changedValues + })) + } + initialValues={initialNewProductForm} + > +
{steps[currentStep].content}
+ + + + {currentStep < steps.length - 1 && ( + + )} + {currentStep === steps.length - 1 && ( + + )} + + +
+
+ ) +} + +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'}

+ +
+ ) + } + + return ( +
+ {contextHolder} + + + Product Information + + + {isEditing ? ( + <> + + + + ) : ( + + )} + + + +
+ + + {productData.id ? ( + + ) : ( + 'n/a' + )} + + + {(() => { + if (productData.createdAt) { + return moment(productData.createdAt.$date).format( + 'YYYY-MM-DD HH:mm:ss' + ) + } + return 'N/A' + })()} + + + {isEditing ? ( + + + + ) : ( + productData.name || 'n/a' + )} + + + + + + + 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 ( + + + + + ) + } + } + ] + + 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 + +
+ setNewVendorFormValues((prevValues) => ({ + ...prevValues, + ...changedValues + })) + } + initialValues={initialNewVendorForm} + > +
{steps[currentStep].content}
+ + + + {currentStep < steps.length - 1 && ( + + )} + {currentStep === steps.length - 1 && ( + + )} + + +
+
+ ) +} + +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'}

+ +
+ ) + } + + return ( +
+ {contextHolder} + + + Vendor Information + + + {isEditing ? ( + <> +
+ ) +} + +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 ( + + + + + ) + } + } + ] + + 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' + > +
+ setEditFilamentFormValues((prevValues) => ({ + ...prevValues, + ...changedValues + })) + } + > + + + + + + + + + + + { + if (!value) return '£' + return `£${value}` + }} + step={0.01} + style={{ width: '100%' }} + addonAfter='per kg' + /> + + + { + return '#' + color.toHex() + }} + > + + + + + + + + { + setImageList(fileList) + }} + > + + + + + } /> + + + } /> + + + + + + + + + + +
+ + ) +} + +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'}

+ +
+ ) + } + + 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 ? ( + GCodeFile + ) : ( + '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 + +
+ setNewGCodeFileFormValues((prevValues) => ({ + ...prevValues, + ...changedValues + })) + } + initialValues={initialNewGCodeFileForm} + > +
{steps[currentStep].content}
+ + + + {currentStep < steps.length - 1 && ( + + )} + {currentStep === steps.length - 1 && ( + + )} + + +
+
+ ) +} + +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} + + + `${stats.printerStatus.printing || 0} Printing`} + /> + `${stats.printerStatus.idle || 0} Idle`} + /> + `${stats.printerStatus.error || 0} Error`} + /> + + + + + + 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' ? ( + + + + ) + } + } + ] + + 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} + + + + +
}} + 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 + +
+ setNewPrintJobFormValues((prevValues) => ({ + ...prevValues, + ...changedValues + })) + } + initialValues={initialNewPrintJobForm} + > + {steps[currentStep].content} + + + + {currentStep < steps.length - 1 && ( + + )} + {currentStep === steps.length - 1 && ( + + )} + + +
+ + + ) +} + +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'}

+ +
+ ) + } + + 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 ( + + + + + ) + } + } + ] + + return ( + <> + + + + + +
}} + 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 + +
+ setNewGCodeFileFormValues((prevValues) => ({ + ...prevValues, + ...changedValues + })) + } + initialValues={initialNewGCodeFileForm} + > + {steps[currentStep].content} + + + + {currentStep < steps.length - 1 && ( + + )} + {currentStep === steps.length - 1 && ( + + )} + + +
+ + + ) +} + +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' /> + )} + + + + + + + +
+ {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 ? ( + <> + + + + setHostnameInput(e.target.value)} + placeholder='Enter host' + autoFocus + /> + + + + ) : ( + <> + + + + + + + + + + + + + + + + )} + + + ) + }, + { + title: 'Required', + key: 'required', + content: ( + <> + + + + + ) + }, + { + title: 'Summary', + key: 'summary', + content: ( + + + + ) + } + ] + + return ( + + {contextHolder} + {notificationContextHolder} + +
+ +
+ + + + + + New Printer + +
+ setNewPrinterFormValues((prevValues) => ({ + ...prevValues, + ...changedValues + })) + } + initialValues={initialNewPrinterForm} + > +
{steps[currentStep].content}
+ + + + {currentStep < steps.length - 1 && ( + + )} + {currentStep === steps.length - 1 && ( + + )} + + +
+
+ ) +} + +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'}

+ +
+ ) + } + + return ( +
+ {contextHolder} + + + Printer Information + + + {isEditing ? ( + <> + + + + ) : ( + + )} + + + +
+ + {/* Read-only fields */} + + + + + {new Date(printerData.updatedAt).toLocaleString()} + + + {/* Editable fields */} + + {isEditing ? ( + + + + ) : ( + printerData.printerName || 'n/a' + )} + + + + {isEditing ? ( + + + + ) : ( + printerData.moonraker?.host || 'n/a' + )} + + + + {isEditing ? ( + + + + ) : ( + printerData.moonraker.port + )} + + + + {isEditing ? ( + + + +
+ ) +} + +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 && ( + + + + ) + } + + 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 ( +
+ + + + + + + + + + + + + + + + + + + + + + + 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' ? ( + + + + + + + )} + + )} + + {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 ( + + + + + + + + ) + } + + 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