Updated dependencies, improved UI elements, and refactored components for better performance. Added new features including audit logs and enhanced loading states. Removed unused print jobs component.

This commit is contained in:
Tom Butcher 2025-06-08 18:31:06 +01:00
parent 8ad0ccac5e
commit bd5085cded
89 changed files with 9428 additions and 4087 deletions

3169
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,8 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@ant-design/charts": "^2.3.0",
"@ant-design/pro-components": "^2.8.7",
"@babel/plugin-transform-private-property-in-object": "^7.27.1",
"@simplewebauthn/browser": "^13.1.0",
"@tsparticles/react": "^3.0.0",
@ -27,9 +29,12 @@
"react": "^18.3.1",
"react-country-flag": "^3.1.0",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-responsive": "^10.0.1",
"react-router-dom": "*",
"react-scripts": "*",
"react-stl-viewer": "^2.5.0",
"remark-gfm": "^4.0.1",
"socket.io-client": "*",
"standard": "^17.1.2",
"styled-components": "*",

View File

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
@ -24,7 +24,7 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
<title>Farm Control</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@ -1,6 +1,18 @@
body,
.ant-typography {
font-family: 'SF Pro';
:root {
--unit-100vh: 100vh;
}
@supports (height: 100dvh) {
:root {
--unit-100vh: 100dvh;
}
}
.ant-menu-overflow-item-rest::after {
border-bottom: none !important;
}
.ant-menu-overflow-item > div:hover {
background-color: transparent !important;
}
.App {

View File

@ -13,8 +13,8 @@ import Printers from './components/Dashboard/Production/Printers'
import ControlPrinter from './components/Dashboard/Production/Printers/ControlPrinter.jsx'
import PrinterInfo from './components/Dashboard/Production/Printers/PrinterInfo.jsx'
import PrintJobs from './components/Dashboard/Production/PrintJobs.jsx'
import PrintJobInfo from './components/Dashboard/Production/PrintJobs/PrintJobInfo.jsx'
import Jobs from './components/Dashboard/Production/Jobs.jsx'
import JobInfo from './components/Dashboard/Production/Jobs/JobInfo.jsx'
import Filaments from './components/Dashboard/Management/Filaments'
import FilamentInfo from './components/Dashboard/Management/Filaments/FilamentInfo.jsx'
@ -48,12 +48,17 @@ import { SocketProvider } from './components/Dashboard/context/SocketContext.js'
import { AuthProvider } from './components/Dashboard/context/AuthContext.js'
import { SpotlightProvider } from './components/Dashboard/context/SpotlightContext.js'
import StockEvents from './components/Dashboard/Inventory/StockEvents.jsx'
import Settings from './components/Dashboard/Management/Settings'
import AuditLogs from './components/Dashboard/Management/AuditLogs.jsx'
import {
ThemeProvider,
useThemeContext
} from './components/Dashboard/context/ThemeContext'
import AppError from './components/App/AppError'
import NoteTypes from './components/Dashboard/Management/NoteTypes.jsx'
import NoteTypeInfo from './components/Dashboard/Management/NoteTypes/NoteTypeInfo.jsx'
const AppContent = () => {
const { themeConfig } = useThemeContext()
@ -97,14 +102,8 @@ const AppContent = () => {
path='production/printers/info'
element={<PrinterInfo />}
/>
<Route
path='production/printjobs'
element={<PrintJobs />}
/>
<Route
path='production/printjobs/info'
element={<PrintJobInfo />}
/>
<Route path='production/jobs' element={<Jobs />} />
<Route path='production/jobs/info' element={<JobInfo />} />
<Route
path='production/gcodefiles'
element={<GCodeFiles />}
@ -168,7 +167,19 @@ const AppContent = () => {
path='management/materials'
element={<Materials />}
/>
<Route
path='management/notetypes'
element={<NoteTypes />}
/>
<Route
path='management/notetypes/info'
element={<NoteTypeInfo />}
/>
<Route path='management/settings' element={<Settings />} />
<Route
path='management/auditlogs'
element={<AuditLogs />}
/>
</Route>
<Route
path='*'

Binary file not shown.

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 64 64"><path d="M0 0h54.469v65.719H0z" style="fill-opacity:0" transform="translate(32 25.39)scale(.58749)"/><path d="M27.141 65.719c.64 0 1.547-.266 2.359-.688 18.313-9.531 24.781-14.5 24.781-25.89V16.016c0-4.453-1.484-6.25-5.375-7.922-3.093-1.297-14.453-5.125-17.359-6.078-1.375-.422-3.047-.688-4.406-.688-1.36 0-3.032.313-4.391.688-2.906.812-14.281 4.797-17.375 6.078C1.5 9.75 0 11.563 0 16.016v23.125c0 11.39 6.484 16.343 24.781 25.89.828.422 1.719.688 2.36.688m0-8.172c-.36 0-.703-.125-1.563-.656C11.563 48.25 7.469 46.297 7.469 37.953v-20.89c0-1.172.25-1.672 1.156-2.032 4.406-1.765 12.969-4.593 16.203-5.828 1.063-.344 1.688-.484 2.313-.484s1.234.156 2.312.484c3.235 1.235 11.766 4.172 16.219 5.828.875.344 1.141.86 1.141 2.032v20.89c0 8.485-4.532 10.875-18.11 18.938-.844.515-1.203.656-1.562.656" style="fill-rule:nonzero" transform="translate(32 25.39)scale(.58749)"/><path d="M10.65 56.399c0-1.97-1.55-3.58-3.569-3.58-1.933 0-3.52 1.631-3.52 3.58 0 1.941 1.587 3.55 3.52 3.55 1.976 0 3.569-1.609 3.569-3.55m20.497-2.611H17.05c-1.617 0-2.824 1.116-2.824 2.611 0 1.489 1.207 2.636 2.824 2.636h18.375c-2.087-1.667-3.47-3.326-4.278-5.247M10.65 44.582c0-1.965-1.55-3.55-3.569-3.55-1.933 0-3.52 1.601-3.52 3.55a3.533 3.533 0 0 0 3.52 3.525c1.976 0 3.569-1.584 3.569-3.525m19.592-2.612H17.05c-1.617 0-2.824 1.117-2.824 2.612 0 1.494 1.207 2.636 2.824 2.636h13.192zm0-6.629H11.704C5.368 35.341 3 32.505 3 26.425V12.967c0-6.085 2.368-8.916 8.704-8.916h40.592c6.336 0 8.704 2.831 8.704 8.916v13.458a20 20 0 0 1-.062 1.605c-1.502-.56-3.758-1.345-5.801-2.043v-13.18c0-2.394-.819-3.47-3.189-3.47H12.052c-2.37 0-3.189 1.076-3.189 3.47v13.778c0 2.395.819 3.47 3.189 3.47h19.269c-.755.839-1.079 1.94-1.079 3.611z" style="fill-rule:nonzero"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.587492,0,0,0.587492,32,25.3907)">
<rect x="0" y="0" width="54.469" height="65.719" style="fill-opacity:0;"/>
<path d="M27.141,65.719C27.781,65.719 28.688,65.453 29.5,65.031C47.813,55.5 54.281,50.531 54.281,39.141L54.281,16.016C54.281,11.563 52.797,9.766 48.906,8.094C45.813,6.797 34.453,2.969 31.547,2.016C30.172,1.594 28.5,1.328 27.141,1.328C25.781,1.328 24.109,1.641 22.75,2.016C19.844,2.828 8.469,6.813 5.375,8.094C1.5,9.75 0,11.563 0,16.016L0,39.141C0,50.531 6.484,55.484 24.781,65.031C25.609,65.453 26.5,65.719 27.141,65.719ZM27.141,57.547C26.781,57.547 26.438,57.422 25.578,56.891C11.563,48.25 7.469,46.297 7.469,37.953L7.469,17.063C7.469,15.891 7.719,15.391 8.625,15.031C13.031,13.266 21.594,10.438 24.828,9.203C25.891,8.859 26.516,8.719 27.141,8.719C27.766,8.719 28.375,8.875 29.453,9.203C32.688,10.438 41.219,13.375 45.672,15.031C46.547,15.375 46.813,15.891 46.813,17.063L46.813,37.953C46.813,46.438 42.281,48.828 28.703,56.891C27.859,57.406 27.5,57.547 27.141,57.547Z" style="fill-rule:nonzero;"/>
</g>
<path d="M10.65,56.399C10.65,54.429 9.1,52.819 7.081,52.819C5.148,52.819 3.561,54.45 3.561,56.399C3.561,58.34 5.148,59.949 7.081,59.949C9.057,59.949 10.65,58.34 10.65,56.399ZM31.147,53.788L17.05,53.788C15.433,53.788 14.226,54.904 14.226,56.399C14.226,57.888 15.433,59.035 17.05,59.035L35.425,59.035C33.338,57.368 31.955,55.709 31.147,53.788ZM10.65,44.582C10.65,42.617 9.1,41.032 7.081,41.032C5.148,41.032 3.561,42.633 3.561,44.582C3.561,46.523 5.148,48.107 7.081,48.107C9.057,48.107 10.65,46.523 10.65,44.582ZM30.242,41.97L17.05,41.97C15.433,41.97 14.226,43.087 14.226,44.582C14.226,46.076 15.433,47.218 17.05,47.218L30.242,47.218L30.242,41.97ZM30.242,35.341L11.704,35.341C5.368,35.341 3,32.505 3,26.425L3,12.967C3,6.882 5.368,4.051 11.704,4.051L52.296,4.051C58.632,4.051 61,6.882 61,12.967L61,26.425C61,26.988 60.98,27.522 60.938,28.03C59.436,27.47 57.18,26.685 55.137,25.987L55.137,12.807C55.137,10.413 54.318,9.337 51.948,9.337L12.052,9.337C9.682,9.337 8.863,10.413 8.863,12.807L8.863,26.585C8.863,28.98 9.682,30.055 12.052,30.055L31.321,30.055C30.566,30.894 30.242,31.995 30.242,33.666L30.242,35.341Z" style="fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" clip-rule="evenodd" viewBox="0 0 65 67"><path fill-rule="nonzero" d="m24.017 36.143-15.42 8.815c-.116.058-.204.146-.204.29s.088.204.204.291l22.126 12.648c.495.261.901.407 1.309.407.406 0 .814-.146 1.279-.407l22.125-12.648c.145-.087.233-.145.233-.29s-.088-.233-.233-.291L40.027 36.15l4.862-2.822 13.571 7.88c2.355 1.395 3.285 2.441 3.285 4.04s-.93 2.647-3.285 4.013L35.723 62.49c-1.395.814-2.528 1.191-3.691 1.191-1.193 0-2.297-.377-3.693-1.191L5.573 49.26c-2.354-1.365-3.256-2.412-3.256-4.011s.902-2.646 3.256-4.042l13.59-7.88z"/><path fill-rule="nonzero" d="M32.032 39.84c1.163 0 2.296-.377 3.691-1.191l22.737-13.2c2.355-1.395 3.285-2.442 3.285-4.041s-.93-2.646-3.285-4.013L35.723 4.167c-1.395-.814-2.528-1.193-3.691-1.193-1.193 0-2.297.379-3.693 1.193L5.573 17.395c-2.354 1.367-3.256 2.413-3.256 4.013s.902 2.646 3.256 4.041l22.766 13.2c1.396.814 2.5 1.191 3.693 1.191m0-5.087c-.408 0-.814-.116-1.31-.407L8.598 21.698c-.116-.058-.204-.145-.204-.29s.088-.204.204-.291L30.723 8.47c.495-.262.901-.408 1.309-.408.406 0 .814.146 1.279.408l22.125 12.647c.145.087.233.146.233.29s-.088.233-.233.291L33.311 34.346c-.465.29-.873.407-1.28.407"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 65 67"><path d="M60.856 46.701c2.655 1.536 3.818 2.85 3.818 4.813 0 1.984-1.163 3.297-3.818 4.839l-24.28 14.081c-1.63.948-2.916 1.416-4.239 1.416-1.349 0-2.604-.468-4.239-1.416L3.792 56.353C1.137 54.811 0 53.498 0 51.514c0-1.963 1.137-3.277 3.792-4.813l4.259-2.468 6.08 3.528-6.087 3.472c-.125.063-.208.156-.208.281 0 .145.083.239.208.302L31.043 64.94c.498.259.89.404 1.294.404s.796-.145 1.268-.404l22.999-13.124c.151-.063.234-.157.234-.302 0-.125-.083-.218-.234-.281l-6.081-3.469 6.078-3.531z" style="fill-rule:nonzero" transform="translate(3.031 .598)scale(.89681)"/><path d="M60.856 32.109c2.655 1.562 3.818 2.856 3.818 4.839s-1.163 3.271-3.818 4.813l-24.28 14.107c-1.63.948-2.916 1.416-4.239 1.416-1.349 0-2.604-.468-4.239-1.416L3.792 41.761C1.137 40.219 0 38.931 0 36.948s1.137-3.277 3.792-4.839l5.022-2.91 6.097 3.533-6.867 3.909c-.125.068-.208.157-.208.307 0 .145.083.213.208.301l22.999 13.099c.498.259.89.404 1.294.404s.796-.145 1.268-.404l22.999-13.099c.151-.088.234-.156.234-.301 0-.15-.083-.239-.234-.307l-6.861-3.906 6.097-3.536z" style="fill-rule:nonzero" transform="translate(3.031 .598)scale(.89681)"/><path d="M32.337 41.788c1.323 0 2.609-.463 4.239-1.417l24.28-14.08c2.655-1.537 3.818-2.856 3.818-4.839 0-1.958-1.163-3.271-3.818-4.813L36.576 2.558c-1.63-.948-2.916-1.416-4.239-1.416-1.349 0-2.604.468-4.239 1.416L3.792 16.639C1.137 18.181 0 19.494 0 21.452c0 1.983 1.137 3.302 3.792 4.839l24.306 14.08c1.635.954 2.89 1.417 4.239 1.417m0-6.501c-.404 0-.796-.145-1.294-.409L8.044 21.759c-.125-.068-.208-.156-.208-.307 0-.125.083-.213.208-.281L31.043 8.052c.498-.265.89-.404 1.294-.404s.796.139 1.268.404l22.999 13.119c.151.068.234.156.234.281 0 .151-.083.239-.234.307L33.605 34.878c-.472.264-.864.409-1.268.409" style="fill-rule:nonzero" transform="translate(3.031 .598)scale(.89681)"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -1,12 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" version="1.1" viewBox="0 0 65 67" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(.09375 1.5681)">
<g transform="matrix(.93038 0 0 .93038 2.2234 .097899)">
<path d="m23.324 37.057-16.574 9.474c-0.125 0.063-0.219 0.157-0.219 0.313s0.094 0.219 0.219 0.312l23.781 13.594c0.532 0.281 0.969 0.438 1.407 0.438 0.437 0 0.875-0.157 1.375-0.438l23.781-13.594c0.156-0.093 0.25-0.156 0.25-0.312s-0.094-0.25-0.25-0.313l-16.562-9.467 5.225-3.033 14.587 8.469c2.531 1.5 3.531 2.625 3.531 4.344s-1 2.844-3.531 4.312l-24.438 14.219c-1.5 0.875-2.718 1.281-3.968 1.281-1.282 0-2.469-0.406-3.969-1.281l-24.469-14.219c-2.531-1.468-3.5-2.593-3.5-4.312s0.969-2.844 3.5-4.344l14.606-8.469 5.218 3.026z" fill-rule="nonzero"/>
</g>
<g transform="matrix(.93038 0 0 .93038 2.2234 .097899)">
<path d="m31.938 41.031c1.25 0 2.468-0.406 3.968-1.281l24.438-14.187c2.531-1.5 3.531-2.625 3.531-4.344s-1-2.844-3.531-4.313l-24.438-14.218c-1.5-0.875-2.718-1.282-3.968-1.282-1.282 0-2.469 0.407-3.969 1.282l-24.469 14.218c-2.531 1.469-3.5 2.594-3.5 4.313s0.969 2.844 3.5 4.344l24.469 14.187c1.5 0.875 2.687 1.281 3.969 1.281zm0-5.468c-0.438 0-0.875-0.125-1.407-0.438l-23.781-13.594c-0.125-0.062-0.219-0.156-0.219-0.312s0.094-0.219 0.219-0.313l23.781-13.593c0.532-0.282 0.969-0.438 1.407-0.438 0.437 0 0.875 0.156 1.375 0.438l23.781 13.593c0.156 0.094 0.25 0.157 0.25 0.313s-0.094 0.25-0.25 0.312l-23.781 13.594c-0.5 0.313-0.938 0.438-1.375 0.438z" fill-rule="nonzero"/>
</g>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 65 67" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.89681,0,0,0.89681,3.03125,0.598183)">
<path d="M60.856,46.701C63.511,48.237 64.674,49.551 64.674,51.514C64.674,53.498 63.511,54.811 60.856,56.353L36.576,70.434C34.946,71.382 33.66,71.85 32.337,71.85C30.988,71.85 29.733,71.382 28.098,70.434L3.792,56.353C1.137,54.811 0,53.498 0,51.514C0,49.551 1.137,48.237 3.792,46.701L8.051,44.233L14.131,47.761L8.044,51.233C7.919,51.296 7.836,51.389 7.836,51.514C7.836,51.659 7.919,51.753 8.044,51.816L31.043,64.94C31.541,65.199 31.933,65.344 32.337,65.344C32.741,65.344 33.133,65.199 33.605,64.94L56.604,51.816C56.755,51.753 56.838,51.659 56.838,51.514C56.838,51.389 56.755,51.296 56.604,51.233L50.523,47.764L56.601,44.233L60.856,46.701Z" style="fill-rule:nonzero;"/>
<path d="M60.856,32.109C63.511,33.671 64.674,34.965 64.674,36.948C64.674,38.931 63.511,40.219 60.856,41.761L36.576,55.868C34.946,56.816 33.66,57.284 32.337,57.284C30.988,57.284 29.733,56.816 28.098,55.868L3.792,41.761C1.137,40.219 0,38.931 0,36.948C0,34.965 1.137,33.671 3.792,32.109L8.814,29.199L14.911,32.732L8.044,36.641C7.919,36.709 7.836,36.798 7.836,36.948C7.836,37.093 7.919,37.161 8.044,37.249L31.043,50.348C31.541,50.607 31.933,50.752 32.337,50.752C32.741,50.752 33.133,50.607 33.605,50.348L56.604,37.249C56.755,37.161 56.838,37.093 56.838,36.948C56.838,36.798 56.755,36.709 56.604,36.641L49.743,32.735L55.84,29.199L60.856,32.109Z" style="fill-rule:nonzero;"/>
<path d="M32.337,41.788C33.66,41.788 34.946,41.325 36.576,40.371L60.856,26.291C63.511,24.754 64.674,23.435 64.674,21.452C64.674,19.494 63.511,18.181 60.856,16.639L36.576,2.558C34.946,1.61 33.66,1.142 32.337,1.142C30.988,1.142 29.733,1.61 28.098,2.558L3.792,16.639C1.137,18.181 0,19.494 0,21.452C0,23.435 1.137,24.754 3.792,26.291L28.098,40.371C29.733,41.325 30.988,41.788 32.337,41.788ZM32.337,35.287C31.933,35.287 31.541,35.142 31.043,34.878L8.044,21.759C7.919,21.691 7.836,21.603 7.836,21.452C7.836,21.327 7.919,21.239 8.044,21.171L31.043,8.052C31.541,7.787 31.933,7.648 32.337,7.648C32.741,7.648 33.133,7.787 33.605,8.052L56.604,21.171C56.755,21.239 56.838,21.327 56.838,21.452C56.838,21.603 56.755,21.691 56.604,21.759L33.605,34.878C33.133,35.142 32.741,35.287 32.337,35.287Z" style="fill-rule:nonzero;"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 64 64"><path d="M32.055 58.515H11.634C5.921 58.515 3 55.635 3 49.953V14.047c0-5.682 2.921-8.562 8.634-8.562h40.743c5.722 0 8.623 2.928 8.623 8.562v20.64a19 19 0 0 0-3.626-2.841v-8.54c0-3.149-1.728-4.791-4.77-4.791H11.376c-3.097 0-4.75 1.642-4.75 4.791v26.793c0 3.169 1.653 4.79 4.75 4.79h18.767a18.8 18.8 0 0 0 1.912 3.626" style="fill-rule:nonzero"/><path d="M29.204 44.266a19 19 0 0 0-.35 2.886h-12.79c-.866 0-1.482-.617-1.482-1.413 0-.816.616-1.473 1.482-1.473zm4.588-9.006a19 19 0 0 0-2.18 2.885H16.064c-.866 0-1.482-.616-1.482-1.412 0-.837.616-1.473 1.482-1.473zm-17.728-6.122h31.942c.825 0 1.462-.656 1.462-1.472 0-.796-.637-1.413-1.462-1.413H16.064c-.866 0-1.482.617-1.482 1.413 0 .816.616 1.472 1.482 1.472" style="fill-rule:nonzero"/><path d="M36.234 67.079c.657 0 1.313-.016 1.954-.078l1.64 3.094c.547 1.062 1.563 1.531 2.703 1.343 1.172-.172 1.875-1.015 2.047-2.187l.516-3.453a28 28 0 0 0 3.687-1.328l2.578 2.328c.86.812 2 .89 3 .343 1.032-.578 1.422-1.609 1.188-2.781l-.703-3.406c1.062-.766 2.047-1.625 3-2.516l3.219 1.329c1.109.437 2.203.156 2.937-.735.766-.875.813-1.969.188-3l-1.829-2.969a40 40 0 0 0 1.969-3.375l3.453.125c1.188.032 2.125-.593 2.516-1.687.406-1.063.094-2.141-.859-2.859l-2.75-2.157c.328-1.25.546-2.547.703-3.875l3.297-1.078c1.125-.359 1.812-1.25 1.812-2.406s-.687-2.063-1.812-2.422l-3.297-1.062c-.157-1.344-.375-2.625-.703-3.875l2.734-2.125c.984-.719 1.25-1.829.859-2.922-.375-1.11-1.312-1.641-2.515-1.625l-3.469.062a31 31 0 0 0-1.938-3.375l1.829-2.906c.656-1.031.546-2.172-.188-3.047-.734-.906-1.812-1.109-2.922-.687l-3.25 1.281a27 27 0 0 0-2.984-2.516l.734-3.391c.25-1.171-.203-2.203-1.203-2.765-1.016-.625-2.094-.422-3 .359l-2.609 2.281a34 34 0 0 0-3.672-1.343l-.485-3.438c-.14-1.172-.937-1.969-2.062-2.187-1.156-.219-2.109.328-2.688 1.359L38.188 4.47a39 39 0 0 0-3.891 0l-1.641-3.063C32.094.345 31.078-.14 29.969.048c-1.172.187-1.891 1.015-2.063 2.187l-.531 3.438c-1.266.39-2.5.843-3.672 1.359l-2.562-2.328c-.875-.828-2.016-.906-3.016-.344-1.031.563-1.406 1.594-1.172 2.766l.672 3.422a30 30 0 0 0-3 2.5l-3.203-1.313c-1.109-.453-2.203-.156-2.953.735-.766.859-.797 1.968-.156 3l1.828 2.953c-.735 1.078-1.375 2.234-1.969 3.39l-3.453-.125c-1.203-.046-2.141.594-2.516 1.672-.437 1.078-.094 2.141.844 2.875l2.734 2.157a28 28 0 0 0-.672 3.875l-3.312 1.062C.656 33.688 0 34.595 0 35.751s.656 2.047 1.797 2.406l3.312 1.078a28 28 0 0 0 .672 3.875l-2.734 2.11c-.953.734-1.219 1.843-.844 2.937s1.313 1.625 2.516 1.61l3.469-.063a29 29 0 0 0 1.953 3.375l-1.844 2.922c-.656 1.031-.531 2.172.187 3.031.75.922 1.813 1.125 2.938.688l3.219-1.297a31 31 0 0 0 2.984 2.547l-.719 3.375c-.25 1.171.219 2.203 1.219 2.781 1 .609 2.094.437 2.984-.36l2.61-2.296c1.172.531 2.406.968 3.656 1.328l.5 3.437c.156 1.188.953 2 2.078 2.203 1.141.235 2.094-.312 2.672-1.359l1.672-3.078c.656.062 1.297.078 1.937.078m0-7.547c-13.125 0-23.781-10.656-23.781-23.781 0-13.156 10.656-23.781 23.781-23.781 13.157 0 23.782 10.625 23.782 23.781 0 13.125-10.625 23.781-23.782 23.781m-6.906-29.187 5.438-3.375-9.641-16.594-5.75 2.859zm15.094 8.578h19.297v-6.469H44.438zm-9.703 5.625-5.344-3.5-10.344 17 5.672 2.984zm1.531.844c5.344 0 9.656-4.313 9.656-9.641a9.64 9.64 0 0 0-9.656-9.656c-5.328 0-9.641 4.312-9.641 9.656a9.637 9.637 0 0 0 9.641 9.641m0-6.047a3.595 3.595 0 0 1-3.594-3.594c0-2 1.61-3.609 3.594-3.609a3.6 3.6 0 0 1 3.609 3.609 3.6 3.6 0 0 1-3.609 3.594" style="fill-rule:nonzero" transform="translate(31.457 32)scale(.44724)"/><path d="M0 0h62.072v56.664H0z" style="fill-opacity:0" transform="translate(-14.402 -5.679)"/></svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.913764,0,0,0.913764,2.84521,2.73099)">
<g transform="matrix(1.09437,0,0,1.09437,-3.11372,-2.98872)">
<path d="M32.055,58.515L11.634,58.515C5.921,58.515 3,55.635 3,49.953L3,14.047C3,8.365 5.921,5.485 11.634,5.485L52.377,5.485C58.099,5.485 61,8.413 61,14.047L61,34.687C59.915,33.599 58.697,32.643 57.374,31.846L57.374,23.306C57.374,20.157 55.646,18.515 52.604,18.515L11.376,18.515C8.279,18.515 6.626,20.157 6.626,23.306L6.626,50.099C6.626,53.268 8.279,54.889 11.376,54.889L30.143,54.889C30.649,56.173 31.293,57.388 32.055,58.515Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(1.09437,0,0,1.09437,-3.11372,-2.98872)">
<path d="M29.204,44.266C29.016,45.205 28.897,46.169 28.854,47.152L16.064,47.152C15.198,47.152 14.582,46.535 14.582,45.739C14.582,44.923 15.198,44.266 16.064,44.266L29.204,44.266ZM33.792,35.26C32.977,36.147 32.246,37.113 31.612,38.145L16.064,38.145C15.198,38.145 14.582,37.529 14.582,36.733C14.582,35.896 15.198,35.26 16.064,35.26L33.792,35.26ZM16.064,29.138L48.006,29.138C48.831,29.138 49.468,28.482 49.468,27.666C49.468,26.87 48.831,26.253 48.006,26.253L16.064,26.253C15.198,26.253 14.582,26.87 14.582,27.666C14.582,28.482 15.198,29.138 16.064,29.138Z" style="fill-rule:nonzero;"/>
</g>
</g>
<g transform="matrix(0.447244,0,0,0.447244,31.4574,32)">
<path d="M36.234,67.079C36.891,67.079 37.547,67.063 38.188,67.001L39.828,70.095C40.375,71.157 41.391,71.626 42.531,71.438C43.703,71.266 44.406,70.423 44.578,69.251L45.094,65.798C46.391,65.438 47.594,64.985 48.781,64.47L51.359,66.798C52.219,67.61 53.359,67.688 54.359,67.141C55.391,66.563 55.781,65.532 55.547,64.36L54.844,60.954C55.906,60.188 56.891,59.329 57.844,58.438L61.063,59.767C62.172,60.204 63.266,59.923 64,59.032C64.766,58.157 64.813,57.063 64.188,56.032L62.359,53.063C63.078,51.97 63.719,50.845 64.328,49.688L67.781,49.813C68.969,49.845 69.906,49.22 70.297,48.126C70.703,47.063 70.391,45.985 69.438,45.267L66.688,43.11C67.016,41.86 67.234,40.563 67.391,39.235L70.688,38.157C71.813,37.798 72.5,36.907 72.5,35.751C72.5,34.595 71.813,33.688 70.688,33.329L67.391,32.267C67.234,30.923 67.016,29.642 66.688,28.392L69.422,26.267C70.406,25.548 70.672,24.438 70.281,23.345C69.906,22.235 68.969,21.704 67.766,21.72L64.297,21.782C63.719,20.626 63.078,19.485 62.359,18.407L64.188,15.501C64.844,14.47 64.734,13.329 64,12.454C63.266,11.548 62.188,11.345 61.078,11.767L57.828,13.048C56.891,12.126 55.891,11.298 54.844,10.532L55.578,7.141C55.828,5.97 55.375,4.938 54.375,4.376C53.359,3.751 52.281,3.954 51.375,4.735L48.766,7.016C47.594,6.501 46.359,6.063 45.094,5.673L44.609,2.235C44.469,1.063 43.672,0.266 42.547,0.048C41.391,-0.171 40.438,0.376 39.859,1.407L38.188,4.47C37.547,4.438 36.891,4.423 36.234,4.423C35.594,4.423 34.953,4.438 34.297,4.47L32.656,1.407C32.094,0.345 31.078,-0.14 29.969,0.048C28.797,0.235 28.078,1.063 27.906,2.235L27.375,5.673C26.109,6.063 24.875,6.516 23.703,7.032L21.141,4.704C20.266,3.876 19.125,3.798 18.125,4.36C17.094,4.923 16.719,5.954 16.953,7.126L17.625,10.548C16.578,11.313 15.578,12.142 14.625,13.048L11.422,11.735C10.313,11.282 9.219,11.579 8.469,12.47C7.703,13.329 7.672,14.438 8.313,15.47L10.141,18.423C9.406,19.501 8.766,20.657 8.172,21.813L4.719,21.688C3.516,21.642 2.578,22.282 2.203,23.36C1.766,24.438 2.109,25.501 3.047,26.235L5.781,28.392C5.469,29.642 5.25,30.923 5.109,32.267L1.797,33.329C0.656,33.688 0,34.595 0,35.751C0,36.907 0.656,37.798 1.797,38.157L5.109,39.235C5.25,40.563 5.469,41.86 5.781,43.11L3.047,45.22C2.094,45.954 1.828,47.063 2.203,48.157C2.578,49.251 3.516,49.782 4.719,49.767L8.188,49.704C8.766,50.86 9.406,52.001 10.141,53.079L8.297,56.001C7.641,57.032 7.766,58.173 8.484,59.032C9.234,59.954 10.297,60.157 11.422,59.72L14.641,58.423C15.578,59.345 16.594,60.188 17.625,60.97L16.906,64.345C16.656,65.516 17.125,66.548 18.125,67.126C19.125,67.735 20.219,67.563 21.109,66.766L23.719,64.47C24.891,65.001 26.125,65.438 27.375,65.798L27.875,69.235C28.031,70.423 28.828,71.235 29.953,71.438C31.094,71.673 32.047,71.126 32.625,70.079L34.297,67.001C34.953,67.063 35.594,67.079 36.234,67.079ZM36.234,59.532C23.109,59.532 12.453,48.876 12.453,35.751C12.453,22.595 23.109,11.97 36.234,11.97C49.391,11.97 60.016,22.595 60.016,35.751C60.016,48.876 49.391,59.532 36.234,59.532ZM29.328,30.345L34.766,26.97L25.125,10.376L19.375,13.235L29.328,30.345ZM44.422,38.923L63.719,38.923L63.719,32.454L44.438,32.454L44.422,38.923ZM34.719,44.548L29.375,41.048L19.031,58.048L24.703,61.032L34.719,44.548ZM36.25,45.392C41.594,45.392 45.906,41.079 45.906,35.751C45.906,30.407 41.594,26.095 36.25,26.095C30.922,26.095 26.609,30.407 26.609,35.751C26.609,41.079 30.922,45.392 36.25,45.392ZM36.25,39.345C34.266,39.345 32.656,37.735 32.656,35.751C32.656,33.751 34.266,32.142 36.25,32.142C38.25,32.142 39.859,33.751 39.859,35.751C39.859,37.735 38.25,39.345 36.25,39.345Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,-14.4016,-5.67889)">
<rect x="0" y="0" width="62.072" height="56.664" style="fill-opacity:0;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 64 64"><path d="M30.765 67.814h6.341c2.58 0 4.624-1.604 5.195-4.074l1.27-5.529.832-.287 4.823 2.956c2.178 1.334 4.724 1.014 6.561-.823l4.392-4.355c1.859-1.854 2.146-4.431.812-6.572l-3.008-4.787.308-.774 5.517-1.308c2.445-.57 4.063-2.64 4.063-5.195v-6.165c0-2.549-1.593-4.593-4.063-5.195l-5.466-1.333-.333-.825 3.007-4.787c1.335-2.141 1.073-4.693-.812-6.572l-4.391-4.381c-1.812-1.811-4.358-2.169-6.535-.823l-4.824 2.956-.883-.339-1.27-5.528C41.73 1.598 39.686 0 37.106 0h-6.341c-2.581 0-4.624 1.609-5.195 4.074l-1.296 5.528-.883.339-4.798-2.956c-2.177-1.334-4.749-.988-6.56.823l-4.367 4.381c-1.885 1.879-2.172 4.431-.812 6.572l2.982 4.787-.307.825-5.467 1.333C1.581 26.314 0 28.352 0 30.901v6.165c0 2.555 1.618 4.625 4.062 5.195l5.518 1.308.282.774L6.88 49.13c-1.36 2.141-1.048 4.718.812 6.572l4.366 4.355c1.837 1.837 4.409 2.157 6.586.823l4.799-2.956.831.287 1.296 5.529c.571 2.47 2.614 4.074 5.195 4.074m.776-5.663c-.526 0-.796-.224-.884-.707l-1.853-7.7c-1.979-.446-3.878-1.246-5.391-2.214l-6.767 4.154c-.37.275-.79.25-1.128-.145l-3.334-3.328c-.343-.343-.358-.713-.119-1.128l4.171-6.716c-.86-1.493-1.695-3.369-2.172-5.349l-7.7-1.821c-.483-.088-.707-.358-.707-.884v-4.684c0-.551.199-.79.707-.884l7.674-1.847c.489-2.076 1.41-4.021 2.146-5.374l-4.145-6.716c-.264-.441-.255-.81.089-1.18l3.364-3.276c.364-.364.707-.389 1.154-.145l6.71 4.077c1.417-.846 3.481-1.695 5.468-2.22l1.833-7.694c.088-.483.358-.708.884-.708h4.789c.526 0 .79.225.858.708l1.884 7.745c2.027.494 3.844 1.315 5.391 2.194l6.72-4.091c.477-.244.789-.224 1.185.145l3.333 3.276c.369.37.358.739.093 1.18l-4.133 6.705c.756 1.347 1.657 3.298 2.14 5.363l7.705 1.858c.483.094.708.333.708.884v4.684c0 .526-.256.796-.708.884l-7.73 1.832a20 20 0 0 1-2.167 5.338l4.154 6.705c.245.415.256.784-.114 1.128l-3.307 3.328c-.37.395-.764.415-1.159.145l-6.74-4.143a17.3 17.3 0 0 1-5.371 2.214l-1.884 7.7c-.068.483-.332.707-.858.707zm2.386-16.394c6.56 0 11.85-5.29 11.85-11.85s-5.29-11.85-11.85-11.85c-6.555 0-11.85 5.29-11.85 11.85s5.295 11.85 11.85 11.85m0-5.361a6.483 6.483 0 0 1-6.489-6.489 6.483 6.483 0 0 1 6.489-6.489 6.48 6.48 0 0 1 6.489 6.489 6.48 6.48 0 0 1-6.489 6.489" style="fill-rule:nonzero" transform="translate(1 1.026)scale(.9135)"/></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.913503,0,0,0.913503,1,1.02599)">
<path d="M30.765,67.814L37.106,67.814C39.686,67.814 41.73,66.21 42.301,63.74L43.571,58.211L44.403,57.924L49.226,60.88C51.404,62.214 53.95,61.894 55.787,60.057L60.179,55.702C62.038,53.848 62.325,51.271 60.991,49.13L57.983,44.343L58.291,43.569L63.808,42.261C66.253,41.691 67.871,39.621 67.871,37.066L67.871,30.901C67.871,28.352 66.278,26.308 63.808,25.706L58.342,24.373L58.009,23.548L61.016,18.761C62.351,16.62 62.089,14.068 60.204,12.189L55.813,7.808C54.001,5.997 51.455,5.639 49.278,6.985L44.454,9.941L43.571,9.602L42.301,4.074C41.73,1.598 39.686,0 37.106,0L30.765,0C28.184,0 26.141,1.609 25.57,4.074L24.274,9.602L23.391,9.941L18.593,6.985C16.416,5.651 13.844,5.997 12.033,7.808L7.666,12.189C5.781,14.068 5.494,16.62 6.854,18.761L9.836,23.548L9.529,24.373L4.062,25.706C1.581,26.314 0,28.352 0,30.901L0,37.066C0,39.621 1.618,41.691 4.062,42.261L9.58,43.569L9.862,44.343L6.88,49.13C5.52,51.271 5.832,53.848 7.692,55.702L12.058,60.057C13.895,61.894 16.467,62.214 18.644,60.88L23.443,57.924L24.274,58.211L25.57,63.74C26.141,66.21 28.184,67.814 30.765,67.814ZM31.541,62.151C31.015,62.151 30.745,61.927 30.657,61.444L28.804,53.744C26.825,53.298 24.926,52.498 23.413,51.53L16.646,55.684C16.276,55.959 15.856,55.934 15.518,55.539L12.184,52.211C11.841,51.868 11.826,51.498 12.065,51.083L16.236,44.367C15.376,42.874 14.541,40.998 14.064,39.018L6.364,37.197C5.881,37.109 5.657,36.839 5.657,36.313L5.657,31.629C5.657,31.078 5.856,30.839 6.364,30.745L14.038,28.898C14.527,26.822 15.448,24.877 16.184,23.524L12.039,16.808C11.775,16.367 11.784,15.998 12.128,15.628L15.492,12.352C15.856,11.988 16.199,11.963 16.646,12.207L23.356,16.284C24.773,15.438 26.837,14.589 28.824,14.064L30.657,6.37C30.745,5.887 31.015,5.662 31.541,5.662L36.33,5.662C36.856,5.662 37.12,5.887 37.188,6.37L39.072,14.115C41.099,14.609 42.916,15.43 44.463,16.309L51.183,12.218C51.66,11.974 51.972,11.994 52.368,12.363L55.701,15.639C56.07,16.009 56.059,16.378 55.794,16.819L51.661,23.524C52.417,24.871 53.318,26.822 53.801,28.887L61.506,30.745C61.989,30.839 62.214,31.078 62.214,31.629L62.214,36.313C62.214,36.839 61.958,37.109 61.506,37.197L53.776,39.029C53.298,40.992 52.489,42.891 51.609,44.367L55.763,51.072C56.008,51.487 56.019,51.856 55.649,52.2L52.342,55.528C51.972,55.923 51.578,55.943 51.183,55.673L44.443,51.53C42.903,52.498 41.113,53.276 39.072,53.744L37.188,61.444C37.12,61.927 36.856,62.151 36.33,62.151L31.541,62.151ZM33.927,45.757C40.487,45.757 45.777,40.467 45.777,33.907C45.777,27.347 40.487,22.057 33.927,22.057C27.372,22.057 22.077,27.347 22.077,33.907C22.077,40.467 27.372,45.757 33.927,45.757ZM33.927,40.396C30.341,40.396 27.438,37.498 27.438,33.907C27.438,30.316 30.341,27.418 33.927,27.418C37.518,27.418 40.416,30.316 40.416,33.907C40.416,37.498 37.518,40.396 33.927,40.396Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 306 113" version="1.1" xmlns="http://www.w3.org/2000/svg" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1.0156,0,0,1.0156,-2.2117e-16,0)">
<g transform="matrix(0.67805,0,0,0.67805,-10.706,-23.499)">
<path d="M15.789,187.103L15.789,45C15.789,43.003 16.606,41.234 18.239,39.691C19.873,38.149 21.688,37.377 23.684,37.377L101.541,37.377C103.538,37.377 105.353,38.149 106.986,39.691C108.619,41.234 109.436,43.003 109.436,45L109.436,63.239C109.436,65.235 108.619,67.005 106.986,68.548C105.353,70.09 103.538,70.862 101.541,70.862L52.268,70.862L52.268,106.796L95.825,106.796C97.821,106.796 99.636,107.567 101.269,109.11C102.903,110.652 103.719,112.422 103.719,114.418L103.719,132.658C103.719,134.654 102.903,136.423 101.269,137.966C99.636,139.509 97.821,140.28 95.825,140.28L52.268,140.28L52.268,187.103C52.268,189.1 51.497,190.915 49.954,192.548C48.411,194.181 46.642,194.998 44.646,194.998L23.412,194.998C21.415,194.998 19.646,194.181 18.103,192.548C16.561,190.915 15.789,189.1 15.789,187.103Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.67805,0,0,0.67805,-317.412,-23.499)">
<path d="M656.07,198C633.384,198 615.462,190.241 602.305,174.724C589.147,159.207 582.568,139.743 582.568,116.331C582.568,92.919 589.147,73.455 602.305,57.938C615.462,42.421 633.384,34.662 656.07,34.662C695.089,34.662 718.773,52.811 727.122,89.108C726.759,91.104 725.806,92.828 724.263,94.28C722.721,95.732 720.951,96.458 718.955,96.458L695.543,96.458C692.821,96.458 690.734,95.097 689.282,92.375C684.926,76.222 673.856,68.146 656.07,68.146C643.91,68.146 634.927,72.593 629.119,81.485C623.312,90.378 620.408,101.993 620.408,116.331C620.408,130.487 623.312,142.056 629.119,151.04C634.927,160.024 643.91,164.515 656.07,164.515C673.856,164.515 684.926,156.439 689.282,140.287C690.734,137.565 692.821,136.204 695.543,136.204L718.955,136.204C720.951,136.204 722.721,136.929 724.263,138.381C725.806,139.833 726.759,141.557 727.122,143.554C718.773,179.851 695.089,198 656.07,198Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
<g transform="matrix(1.304,0,0,1.304,-978.571,-415.07)">
<path d="M947.98,337.61L901.72,337.61C900.664,337.61 899.704,337.202 898.84,336.386C897.976,335.57 897.544,334.634 897.544,333.578L897.544,322.346C897.544,321.29 897.976,320.354 898.84,319.538C899.704,318.722 900.664,318.314 901.72,318.314L967.819,318.314C971.201,318.314 974.205,319.678 976.829,322.405C979.556,325.029 980.92,328.033 980.92,331.415L980.92,397.514C980.92,398.57 980.512,399.53 979.696,400.394C978.88,401.258 977.944,401.69 976.888,401.69L965.656,401.69C964.6,401.69 963.664,401.258 962.848,400.394C962.032,399.53 961.624,398.57 961.624,397.514L961.624,351.254L912.362,400.517C911.615,401.264 910.648,401.654 909.46,401.688C908.272,401.722 907.304,401.365 906.558,400.619L898.616,392.676C897.869,391.93 897.512,390.962 897.546,389.774C897.58,388.587 897.971,387.619 898.717,386.872L947.98,337.61Z" style="fill-rule:nonzero;"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(9.81799e-15,-160.34,160.34,9.81799e-15,6673.61,195)"><stop offset="0" style="stop-color:rgb(0,162,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(0,142,255);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -32,7 +32,7 @@ const AppError = ({
>
<Card>
<Flex vertical align='center'>
<FarmControlLogo style={{ fontSize: '350px', height: '31px' }} />
<FarmControlLogo style={{ fontSize: '500px', height: '40px' }} />
</Flex>
</Card>
<Alert

View File

@ -1,32 +1,55 @@
import React from 'react'
import React, { useState, useEffect } from 'react'
import { Flex, Card, Alert } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import AuthParticles from './AppParticles'
import FarmControlLogo from '../Logos/FarmControlLogo'
const AppLoading = () => {
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(true)
}, 1000)
return () => clearTimeout(timer)
}, [])
return (
<>
<AuthParticles />
<Flex
align='center'
justify='center'
vertical
style={{ height: '100vh' }}
gap={'large'}
<div
style={{
backgroundColor: 'black'
}}
>
<div
style={{
backgroundColor: 'black',
minHeight: '100vh',
transition: 'opacity 0.5s ease-in-out',
opacity: isVisible ? 1 : 0
}}
>
<Card>
<Flex vertical align='center'>
<FarmControlLogo style={{ fontSize: '350px', height: '31px' }} />
</Flex>
</Card>
<Alert
message='Loading Farm Control please wait...'
icon={<LoadingOutlined />}
showIcon
/>
</Flex>
</>
<AuthParticles />
<Flex
align='center'
justify='center'
vertical
style={{ height: '100vh' }}
gap={'large'}
>
<Card>
<Flex vertical align='center'>
<FarmControlLogo style={{ fontSize: '500px', height: '40px' }} />
</Flex>
</Card>
<Alert
message='Loading Farm Control please wait...'
icon={<LoadingOutlined />}
showIcon
/>
</Flex>
</div>
</div>
)
}

View File

@ -1,24 +1,19 @@
// src/filamentStocks.js
import React, { useEffect, useState, useContext, useCallback } from 'react'
import React, { useState, useContext, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import {
Table,
Button,
Flex,
Space,
Modal,
message,
Dropdown,
Typography,
Popover,
message,
Checkbox,
Input,
Spin
Popover,
Input
} from 'antd'
import { createStyles } from 'antd-style'
import { LoadingOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext'
import { SocketContext } from '../context/SocketContext'
@ -34,49 +29,20 @@ import TimeDisplay from '../common/TimeDisplay'
import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon'
import useColumnVisibility from '../hooks/useColumnVisibility'
import DashboardTable from '../common/DashboardTable'
import config from '../../../config'
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 FilamentStocks = () => {
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
const { styles } = useStyle()
const { socket } = useContext(SocketContext)
const [filamentStocksData, setFilamentStocksData] = useState([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({
field: 'createdAt',
order: 'descend'
})
const [initialized, setInitialized] = useState(false)
const tableRef = useRef()
const [newFilamentStockOpen, setNewFilamentStockOpen] = useState(false)
const [initialized, setInitialized] = useState(false)
const { authenticated } = useContext(AuthContext)
@ -116,135 +82,6 @@ const FilamentStocks = () => {
)
}
const fetchFilamentStocksData = useCallback(
async (pageNum = 1, append = false) => {
try {
const response = await axios.get(
`${config.backendUrl}/filamentstocks`,
{
params: {
page: pageNum,
limit: 25,
...filters,
sort: sorter.field,
order: sorter.order
},
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
const newData = response.data
setHasMore(newData.length === 25)
if (append) {
setFilamentStocksData((prev) => [...prev, ...newData])
} else {
setFilamentStocksData(newData)
}
setLoading(false)
setLazyLoading(false)
} catch (err) {
messageApi.error('Error fetching filament stocks:', err)
setLoading(false)
setLazyLoading(false)
}
},
[messageApi, filters, sorter]
)
useEffect(() => {
if (authenticated) {
fetchFilamentStocksData()
}
}, [authenticated, fetchFilamentStocksData])
useEffect(() => {
if (socket && !initialized) {
setInitialized(true)
socket.on('notify_filamentstock_update', (updateData) => {
console.log('Received filament stock update:', updateData)
setFilamentStocksData((prevData) => {
return prevData.map((stock) => {
if (stock._id === updateData._id) {
return {
...stock,
...updateData
}
}
return stock
})
})
})
}
return () => {
if (socket && initialized) {
console.log('Deregistering filament stock update listener')
socket.off('notify_filamentstock_update')
}
}
}, [socket, initialized])
const getFilamentStockActionItems = (id) => {
return {
items: [
{
label: 'Info',
key: 'info',
icon: <InfoCircleIcon />
}
],
onClick: ({ key }) => {
if (key === 'info') {
navigate(
`/dashboard/inventory/filamentstocks/info?filamentStockId=${id}`
)
}
}
}
}
const handleScroll = useCallback(
(e) => {
const { target } = e
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
if (
scrollHeight - scrollTop - clientHeight < 100 &&
!lazyLoading &&
hasMore
) {
setLazyLoading(true)
const nextPage = page + 1
setPage(nextPage)
fetchFilamentStocksData(nextPage, true)
}
},
[page, lazyLoading, hasMore, fetchFilamentStocksData]
)
const handleTableChange = (pagination, filters, sorter) => {
const newFilters = {}
Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) {
newFilters[key] = value[0]
}
})
setPage(1)
setFilters(newFilters)
setSorter({
field: sorter.field,
order: sorter.order
})
fetchFilamentStocksData(1)
}
// Column definitions
const columns = [
{
@ -281,7 +118,7 @@ const FilamentStocks = () => {
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 165,
width: 180,
render: (text) => (
<IdText id={text} type={'filamentstock'} longId={false} />
)
@ -366,6 +203,72 @@ const FilamentStocks = () => {
}
]
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
'FilamentStocks',
columns
)
React.useEffect(() => {
if (socket && !initialized) {
setInitialized(true)
socket.on('notify_filamentstock_update', (updateData) => {
console.log('Received filament stock update:', updateData)
if (tableRef.current) {
tableRef.current.updateData(updateData._id, updateData)
}
})
}
return () => {
if (socket && initialized) {
console.log('Deregistering filament stock update listener')
socket.off('notify_filamentstock_update')
}
}
}, [socket, initialized])
const getFilamentStockActionItems = (id) => {
return {
items: [
{
label: 'Info',
key: 'info',
icon: <InfoCircleIcon />
}
],
onClick: ({ key }) => {
if (key === 'info') {
navigate(
`/dashboard/inventory/filamentstocks/info?filamentStockId=${id}`
)
}
}
}
}
const actionItems = {
items: [
{
label: 'New Filament Stock',
key: 'newFilamentStock',
icon: <PlusIcon />
},
{ type: 'divider' },
{
label: 'Reload List',
key: 'reloadList',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reloadList') {
tableRef.current?.reload()
} else if (key === 'newFilamentStock') {
setNewFilamentStockOpen(true)
}
}
}
const getViewDropdownItems = () => {
const columnItems = columns
.filter((col) => col.key && col.title !== '')
@ -390,39 +293,10 @@ const FilamentStocks = () => {
)
}
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
'FilamentStocks',
columns
)
const visibleColumns = columns.filter(
(col) => !col.key || columnVisibility[col.key]
)
const actionItems = {
items: [
{
label: 'New Filament Stock',
key: 'newFilamentStock',
icon: <PlusIcon />
},
{ type: 'divider' },
{
label: 'Reload List',
key: 'reloadList',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reloadList') {
setPage(1)
fetchFilamentStocksData(1)
} else if (key === 'newFilamentStock') {
setNewFilamentStockOpen(true)
}
}
}
return (
<>
<Flex vertical={'true'} gap='large'>
@ -440,19 +314,13 @@ const FilamentStocks = () => {
<Button>View</Button>
</Popover>
</Space>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Flex>
<Table
dataSource={filamentStocksData}
className={styles.customTable}
<DashboardTable
ref={tableRef}
columns={visibleColumns}
pagination={false}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
scroll={{ y: 'calc(100vh - 270px)' }}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
url={`${config.backendUrl}/filamentstocks`}
authenticated={authenticated}
/>
</Flex>
<Modal
@ -463,13 +331,13 @@ const FilamentStocks = () => {
onCancel={() => {
setNewFilamentStockOpen(false)
}}
destroyOnClose
destroyOnHidden={true}
>
<NewFilamentStock
onOk={() => {
setNewFilamentStockOpen(false)
messageApi.success('New filament stock created successfully.')
fetchFilamentStocksData()
tableRef.current?.reload()
}}
reset={newFilamentStockOpen}
/>

View File

@ -10,7 +10,8 @@ import {
Typography,
Form,
Badge,
Collapse
Collapse,
Flex
} from 'antd'
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
import IdText from '../../common/IdText'
@ -132,155 +133,158 @@ const FilamentStockInfo = () => {
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder}
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Filament Stock Information
</Title>
}
key='1'
<Flex vertical gap={'large'}>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Form
form={form}
layout='vertical'
initialValues={{
filament: filamentStockData.filament || {}
}}
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Filament Stock Information
</Title>
}
key='1'
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
<Form
form={form}
layout='vertical'
initialValues={{
filament: filamentStockData.filament || {}
}}
>
{/* Read-only fields */}
<Descriptions.Item label='ID' span={1}>
{filamentStockData.id ? (
<IdText id={filamentStockData.id} type={'filamentstock'} />
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
<TimeDisplay
dateTime={filamentStockData.createdAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='State'>
<FilamentStockState filamentStock={filamentStockData} />
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
<TimeDisplay
dateTime={filamentStockData.updatedAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Filament Name'>
{filamentStockData.filament ? (
<Space>
<FilamentIcon />
<Badge
color={filamentStockData.filament.color}
text={filamentStockData.filament.name}
/>
</Space>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Filament ID' span={1}>
{filamentStockData.filament ? (
<IdText
id={filamentStockData.filament.id}
type={'filament'}
showHyperlink={true}
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
{/* Read-only fields */}
<Descriptions.Item label='ID' span={1}>
{filamentStockData.id ? (
<IdText id={filamentStockData.id} type={'filamentstock'} />
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
<TimeDisplay
dateTime={filamentStockData.createdAt}
showSince={true}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Current Weight'>
{filamentStockData.currentGrossWeight ? (
<Descriptions style={{ width: '250px' }} column={2}>
<Descriptions.Item label='Net'>
{filamentStockData.currentNetWeight.toFixed(2) + 'g'}
</Descriptions.Item>
<Descriptions.Item label='Gross'>
{filamentStockData.currentGrossWeight.toFixed(2) + 'g'}
</Descriptions.Item>
</Descriptions>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Starting Weight'>
{filamentStockData.startingGrossWeight ? (
<Space>
</Descriptions.Item>
<Descriptions.Item label='State'>
<FilamentStockState filamentStock={filamentStockData} />
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
<TimeDisplay
dateTime={filamentStockData.updatedAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Filament Name'>
{filamentStockData.filament ? (
<Space>
<FilamentIcon />
<Badge
color={filamentStockData.filament.color}
text={filamentStockData.filament.name}
/>
</Space>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Filament ID' span={1}>
{filamentStockData.filament ? (
<IdText
id={filamentStockData.filament.id}
type={'filament'}
showHyperlink={true}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Current Weight'>
{filamentStockData.currentGrossWeight ? (
<Descriptions style={{ width: '250px' }} column={2}>
<Descriptions.Item label='Net'>
{filamentStockData.startingNetWeight.toFixed(2) + 'g'}
{filamentStockData.currentNetWeight.toFixed(2) + 'g'}
</Descriptions.Item>
<Descriptions.Item label='Gross'>
{filamentStockData.startingGrossWeight.toFixed(2) + 'g'}
{filamentStockData.currentGrossWeight.toFixed(2) + 'g'}
</Descriptions.Item>
</Descriptions>
</Space>
) : (
'n/a'
)}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Starting Weight'>
{filamentStockData.startingGrossWeight ? (
<Space>
<Descriptions style={{ width: '250px' }} column={2}>
<Descriptions.Item label='Net'>
{filamentStockData.startingNetWeight.toFixed(2) + 'g'}
</Descriptions.Item>
<Descriptions.Item label='Gross'>
{filamentStockData.startingGrossWeight.toFixed(2) +
'g'}
</Descriptions.Item>
</Descriptions>
</Space>
) : (
'n/a'
)}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.events ? ['2'] : []}
onChange={(keys) => updateCollapseState('events', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Filament Stock Events
</Title>
}
key='2'
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.events ? ['2'] : []}
onChange={(keys) => updateCollapseState('events', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<StockEventTable stockEvents={filamentStockData.stockEvents} />
</Collapse.Panel>
</Collapse>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Filament Stock Events
</Title>
}
key='2'
>
<StockEventTable stockEvents={filamentStockData.stockEvents} />
</Collapse.Panel>
</Collapse>
</Flex>
</div>
)
}

View File

@ -9,6 +9,7 @@ import {
Descriptions,
Alert
} from 'antd'
import { useMediaQuery } from 'react-responsive'
import PropTypes from 'prop-types'
import { SocketContext } from '../../context/SocketContext'
@ -28,6 +29,8 @@ const LoadFilamentStock = ({
printer = null,
filamentStockLoaded = false
}) => {
const isMobile = useMediaQuery({ maxWidth: 768 })
LoadFilamentStock.propTypes = {
onOk: PropTypes.func.isRequired,
reset: PropTypes.bool.isRequired,
@ -266,16 +269,18 @@ const LoadFilamentStock = ({
return (
<Flex gap={'middle'}>
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
{!isMobile && (
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
)}
<Divider type={'vertical'} style={{ height: 'unset' }} />
{!isMobile && <Divider type={'vertical'} style={{ height: 'unset' }} />}
<Flex vertical={'true'} style={{ flexGrow: 1 }} gap='middle'>
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>

View File

@ -1,5 +1,6 @@
import React, { useState, useContext, useEffect } from 'react'
import { Form, Button, Typography, Flex, Steps, Divider, Alert } from 'antd'
import { useMediaQuery } from 'react-responsive'
import PropTypes from 'prop-types'
import { SocketContext } from '../../context/SocketContext'
@ -18,6 +19,7 @@ const UnloadFilamentStock = ({ onOk, reset, printer = null }) => {
}
const { socket } = useContext(SocketContext)
const isMobile = useMediaQuery({ maxWidth: 768 })
const initialUnloadFilamentStockForm = {
printer: printer
@ -194,16 +196,18 @@ const UnloadFilamentStock = ({ onOk, reset, printer = null }) => {
return (
<Flex gap={'middle'}>
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
{!isMobile && (
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
)}
<Divider type={'vertical'} style={{ height: 'unset' }} />
{!isMobile && <Divider type={'vertical'} style={{ height: 'unset' }} />}
<Flex vertical={'true'} style={{ flexGrow: 1 }} gap='middle'>
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>

View File

@ -1,20 +1,8 @@
// src/partStocks.js
import React, { useEffect, useState, useContext, useCallback } from 'react'
import React, { useState, useContext, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import {
Table,
Button,
Flex,
Space,
Modal,
message,
Dropdown,
Typography
} from 'antd'
import { createStyles } from 'antd-style'
import { LoadingOutlined } from '@ant-design/icons'
import { Button, Flex, Space, Modal, message, Dropdown, Typography } from 'antd'
import { AuthContext } from '../context/AuthContext'
@ -26,64 +14,21 @@ import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
import PartStockState from '../common/PartStockState'
import TimeDisplay from '../common/TimeDisplay'
import DashboardTable from '../common/DashboardTable'
import config from '../../../config'
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 PartStocks = () => {
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
const { styles } = useStyle()
const [partStocksData, setPartStocksData] = useState([])
const tableRef = useRef()
const [newPartStockOpen, setNewPartStockOpen] = useState(false)
const [loading, setLoading] = useState(true)
const { authenticated } = useContext(AuthContext)
const fetchPartStocksData = useCallback(async () => {
try {
const response = await axios.get(`${config.backendUrl}/partstocks`, {
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
})
setPartStocksData(response.data)
setLoading(false)
} catch (err) {
messageApi.info(err)
}
}, [messageApi])
useEffect(() => {
// Fetch initial data
if (authenticated) {
fetchPartStocksData()
}
}, [authenticated, fetchPartStocksData])
const getPartStockActionItems = (id) => {
return {
items: [
@ -123,7 +68,7 @@ const PartStocks = () => {
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 165,
width: 180,
render: (text) => <IdText id={text} type={'partstock'} longId={false} />
},
{
@ -213,7 +158,7 @@ const PartStocks = () => {
],
onClick: ({ key }) => {
if (key === 'reloadList') {
fetchPartStocksData()
tableRef.current?.reload()
} else if (key === 'newPartStock') {
setNewPartStockOpen(true)
}
@ -229,14 +174,11 @@ const PartStocks = () => {
<Button>Actions</Button>
</Dropdown>
</Space>
<Table
dataSource={partStocksData}
className={styles.customTable}
<DashboardTable
ref={tableRef}
columns={columns}
pagination={false}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
scroll={{ y: 'calc(100vh - 270px)' }}
url={`${config.backendUrl}/partstocks`}
authenticated={authenticated}
/>
</Flex>
<Modal
@ -247,13 +189,13 @@ const PartStocks = () => {
onCancel={() => {
setNewPartStockOpen(false)
}}
destroyOnClose
destroyOnHidden={true}
>
<NewPartStock
onOk={() => {
setNewPartStockOpen(false)
messageApi.success('New part stock created successfully.')
fetchPartStocksData()
tableRef.current?.reload()
}}
reset={newPartStockOpen}
/>

View File

@ -1,10 +1,6 @@
import React, { useEffect, useState, useContext, useCallback } from 'react'
import React, { useState, useContext, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { Table, Button, Flex, Space, message, Dropdown, Typography } from 'antd'
import { createStyles } from 'antd-style'
import { LoadingOutlined } from '@ant-design/icons'
import { Button, Flex, Space, message, Dropdown, Typography } from 'antd'
import { AuthContext } from '../context/AuthContext'
import { SocketContext } from '../context/SocketContext'
@ -15,78 +11,29 @@ import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
import TimeDisplay from '../common/TimeDisplay'
import DashboardTable from '../common/DashboardTable'
import config from '../../../config'
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 StockAudits = () => {
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
const { styles } = useStyle()
const { socket } = useContext(SocketContext)
const [stockAuditsData, setStockAuditsData] = useState([])
const [loading, setLoading] = useState(true)
const [initialized, setInitialized] = useState(false)
const tableRef = useRef()
const { authenticated } = useContext(AuthContext)
const fetchStockAuditsData = useCallback(async () => {
try {
const response = await axios.get(`${config.backendUrl}/stockaudits`, {
headers: {
Accept: 'application/json'
},
withCredentials: true
})
setStockAuditsData(response.data)
setLoading(false)
} catch (err) {
messageApi.info(err)
}
}, [messageApi])
useEffect(() => {
if (authenticated) {
fetchStockAuditsData()
}
}, [authenticated, fetchStockAuditsData])
useEffect(() => {
React.useEffect(() => {
if (socket && !initialized) {
setInitialized(true)
socket.on('notify_stockaudit_update', (updateData) => {
console.log('Received stock audit update:', updateData)
setStockAuditsData((prevData) => {
return prevData.map((audit) => {
if (audit._id === updateData._id) {
return {
...audit,
...updateData
}
}
return audit
})
})
if (tableRef.current) {
tableRef.current.updateData(updateData._id, updateData)
}
})
}
@ -128,7 +75,7 @@ const StockAudits = () => {
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 165,
width: 180,
render: (text) => <IdText id={text} type={'stockaudit'} longId={false} />
},
{
@ -203,7 +150,7 @@ const StockAudits = () => {
],
onClick: ({ key }) => {
if (key === 'reloadList') {
fetchStockAuditsData()
tableRef.current?.reload()
} else if (key === 'newStockAudit') {
// TODO: Implement new stock audit creation
messageApi.info('New stock audit creation not implemented yet')
@ -220,14 +167,11 @@ const StockAudits = () => {
<Button>Actions</Button>
</Dropdown>
</Space>
<Table
dataSource={stockAuditsData}
className={styles.customTable}
<DashboardTable
ref={tableRef}
columns={columns}
pagination={false}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
scroll={{ y: 'calc(100vh - 270px)' }}
url={`${config.backendUrl}/stockaudits`}
authenticated={authenticated}
/>
</Flex>
</>

View File

@ -103,7 +103,7 @@ const StockAuditInfo = () => {
title: 'Item ID',
dataIndex: '_id',
key: 'id',
width: 165,
width: 180,
render: (text) => (
<IdText id={text} type={'stockaudititem'} longId={false} />
)

View File

@ -1,21 +1,15 @@
import React, { useEffect, useState, useContext, useCallback } from 'react'
import axios from 'axios'
import React, { useState, useContext, useRef } from 'react'
import {
Button,
Flex,
Space,
message,
Spin,
Popover,
Checkbox,
Dropdown,
Table,
Typography,
Input
} from 'antd'
import { createStyles } from 'antd-style'
import { LoadingOutlined, AuditOutlined } from '@ant-design/icons'
import { AuditOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext'
import { SocketContext } from '../context/SocketContext'
@ -28,63 +22,24 @@ import PlayCircleIcon from '../../Icons/PlayCircleIcon'
import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon'
import useColumnVisibility from '../hooks/useColumnVisibility'
import DashboardTable from '../common/DashboardTable'
import config from '../../../config'
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 StockEvents = () => {
const [messageApi, contextHolder] = message.useMessage()
const { styles } = useStyle()
const { socket } = useContext(SocketContext)
const [initialized, setInitialized] = useState(false)
// Helper function to convert text to camelCase
const toCamelCase = (text) => {
return text
.toLowerCase()
.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => {
return index === 0 ? word.toLowerCase() : word.toUpperCase()
})
.replace(/\s+/g, '')
}
const [stockEventsData, setStockEventsData] = useState([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({
field: 'createdAt',
order: 'descend'
})
const tableRef = useRef()
// Column definitions for visibility
const columns = [
{
title: '',
key: 'icon',
width: 50,
width: 40,
fixed: 'left',
render: (record) => {
switch (record.type.toLowerCase()) {
case 'subjob':
@ -103,6 +58,7 @@ const StockEvents = () => {
dataIndex: 'type',
key: 'type',
width: 200,
fixed: 'left',
sorter: true,
filterDropdown: ({
setSelectedKeys,
@ -118,6 +74,15 @@ const StockEvents = () => {
propertyName: 'type'
})
},
{
title: 'ID',
key: 'id',
dataIndex: '_id',
width: 170,
render: (id) => {
return <IdText id={id} longId={false} type={'stockevent'} />
}
},
{
title: <PlusMinusIcon />,
dataIndex: 'value',
@ -134,26 +99,17 @@ const StockEvents = () => {
}
},
{
title: 'Linked ID',
key: 'linkedId',
width: 100,
title: 'Stock ID',
key: 'stockId',
width: 170,
render: (record) => {
if (record.subJob?.number) {
if (record.filamentStock?._id) {
return (
<IdText
id={record.subJob.number.toString().padStart(6, '0')}
id={record.filamentStock._id}
longId={false}
type={'subjob'}
/>
)
}
if (record.stockAudit) {
return (
<IdText
id={record.stockAudit._id}
longId={false}
type={'stockaudit'}
showHyperlink={true}
type={'filamentstock'}
/>
)
}
@ -161,21 +117,41 @@ const StockEvents = () => {
}
},
{
title: 'Job ID',
key: 'jobId',
width: 100,
title: 'Linked IDs',
key: 'linkedIds',
width: 170 * 2,
render: (record) => {
if (record.subJob) {
return (
<IdText
id={record.job}
longId={false}
type={'job'}
showHyperlink={true}
/>
)
const ids = (
<Space size={'middle'}>
{record.job ? (
<IdText
id={record.job}
longId={false}
showHyperlink={true}
type={'job'}
/>
) : null}
{record.subJob?.number ? (
<IdText
id={record.subJob.number.toString().padStart(6, '0')}
longId={false}
type={'subjob'}
/>
) : null}
{record.stockAudit ? (
<IdText
id={record.stockAudit._id}
longId={false}
type={'stockaudit'}
showHyperlink={true}
/>
) : null}
</Space>
)
if (!record.stockAudit && !record.job && !record.subJob) {
return 'n/a'
}
return 'n/a'
return ids
}
},
{
@ -252,79 +228,15 @@ const StockEvents = () => {
const { authenticated } = useContext(AuthContext)
const fetchStockEventsData = useCallback(
async (pageNum = 1, append = false) => {
try {
const response = await axios.get(`${config.backendUrl}/stockevents`, {
params: {
page: pageNum,
limit: 25,
type: filters.type,
...filters,
sort: sorter.field,
order: sorter.order
},
headers: {
Accept: 'application/json'
},
withCredentials: true
})
const newData = response.data
setHasMore(newData.length === 25)
if (append) {
setStockEventsData((prev) => [...prev, ...newData])
} else {
setStockEventsData(newData)
}
setLoading(false)
setLazyLoading(false)
} catch (error) {
if (error.response) {
messageApi.error(
'Error fetching stock events:',
error.response.status
)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
setLoading(false)
setLazyLoading(false)
}
},
[messageApi, filters, sorter]
)
useEffect(() => {
if (authenticated) {
fetchStockEventsData()
}
}, [authenticated, fetchStockEventsData])
useEffect(() => {
React.useEffect(() => {
// Add WebSocket event listener for real-time updates
if (socket && !initialized) {
setInitialized(true)
socket.on('notify_stockevent_update', (updateData) => {
console.log('Received stock event update:', updateData)
setStockEventsData((prevData) => {
return prevData.map((stockEvent) => {
if (stockEvent?._id) {
if (stockEvent._id === updateData._id) {
return {
...stockEvent,
...updateData
}
} else {
return stockEvent
}
}
})
})
if (tableRef.current) {
tableRef.current.updateData(updateData._id, updateData)
}
})
}
@ -336,27 +248,6 @@ const StockEvents = () => {
}
}, [socket, initialized])
const handleScroll = useCallback(
(e) => {
const { target } = e
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
if (
scrollHeight - scrollTop - clientHeight < 100 &&
!lazyLoading &&
hasMore
) {
setLazyLoading(true)
const nextPage = page + 1
setPage(nextPage)
fetchStockEventsData(nextPage, true)
}
},
[page, lazyLoading, hasMore, fetchStockEventsData]
)
const actionItems = {
items: [
{
@ -367,34 +258,11 @@ const StockEvents = () => {
],
onClick: ({ key }) => {
if (key === 'reloadList') {
setPage(1)
fetchStockEventsData(1)
tableRef.current?.reload()
}
}
}
const handleTableChange = (pagination, filters, sorter) => {
const newFilters = {}
Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) {
// Convert type filter to camelCase
if (key === 'type') {
newFilters[key] = toCamelCase(value[0])
} else {
newFilters[key] = value[0]
}
}
})
setPage(1)
setFilters(newFilters)
setSorter({
field: sorter.field,
order: sorter.order
})
// Trigger a new fetch with the updated filters
fetchStockEventsData(1)
}
const getViewDropdownItems = () => {
const columnItems = columns
.filter((col) => col.key && col.title !== '')
@ -426,7 +294,6 @@ const StockEvents = () => {
return (
<>
<Flex vertical={'true'} gap='large'>
{contextHolder}
<Flex justify={'space-between'}>
<Space size='small'>
<Dropdown menu={actionItems}>
@ -440,19 +307,13 @@ const StockEvents = () => {
<Button>View</Button>
</Popover>
</Space>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Flex>
<Table
dataSource={stockEventsData}
<DashboardTable
ref={tableRef}
columns={visibleColumns}
className={styles.customTable}
pagination={false}
scroll={{ y: 'calc(100vh - 270px)' }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
url={`${config.backendUrl}/stockevents`}
authenticated={authenticated}
/>
</Flex>
</>

View File

@ -0,0 +1,333 @@
import React, { useContext, useRef } from 'react'
import {
Button,
Flex,
Space,
Typography,
Popover,
Checkbox,
Dropdown,
Tag,
Descriptions,
Input,
Badge
} from 'antd'
import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText'
import ReloadIcon from '../../Icons/ReloadIcon'
import useColumnVisibility from '../hooks/useColumnVisibility'
import TimeDisplay from '../common/TimeDisplay'
import DashboardTable from '../common/DashboardTable'
import config from '../../../config'
import AuditLogIcon from '../../Icons/AuditLogIcon'
import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon'
const { Text } = Typography
const formatPropertyName = (name) => {
return name
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase())
}
const isObjectId = (value) => {
return typeof value === 'string' && /^[0-9a-fA-F]{24}$/.test(value)
}
const formatValue = (value, propertyName) => {
if (value === null || value === undefined || value === '') {
return <Text type='secondary'>n/a</Text>
}
// Handle colors specifically
if (propertyName === 'color' && value) {
return <Badge color={value} text={value} />
}
if (propertyName === 'state' && typeof value === 'object' && value.type) {
return (
<Text>{value.type.charAt(0).toUpperCase() + value.type.slice(1)}</Text>
)
}
// Check if the value is a timestamp (ISO date string)
if (
typeof value === 'string' &&
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)
) {
return <TimeDisplay dateTime={value} />
}
if (typeof value === 'boolean') {
return (
<Tag color={value ? 'success' : 'error'} style={{ margin: 0 }}>
{value ? 'Yes' : 'No'}
</Tag>
)
}
if (isObjectId(value)) {
return (
<IdText
id={value}
type={propertyName.toLowerCase().replaceAll('current', '')}
longId={false}
showHyperlink={true}
/>
)
}
if (typeof value === 'object') {
return <Text>{JSON.stringify(value)}</Text>
}
return <Text>{value}</Text>
}
const AuditLogs = () => {
const tableRef = useRef()
// Column definitions
const columns = [
{
title: '',
dataIndex: '',
key: '',
width: 40,
fixed: 'left',
render: () => <AuditLogIcon />
},
{
title: 'ID',
dataIndex: '_id',
key: 'id',
fixed: 'left',
width: 180,
render: (text) => <IdText id={text} type={'auditlog'} longId={false} />,
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'ID'
}),
onFilter: (value, record) =>
record._id.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'Owner Name',
dataIndex: ['owner', 'name'],
key: 'name',
width: 200,
fixed: 'left',
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'name'
}),
onFilter: (value, record) =>
record.owner?.name?.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'Owner',
key: 'owner',
width: 180,
render: (record) => (
<IdText
id={record.owner._id}
type={record.ownerModel.toLowerCase()}
longId={false}
showHyperlink={true}
/>
)
},
{
title: 'Target',
key: 'target',
width: 180,
render: (record) => (
<IdText
id={record.target}
type={record.targetModel.toLowerCase()}
longId={false}
showHyperlink={true}
/>
)
},
{
title: 'Properties',
dataIndex: 'type',
key: 'type',
width: 550,
render: (_, record) => {
const oldValue = record.oldValue || {}
const newValue = record.newValue || {}
return (
<Descriptions size='small' column={1}>
{Object.keys(newValue).map((key) => (
<Descriptions.Item key={key} label={formatPropertyName(key)}>
<Space>
{formatValue(oldValue[key], key)}
<Text type='secondary'></Text>
{formatValue(newValue[key], key)}
</Space>
</Descriptions.Item>
))}
</Descriptions>
)
}
},
{
title: 'Timestamp',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
fixed: 'right',
render: (createdAt) => {
if (createdAt) {
return <TimeDisplay dateTime={createdAt} />
} else {
return 'n/a'
}
},
sorter: true,
defaultSortOrder: 'descend'
}
]
const getFilterDropdown = ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName
}) => {
return (
<div style={{ padding: 8 }}>
<Space.Compact>
<Input
placeholder={'Search ' + propertyName}
value={selectedKeys[0]}
onChange={(e) =>
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
onPressEnter={() => confirm()}
style={{ width: 200, display: 'block' }}
/>
<Button
onClick={() => {
clearFilters()
confirm()
}}
icon={<XMarkIcon />}
/>
<Button
type='primary'
onClick={() => confirm()}
icon={<CheckIcon />}
/>
</Space.Compact>
</div>
)
}
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
'AuditLogs',
columns
)
const { authenticated } = useContext(AuthContext)
const actionItems = {
items: [
{
label: 'Reload List',
key: 'reloadList',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reloadList') {
tableRef.current?.reload()
}
}
}
const getViewDropdownItems = () => {
const columnItems = columns
.filter((col) => col.key && col.title !== '')
.map((col) => (
<Checkbox
checked={columnVisibility[col.key]}
key={col.key}
onChange={(e) => {
updateColumnVisibility(col.key, e.target.checked)
}}
>
{col.title}
</Checkbox>
))
return (
<Flex vertical>
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
{columnItems}
</Flex>
</Flex>
)
}
const visibleColumns = columns.filter(
(col) => !col.key || columnVisibility[col.key]
)
return (
<>
<Flex vertical={'true'} gap='large'>
<Flex justify={'space-between'}>
<Space size='small'>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button>View</Button>
</Popover>
</Space>
</Flex>
<DashboardTable
ref={tableRef}
columns={visibleColumns}
url={`${config.backendUrl}/auditlogs`}
authenticated={authenticated}
/>
</Flex>
</>
)
}
export default AuditLogs

View File

@ -282,7 +282,7 @@ const Filaments = () => {
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 165,
width: 180,
render: (text) => <IdText id={text} type={'filament'} longId={false} />,
filterDropdown: ({
setSelectedKeys,
@ -493,7 +493,7 @@ const Filaments = () => {
onCancel={() => {
setNewFilamentOpen(false)
}}
destroyOnClose
destroyOnHidden={true}
>
<NewFilament
onOk={() => {

View File

@ -34,7 +34,7 @@ import useCollapseState from '../../hooks/useCollapseState'
import config from '../../../../config.js'
const { Title, Link } = Typography
const { Title, Link, Text } = Typography
const FilamentInfo = () => {
const [filamentData, setFilamentData] = useState(null)
@ -211,296 +211,333 @@ const FilamentInfo = () => {
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder}
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Filament Information
</Title>
<Space>
<Button
icon={<DeleteOutlined />}
danger
onClick={() => setIsDeleteModalOpen(true)}
loading={loading}
/>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateFilamentInfo}
loading={loading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
}
key='1'
<Flex vertical gap={'large'}>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Form
form={form}
layout='vertical'
initialValues={{
name: filamentData.name || '',
vendor: filamentData.vendor || { id: null, name: '' },
type: filamentData.type || '',
cost: filamentData.cost || null,
color: filamentData.color || '#000000',
diameter: filamentData.diameter || null,
density: filamentData.density || null,
url: filamentData.url || '',
barcode: filamentData.barcode || ''
}}
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Filament Information
</Title>
<Space>
<Button
icon={<DeleteOutlined />}
danger
onClick={() => setIsDeleteModalOpen(true)}
loading={loading}
/>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateFilamentInfo}
loading={loading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
}
key='1'
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
<Form
form={form}
layout='vertical'
initialValues={{
name: filamentData.name || '',
vendor: filamentData.vendor || { id: null, name: '' },
type: filamentData.type || '',
cost: filamentData.cost || null,
color: filamentData.color || '#000000',
diameter: filamentData.diameter || null,
density: filamentData.density || null,
url: filamentData.url || '',
barcode: filamentData.barcode || ''
}}
>
<Descriptions.Item label='ID' span={1}>
{filamentData.id ? (
<IdText id={filamentData.id} type={'filament'} />
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
<TimeDisplay
dateTime={filamentData.createdAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID' span={1}>
{filamentData.id ? (
<IdText id={filamentData.id} type={'filament'} />
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
<TimeDisplay
dateTime={filamentData.createdAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='name'
rules={[
{
required: true,
message: 'Please enter a filament name'
},
{ max: 100, message: 'Name cannot exceed 100 characters' }
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter filament name' />
</Form.Item>
) : (
filamentData.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Name'>
<Flex align={'center'} gap={'small'}>
{isEditing ? (
<Form.Item
name='name'
rules={[
{
required: true,
message: 'Please enter a filament name'
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter filament name' />
</Form.Item>
) : (
filamentData.name || 'n/a'
)}
</Flex>
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
<TimeDisplay
dateTime={filamentData.updatedAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
<TimeDisplay
dateTime={filamentData.updatedAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Vendor'>
{isEditing ? (
<Form.Item
name='vendor'
rules={[
{ required: true, message: 'Please enter a vendor' }
]}
style={{ margin: 0 }}
>
<VendorSelect />
</Form.Item>
) : (
filamentData.vendor.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor'>
<Flex align={'center'} gap={'small'}>
{isEditing ? (
<Form.Item
name='vendor'
rules={[
{ required: true, message: 'Please enter a vendor' }
]}
style={{ margin: 0 }}
>
<VendorSelect />
</Form.Item>
) : filamentData.vendor.name ? (
<Text>{filamentData.vendor.name}</Text>
) : (
<Text>n/a</Text>
)}
</Flex>
</Descriptions.Item>
<Descriptions.Item label='Vendor ID'>
<IdText
id={filamentData.vendor.id}
type={'vendor'}
showHyperlink={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Vendor ID'>
<IdText
id={filamentData.vendor.id}
type={'vendor'}
showHyperlink={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Material'>
{isEditing ? (
<Form.Item
name='type'
style={{ margin: 0 }}
rules={[
{ required: true, message: 'Please select a material' }
]}
>
<Select>
<Select.Option value='PLA'>PLA</Select.Option>
<Select.Option value='PETG'>PETG</Select.Option>
<Select.Option value='ABS'>ABS</Select.Option>
<Select.Option value='ASA'>ASA</Select.Option>
<Select.Option value='HIPS'>HIPS</Select.Option>
<Select.Option value='TPU'>TPU</Select.Option>
</Select>
</Form.Item>
) : (
filamentData.type || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Material'>
<Flex align={'center'} gap={'small'}>
{isEditing ? (
<Form.Item
name='type'
style={{ margin: 0 }}
rules={[
{
required: true,
message: 'Please select a material'
}
]}
>
<Select>
<Select.Option value='PLA'>PLA</Select.Option>
<Select.Option value='PETG'>PETG</Select.Option>
<Select.Option value='ABS'>ABS</Select.Option>
<Select.Option value='ASA'>ASA</Select.Option>
<Select.Option value='HIPS'>HIPS</Select.Option>
<Select.Option value='TPU'>TPU</Select.Option>
</Select>
</Form.Item>
) : (
filamentData.type || 'n/a'
)}
</Flex>
</Descriptions.Item>
<Descriptions.Item label='Cost'>
{isEditing ? (
<Form.Item
name='cost'
style={{ margin: 0 }}
rules={[{ required: true, message: 'Please enter a cost' }]}
>
<InputNumber
prefix='£'
suffix='/kg'
style={{ width: '100%' }}
/>
</Form.Item>
) : filamentData.cost ? (
`£${filamentData.cost}/kg`
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Cost'>
<Flex align={'center'} gap={'small'}>
{isEditing ? (
<Form.Item
name='cost'
style={{ margin: 0 }}
rules={[
{ required: true, message: 'Please enter a cost' }
]}
>
<InputNumber
prefix='£'
suffix='/kg'
style={{ width: '100%' }}
/>
</Form.Item>
) : filamentData.cost ? (
`£${filamentData.cost}/kg`
) : (
'n/a'
)}
</Flex>
</Descriptions.Item>
<Descriptions.Item label='Color'>
{isEditing ? (
<Form.Item
name='color'
style={{ margin: 0 }}
rules={[
{ required: true, message: 'Please select a color' }
]}
>
<ColorPicker />
</Form.Item>
) : (
<Badge color={filamentData.color} text={filamentData.color} />
)}
</Descriptions.Item>
<Descriptions.Item label='Color'>
<Flex align={'center'} gap={'small'}>
{isEditing ? (
<Form.Item
name='color'
style={{ margin: 0 }}
rules={[
{ required: true, message: 'Please select a color' }
]}
getValueFromEvent={(color) => {
return '#' + color.toHex()
}}
>
<ColorPicker showText disabledAlpha />
</Form.Item>
) : (
<Badge
color={filamentData.color}
text={filamentData.color}
/>
)}
</Flex>
</Descriptions.Item>
<Descriptions.Item label='Diameter'>
{isEditing ? (
<Form.Item
name='diameter'
style={{ margin: 0 }}
rules={[
{ required: true, message: 'Please enter a diameter' }
]}
>
<InputNumber suffix='mm' style={{ width: '100%' }} />
</Form.Item>
) : filamentData.diameter ? (
`${filamentData.diameter}mm`
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Diameter'>
<Flex align={'center'} gap={'small'}>
{isEditing ? (
<Form.Item
name='diameter'
style={{ margin: 0 }}
rules={[
{ required: true, message: 'Please enter a diameter' }
]}
>
<InputNumber suffix='mm' style={{ width: '100%' }} />
</Form.Item>
) : filamentData.diameter ? (
`${filamentData.diameter}mm`
) : (
'n/a'
)}
</Flex>
</Descriptions.Item>
<Descriptions.Item label='Density'>
{isEditing ? (
<Form.Item
name='density'
style={{ margin: 0 }}
rules={[
{ required: true, message: 'Please enter a density' }
]}
>
<InputNumber suffix='g/cm³' style={{ width: '100%' }} />
</Form.Item>
) : filamentData.density ? (
`${filamentData.density}g/cm³`
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Density'>
<Flex align={'center'} gap={'small'}>
{isEditing ? (
<Form.Item
name='density'
style={{ margin: 0 }}
rules={[
{ required: true, message: 'Please enter a density' }
]}
>
<InputNumber suffix='g/cm³' style={{ width: '100%' }} />
</Form.Item>
) : filamentData.density ? (
`${filamentData.density}g/cm³`
) : (
'n/a'
)}
</Flex>
</Descriptions.Item>
<Descriptions.Item label='URL'>
{isEditing ? (
<Form.Item name='url' style={{ margin: 0 }}>
<Input placeholder='Enter URL' />
</Form.Item>
) : filamentData.url ? (
<Link href={filamentData.url} target='_blank'>
{filamentData.url}
</Link>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='URL'>
<Flex align={'center'} gap={'small'}>
{isEditing ? (
<Form.Item name='url' style={{ margin: 0 }}>
<Input placeholder='Enter URL' />
</Form.Item>
) : filamentData.url ? (
<Link href={filamentData.url} target='_blank'>
{filamentData.url}
</Link>
) : (
'n/a'
)}
</Flex>
</Descriptions.Item>
<Descriptions.Item label='Barcode'>
{isEditing ? (
<Form.Item name='barcode' style={{ margin: 0 }}>
<Input placeholder='Enter barcode' />
</Form.Item>
) : (
filamentData.barcode || 'n/a'
)}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
<Descriptions.Item label='Barcode'>
<Flex align={'center'} gap={'small'}>
{isEditing ? (
<Form.Item name='barcode' style={{ margin: 0 }}>
<Input placeholder='Enter barcode' />
</Form.Item>
) : (
filamentData.barcode || 'n/a'
)}
</Flex>
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.details ? ['2'] : []}
onChange={(keys) => updateCollapseState('details', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Additional Details
</Title>
}
key='2'
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.details ? ['2'] : []}
onChange={(keys) => updateCollapseState('details', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
{/* Add any additional details sections here */}
</Collapse.Panel>
</Collapse>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Additional Details
</Title>
}
key='2'
>
{/* Add any additional details sections here */}
</Collapse.Panel>
</Collapse>
</Flex>
<Modal
title='Delete Filament'

View File

@ -1,6 +1,7 @@
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import axios from 'axios'
import { useMediaQuery } from 'react-responsive'
import {
Form,
Input,
@ -38,6 +39,7 @@ const initialNewFilamentForm = {
const NewFilament = ({ onOk, reset }) => {
const [messageApi, contextHolder] = message.useMessage()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [newFilamentLoading, setNewFilamentLoading] = useState(false)
const [currentStep, setCurrentStep] = useState(0)
@ -349,16 +351,18 @@ const NewFilament = ({ onOk, reset }) => {
<Flex gap='middle'>
{contextHolder}
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
{!isMobile && (
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
)}
<Divider type='vertical' style={{ height: 'unset' }} />
{!isMobile && <Divider type='vertical' style={{ height: 'unset' }} />}
<Flex vertical style={{ flexGrow: 1 }} gap='middle'>
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>

View File

@ -168,7 +168,7 @@ const Materials = () => {
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 165,
width: 180,
render: (text) => <IdText id={text} type={'material'} longId={false} />
},
{

View File

@ -0,0 +1,321 @@
import React, { useState, useContext, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Button,
Flex,
Space,
Modal,
Dropdown,
message,
Checkbox,
Popover,
Input,
Badge,
Tag
} from 'antd'
import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText'
import NewNoteType from './NoteTypes/NewNoteType'
import TimeDisplay from '../common/TimeDisplay'
import DashboardTable from '../common/DashboardTable'
import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import useColumnVisibility from '../hooks/useColumnVisibility'
import config from '../../../config'
import NoteTypeIcon from '../../Icons/NoteTypeIcon'
const NoteTypes = () => {
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
const [newNoteTypeOpen, setNewNoteTypeOpen] = useState(false)
const tableRef = useRef()
const { authenticated } = useContext(AuthContext)
const getFilterDropdown = ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName
}) => {
return (
<div style={{ padding: 8 }}>
<Space.Compact>
<Input
placeholder={'Search ' + propertyName}
value={selectedKeys[0]}
onChange={(e) =>
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
onPressEnter={() => confirm()}
style={{ width: 200, display: 'block' }}
/>
<Button
onClick={() => {
clearFilters()
confirm()
}}
icon={<XMarkIcon />}
/>
<Button
type='primary'
onClick={() => confirm()}
icon={<CheckIcon />}
/>
</Space.Compact>
</div>
)
}
const getViewDropdownItems = () => {
const columnItems = columns
.filter((col) => col.key && col.title !== '')
.map((col) => (
<Checkbox
checked={columnVisibility[col.key]}
key={col.key}
onChange={(e) => {
updateColumnVisibility(col.key, e.target.checked)
}}
>
{col.title}
</Checkbox>
))
return (
<Flex vertical>
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
{columnItems}
</Flex>
</Flex>
)
}
const getNoteTypeActionItems = (id) => {
return {
items: [
{
label: 'Info',
key: 'info',
icon: <InfoCircleIcon />
}
],
onClick: ({ key }) => {
if (key === 'info') {
navigate(`/dashboard/management/notetypes/info?noteTypeId=${id}`)
}
}
}
}
const columns = [
{
title: '',
dataIndex: '',
key: 'icon',
width: 40,
fixed: 'left',
render: () => <NoteTypeIcon />
},
{
title: 'Name',
dataIndex: 'name',
key: 'name',
width: 200,
fixed: 'left',
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'name'
}),
onFilter: (value, record) =>
record.name.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 180,
render: (text) => <IdText id={text} type={'notetype'} longId={false} />,
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'ID'
}),
onFilter: (value, record) =>
record._id.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'Color',
dataIndex: 'color',
key: 'color',
width: 120,
render: (color) => <Badge color={color} text={color} />
},
{
title: 'Active',
dataIndex: 'isActive',
key: 'isActive',
width: 100,
render: (isActive) => (
<Tag color={isActive ? 'success' : 'error'}>
{isActive ? 'Yes' : 'No'}
</Tag>
),
sorter: true
},
{
title: 'Created At',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (createdAt) => {
if (createdAt) {
return <TimeDisplay dateTime={createdAt} />
} else {
return 'n/a'
}
},
sorter: true,
defaultSortOrder: 'descend'
},
{
title: 'Updated At',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 180,
render: (updatedAt) => {
if (updatedAt) {
return <TimeDisplay dateTime={updatedAt} />
} else {
return 'n/a'
}
},
sorter: true,
defaultSortOrder: 'descend'
},
{
title: 'Actions',
key: 'actions',
fixed: 'right',
width: 150,
render: (text, record) => {
return (
<Space gap='small'>
<Button
icon={<InfoCircleIcon />}
onClick={() =>
navigate(
`/dashboard/management/notetypes/info?noteTypeId=${record._id}`
)
}
/>
<Dropdown menu={getNoteTypeActionItems(record._id)}>
<Button>Actions</Button>
</Dropdown>
</Space>
)
}
}
]
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
'NoteTypes',
columns
)
const visibleColumns = columns.filter(
(col) => !col.key || columnVisibility[col.key]
)
const actionItems = {
items: [
{
label: 'New Note Type',
key: 'newNoteType',
icon: <PlusIcon />
},
{ type: 'divider' },
{
label: 'Reload List',
key: 'reloadList',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reloadList') {
tableRef.current?.reload()
} else if (key === 'newNoteType') {
setNewNoteTypeOpen(true)
}
}
}
return (
<>
<Flex vertical={'true'} gap='large'>
{contextHolder}
<Flex justify={'space-between'}>
<Space size='small'>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button>View</Button>
</Popover>
</Space>
</Flex>
<DashboardTable
ref={tableRef}
columns={visibleColumns}
url={`${config.backendUrl}/notetypes`}
authenticated={authenticated}
/>
</Flex>
<Modal
open={newNoteTypeOpen}
onCancel={() => setNewNoteTypeOpen(false)}
footer={null}
destroyOnHidden={true}
width={700}
>
<NewNoteType
onOk={() => {
setNewNoteTypeOpen(false)
messageApi.success('New note type created successfully.')
tableRef.current?.reload()
}}
reset={!newNoteTypeOpen}
/>
</Modal>
</>
)
}
export default NoteTypes

View File

@ -0,0 +1,259 @@
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import axios from 'axios'
import { useMediaQuery } from 'react-responsive'
import {
Form,
Input,
Button,
message,
Typography,
Flex,
Steps,
Descriptions,
Divider,
ColorPicker,
Switch,
Badge,
Checkbox,
Tag
} from 'antd'
import config from '../../../../config'
const { Title, Text } = Typography
const initialNewNoteTypeForm = {
name: '',
color: null,
active: true
}
const NewNoteType = ({ onOk, reset }) => {
const [messageApi, contextHolder] = message.useMessage()
const [newNoteTypeLoading, setNewNoteTypeLoading] = useState(false)
const [currentStep, setCurrentStep] = useState(0)
const [nextEnabled, setNextEnabled] = useState(false)
const [colorEnabled, setColorEnabled] = useState(false)
const [newNoteTypeForm] = Form.useForm()
const [newNoteTypeFormValues, setNewNoteTypeFormValues] = useState(
initialNewNoteTypeForm
)
const isMobile = useMediaQuery({ maxWidth: 768 })
const newNoteTypeFormUpdateValues = Form.useWatch([], newNoteTypeForm)
React.useEffect(() => {
newNoteTypeForm
.validateFields({
validateOnly: true
})
.then(() => setNextEnabled(true))
.catch(() => setNextEnabled(false))
}, [newNoteTypeForm, newNoteTypeFormUpdateValues])
const summaryItems = [
{
key: 'name',
label: 'Name',
children: newNoteTypeFormValues.name
},
{
key: 'color',
label: 'Color',
children: newNoteTypeFormValues.color ? (
<Badge
status={newNoteTypeFormValues.color}
text={newNoteTypeFormValues.color}
/>
) : (
<Text>n/a</Text>
)
},
{
key: 'active',
label: 'Active',
children: newNoteTypeFormValues.active ? (
<Tag color={'success'}>Yes</Tag>
) : (
<Tag color={'error'}>No</Tag>
)
}
]
React.useEffect(() => {
if (reset) {
newNoteTypeForm.resetFields()
}
}, [reset, newNoteTypeForm])
const handleNewNoteType = async () => {
setNewNoteTypeLoading(true)
try {
await axios.post(
`${config.backendUrl}/noteTypes`,
newNoteTypeFormValues,
{
withCredentials: true
}
)
onOk()
} catch (error) {
messageApi.error('Error creating new note type: ' + error.message)
} finally {
setNewNoteTypeLoading(false)
}
}
const steps = [
{
title: 'Details',
key: 'details',
content: (
<>
<Form.Item
label='Name'
name='name'
rules={[
{
required: true,
message: 'Please enter a name.'
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
>
<Input />
</Form.Item>
<Flex gap='middle' align='center'>
<Form.Item
label='Color'
name='color'
getValueFromEvent={(color) => {
if (color != null) {
return '#' + color.toHex()
}
return null
}}
>
<ColorPicker showText={true} disabled={!colorEnabled} />
</Form.Item>
<Checkbox
checked={colorEnabled}
onChange={(e) => {
setColorEnabled(e.target.checked)
if (!e.target.checked) {
setNewNoteTypeFormValues((prev) => ({ ...prev, color: null }))
} else if (e.target.checked) {
setNewNoteTypeFormValues((prev) => ({
...prev,
color: newNoteTypeForm.getFieldValue('color')
}))
}
}}
/>
</Flex>
<Form.Item
label='Active'
name='active'
valuePropName='checked'
style={{ marginBottom: 8 }}
>
<Switch />
</Form.Item>
</>
)
},
{
title: 'Summary',
key: 'summary',
content: <Descriptions column={1} items={summaryItems} size={'small'} />
}
]
return (
<Flex gap='middle'>
{contextHolder}
{!isMobile && (
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
)}
{!isMobile && <Divider type='vertical' style={{ height: 'unset' }} />}
<Flex vertical gap='middle' style={{ flexGrow: 1 }}>
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
New Note Type
</Title>
<Form
name='basic'
autoComplete='off'
form={newNoteTypeForm}
onFinish={handleNewNoteType}
onValuesChange={(changedValues) =>
setNewNoteTypeFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={initialNewNoteTypeForm}
>
<div style={{ minHeight: '260px' }}>{steps[currentStep].content}</div>
</Form>
<Flex justify='end'>
<Button
style={{ margin: '0 8px' }}
onClick={() => {
setCurrentStep((prev) => prev - 1)
setNextEnabled(true)
}}
disabled={currentStep === 0}
>
Previous
</Button>
{currentStep < steps.length - 1 ? (
<Button
type='primary'
disabled={!nextEnabled}
onClick={() => {
setCurrentStep((prev) => prev + 1)
setNextEnabled(false)
}}
>
Next
</Button>
) : (
<Button
type='primary'
loading={newNoteTypeLoading}
onClick={() => {
newNoteTypeForm.submit()
}}
>
Done
</Button>
)}
</Flex>
</Flex>
</Flex>
)
}
NewNoteType.propTypes = {
onOk: PropTypes.func.isRequired,
reset: PropTypes.bool
}
export default NewNoteType

View File

@ -0,0 +1,402 @@
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,
Collapse,
Switch,
ColorPicker,
Checkbox,
Tag,
Dropdown,
Popover,
Badge
} from 'antd'
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
import IdText from '../../common/IdText'
import TimeDisplay from '../../common/TimeDisplay'
import ReloadIcon from '../../../Icons/ReloadIcon'
import EditIcon from '../../../Icons/EditIcon.jsx'
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
import CheckIcon from '../../../Icons/CheckIcon.jsx'
import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import config from '../../../../config.js'
const { Title } = Typography
const NoteTypeInfo = () => {
const [noteTypeData, setNoteTypeData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const noteTypeId = new URLSearchParams(location.search).get('noteTypeId')
const [isEditing, setIsEditing] = useState(false)
const [form] = Form.useForm()
const [colorEnabled, setColorEnabled] = useState(false)
const [collapseState, updateCollapseState] = useCollapseState(
'NoteTypeInfo',
{
info: true,
auditLogs: true
}
)
useEffect(() => {
if (noteTypeId) {
fetchNoteTypeDetails()
}
}, [noteTypeId])
useEffect(() => {
if (noteTypeData) {
form.setFieldsValue({
name: noteTypeData.name || '',
color: noteTypeData.color || '#000000',
active: noteTypeData.active ?? true
})
setColorEnabled(!!noteTypeData.color)
}
}, [noteTypeData, form])
const fetchNoteTypeDetails = async () => {
try {
setLoading(true)
const response = await axios.get(
`${config.backendUrl}/noteTypes/${noteTypeId}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
setNoteTypeData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch note type details')
messageApi.error('Failed to fetch note type details')
} finally {
setLoading(false)
}
}
const startEditing = () => {
updateCollapseState('info', true)
setIsEditing(true)
}
const cancelEditing = () => {
form.setFieldsValue({
name: noteTypeData?.name || '',
color: noteTypeData?.color || '#000000',
active: noteTypeData?.active ?? true
})
setIsEditing(false)
}
const updateInfo = async () => {
try {
const values = await form.validateFields()
setLoading(true)
await axios.put(`${config.backendUrl}/noteTypes/${noteTypeId}`, values, {
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
})
setNoteTypeData({ ...noteTypeData, ...values })
setIsEditing(false)
messageApi.success('Note type information updated successfully')
} catch (err) {
if (err.errorFields) {
return
}
console.error('Failed to update note type information:', err)
messageApi.error('Failed to update note type information')
} finally {
fetchNoteTypeDetails()
setLoading(false)
}
}
const getViewDropdownItems = () => {
const sections = [
{ key: 'info', label: 'Note Type Information' },
{ key: 'auditLogs', label: 'Audit Logs' }
]
return (
<Flex vertical>
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
{sections.map((section) => (
<Checkbox
checked={collapseState[section.key]}
key={section.key}
onChange={(e) => {
updateCollapseState(section.key, e.target.checked)
}}
>
{section.label}
</Checkbox>
))}
</Flex>
</Flex>
)
}
const actionItems = {
items: [
{
label: 'Reload Note Type',
key: 'reload',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchNoteTypeDetails()
}
}
}
return (
<>
<Flex justify='space-between'>
<Space size='small'>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button>View</Button>
</Popover>
</Space>
<Space size={'small'}>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateInfo}
loading={loading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
{error ? (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Note type not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchNoteTypeDetails}>
Retry
</Button>
</Space>
) : (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder}
<Flex vertical gap={'large'}>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Note Type Information
</Title>
</Flex>
}
key='1'
>
<Spin spinning={loading} indicator={<LoadingOutlined />}>
<Form form={form} layout='vertical'>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID'>
<IdText id={noteTypeData?._id} type='notetype' />
</Descriptions.Item>
<Descriptions.Item label='Created At'>
<TimeDisplay
dateTime={noteTypeData?.createdAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='name'
rules={[
{
required: true,
message: 'Please enter a note type name'
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : (
noteTypeData?.name
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
<TimeDisplay
dateTime={noteTypeData?.updatedAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Color'>
{isEditing ? (
<Flex gap='middle' align='center'>
<Form.Item
name='color'
style={{ margin: 0 }}
getValueFromEvent={(color) => {
if (color != null) {
return '#' + color.toHex()
}
return null
}}
>
<ColorPicker
showText={true}
disabled={!colorEnabled}
/>
</Form.Item>
<Checkbox
checked={colorEnabled}
onChange={(e) => {
setColorEnabled(e.target.checked)
if (!e.target.checked) {
form.setFieldValue('color', null)
} else if (e.target.checked) {
form.setFieldValue('color', '#000000')
}
}}
/>
</Flex>
) : noteTypeData?.color ? (
<Badge
color={noteTypeData.color}
text={noteTypeData.color}
/>
) : (
'No color set'
)}
</Descriptions.Item>
<Descriptions.Item label='Active'>
{isEditing ? (
<Form.Item
name='active'
valuePropName='checked'
style={{ margin: 0 }}
>
<Switch />
</Form.Item>
) : noteTypeData?.active ? (
<Tag color='success'>Yes</Tag>
) : (
<Tag color='error'>No</Tag>
)}
</Descriptions.Item>
</Descriptions>
</Form>
</Spin>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.auditLogs ? ['2'] : []}
onChange={(keys) =>
updateCollapseState('auditLogs', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Audit Logs
</Title>
}
key='2'
>
<AuditLogTable
items={noteTypeData?.auditLogs || []}
loading={loading}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</Flex>
</div>
)}
</>
)
}
export default NoteTypeInfo

View File

@ -1,28 +1,24 @@
// src/gcodefiles.js
import React, { useEffect, useState, useContext, useCallback } from 'react'
import React, { useState, useContext, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import {
Table,
Button,
Flex,
Space,
Modal,
Dropdown,
message,
Typography,
Spin,
Checkbox,
Popover,
Input
Input,
message
} from 'antd'
import { createStyles } from 'antd-style'
import { LoadingOutlined, DownloadOutlined } from '@ant-design/icons'
import { DownloadOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText'
import DashboardTable from '../common/DashboardTable'
import NewProduct from './Products/NewProduct'
import PartIcon from '../../Icons/PartIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
@ -37,35 +33,12 @@ import config from '../../../config'
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 Parts = () => {
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
const { styles } = useStyle()
const [partsData, setPartsData] = useState([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [newProductOpen, setNewProductOpen] = useState(false)
const tableRef = useRef()
const { authenticated } = useContext(AuthContext)
// Column definitions
const columns = [
@ -104,7 +77,7 @@ const Parts = () => {
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 165,
width: 180,
render: (text) => <IdText id={text} type={'part'} longId={false} />
},
{
@ -131,7 +104,7 @@ const Parts = () => {
{
title: 'Product ID',
key: 'productId',
width: 165,
width: 180,
render: (record) => (
<IdText
id={record.product._id}
@ -153,8 +126,7 @@ const Parts = () => {
return 'n/a'
}
},
sorter: true,
defaultSortOrder: 'descend'
sorter: true
},
{
title: 'Updated At',
@ -168,8 +140,7 @@ const Parts = () => {
return 'n/a'
}
},
sorter: true,
defaultSortOrder: 'descend'
sorter: true
},
{
title: 'Actions',
@ -196,89 +167,11 @@ const Parts = () => {
}
]
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({})
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
'Parts',
columns
)
const { authenticated } = useContext(AuthContext)
const fetchPartsData = useCallback(
async (pageNum = 1, append = false) => {
try {
const response = await axios.get(`${config.backendUrl}/parts`, {
params: {
page: pageNum,
limit: 25,
...filters,
sort: sorter.field,
order: sorter.order
},
headers: {
Accept: 'application/json'
},
withCredentials: true
})
const newData = response.data
setHasMore(newData.length === 25)
if (append) {
setPartsData((prev) => [...prev, ...newData])
} else {
setPartsData(newData)
}
setLoading(false)
setLazyLoading(false)
} 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.'
)
}
setLoading(false)
setLazyLoading(false)
}
},
[messageApi, filters, sorter]
)
useEffect(() => {
if (authenticated) {
fetchPartsData()
}
}, [authenticated, fetchPartsData])
const handleScroll = useCallback(
(e) => {
const { target } = e
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
// If we're near the bottom (within 100px) and not currently loading
if (
scrollHeight - scrollTop - clientHeight < 100 &&
!lazyLoading &&
hasMore
) {
setLazyLoading(true)
const nextPage = page + 1
setPage(nextPage)
fetchPartsData(nextPage, true)
}
},
[page, lazyLoading, hasMore, fetchPartsData]
)
const getPartActionItems = (id) => {
return {
items: [
@ -353,29 +246,13 @@ const Parts = () => {
],
onClick: ({ key }) => {
if (key === 'reloadList') {
setPage(1)
fetchPartsData(1)
tableRef.current?.reload()
} else if (key === 'newProduct') {
setNewProductOpen(true)
}
}
}
const handleTableChange = (pagination, filters, sorter) => {
const newFilters = {}
Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) {
newFilters[key] = value[0]
}
})
setPage(1)
setFilters(newFilters)
setSorter({
field: sorter.field,
order: sorter.order
})
}
const getViewDropdownItems = () => {
const columnItems = columns
.filter((col) => col.key && col.title !== '')
@ -421,19 +298,12 @@ const Parts = () => {
<Button>View</Button>
</Popover>
</Space>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Flex>
<Table
dataSource={partsData}
<DashboardTable
ref={tableRef}
columns={visibleColumns}
className={styles.customTable}
pagination={false}
scroll={{ y: 'calc(100vh - 270px)' }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
url={`${config.backendUrl}/parts`}
authenticated={authenticated}
/>
</Flex>
<Modal
@ -443,13 +313,13 @@ const Parts = () => {
onCancel={() => {
setNewProductOpen(false)
}}
destroyOnClose
destroyOnHidden={true}
>
<NewProduct
onOk={() => {
setNewProductOpen(false)
setPage(1)
fetchPartsData(1)
messageApi.success('Product created successfully!')
tableRef.current?.reload()
}}
reset={newProductOpen}
/>

View File

@ -208,7 +208,7 @@ const PartInfo = () => {
console.error('Failed to update part information:', err)
messageApi.error('Failed to update part information')
} finally {
fetchPartDetails()
await fetchPartDetails()
setLoading(false)
}
}
@ -238,282 +238,287 @@ const PartInfo = () => {
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder}
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Part Information
</Title>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateInfo}
loading={loading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
}
key='1'
<Flex vertical gap={'large'}>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Form
form={partForm}
layout='vertical'
onValuesChange={(changedValues) =>
setPartFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Part Information
</Title>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateInfo}
loading={loading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
}
initialValues={{
name: partData.name || '',
version: partData.version || '',
tags: partData.tags || []
}}
key='1'
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
<Form
form={partForm}
layout='vertical'
onValuesChange={(changedValues) =>
setPartFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={{
name: partData.name || '',
version: partData.version || '',
tags: partData.tags || []
}}
>
<Descriptions.Item label='ID' span={1}>
{partData.id ? (
<IdText id={partData.id} type='part'></IdText>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
<TimeDisplay dateTime={partData.createdAt} showSince={true} />
</Descriptions.Item>
<Descriptions.Item label='Name' span={1}>
{isEditing ? (
<Form.Item
name='name'
rules={[
{
required: true,
message: 'Please enter a product name'
},
{ max: 100, message: 'Name cannot exceed 100 characters' }
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter product name' />
</Form.Item>
) : (
partData.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
<TimeDisplay dateTime={partData.updatedAt} showSince={true} />
</Descriptions.Item>
<Descriptions.Item label='Product Name' span={1}>
{partData.product.name || 'n/a'}
</Descriptions.Item>
<Descriptions.Item label='Product ID' span={1}>
{(
<IdText
id={partData.product._id}
type={'product'}
showHyperlink={true}
/>
) || 'n/a'}
</Descriptions.Item>
<Descriptions.Item
label={!marginOrPrice ? 'Margin' : 'Price'}
span={1}
>
{isEditing && useGlobalPricing == false ? (
<Flex gap='middle'>
{marginOrPrice == false ? (
<Form.Item
name='margin'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a margin.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonAfter='%'
/>
</Form.Item>
) : (
<Form.Item
name='price'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a price.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonBefore='£'
/>
</Form.Item>
)}
<Form.Item
name='marginOrPrice'
valuePropName='checked'
style={{ margin: 0 }}
>
<Checkbox>Price</Checkbox>
</Form.Item>
</Flex>
) : partData.margin &&
marginOrPrice == false &&
partData.useGlobalPricing == false ? (
partData.margin + '%'
) : partData.price &&
marginOrPrice == true &&
partData.useGlobalPricing == false ? (
'£' + partData.price
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Global Pricing'>
{isEditing ? (
<Form.Item
name='useGlobalPricing'
rules={[
{
required: true,
message: 'Please enter a global price method'
}
]}
style={{ margin: 0 }}
valuePropName='checked'
>
<Switch />
</Form.Item>
) : partData.useGlobalPricing == true ? (
<Tag color='success' icon={<CheckIcon />}>
Yes
</Tag>
) : partData.useGlobalPricing == false ? (
<Tag icon={<XMarkIcon />}>No</Tag>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Version' span={1}>
{partData.version || 'n/a'}
</Descriptions.Item>
<Descriptions.Item label='Tags'>
{partData.tags &&
partData.tags.map((tag, index) => (
<Tag key={index}>{tag}</Tag>
))}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.preview ? ['2'] : []}
onChange={(keys) => updateCollapseState('preview', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Part Preview
</Title>
}
key='2'
>
<Card styles={{ body: { padding: '10px' } }}>
{stlLoadError ? (
<div
style={{
height: '40vw',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f5f5f5'
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Space direction='vertical' align='center'>
<XMarkIcon style={{ fontSize: '24px', color: '#ff4d4f' }} />
<Typography.Text type='danger'>
{stlLoadError}
</Typography.Text>
</Space>
</div>
) : (
partFileObjectId && (
<StlViewer
url={partFileObjectId}
orbitControls
shadows
style={{ height: '40vw' }}
modelProps={{
color: '#008675'
<Descriptions.Item label='ID' span={1}>
{partData.id ? (
<IdText id={partData.id} type='part'></IdText>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
<TimeDisplay dateTime={partData.createdAt} showSince={true} />
</Descriptions.Item>
<Descriptions.Item label='Name' span={1}>
{isEditing ? (
<Form.Item
name='name'
rules={[
{
required: true,
message: 'Please enter a product name'
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter product name' />
</Form.Item>
) : (
partData.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
<TimeDisplay dateTime={partData.updatedAt} showSince={true} />
</Descriptions.Item>
<Descriptions.Item label='Product Name' span={1}>
{partData.product.name || 'n/a'}
</Descriptions.Item>
<Descriptions.Item label='Product ID' span={1}>
{(
<IdText
id={partData.product._id}
type={'product'}
showHyperlink={true}
/>
) || 'n/a'}
</Descriptions.Item>
<Descriptions.Item
label={!marginOrPrice ? 'Margin' : 'Price'}
span={1}
>
{isEditing && useGlobalPricing == false ? (
<Flex gap='middle'>
{marginOrPrice == false ? (
<Form.Item
name='margin'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a margin.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonAfter='%'
/>
</Form.Item>
) : (
<Form.Item
name='price'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a price.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonBefore='£'
/>
</Form.Item>
)}
<Form.Item
name='marginOrPrice'
valuePropName='checked'
style={{ margin: 0 }}
>
<Checkbox>Price</Checkbox>
</Form.Item>
</Flex>
) : partData.margin &&
marginOrPrice == false &&
partData.useGlobalPricing == false ? (
partData.margin + '%'
) : partData.price &&
marginOrPrice == true &&
partData.useGlobalPricing == false ? (
'£' + partData.price
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Global Pricing'>
{isEditing ? (
<Form.Item
name='useGlobalPricing'
rules={[
{
required: true,
message: 'Please enter a global price method'
}
]}
style={{ margin: 0 }}
valuePropName='checked'
>
<Switch />
</Form.Item>
) : partData.useGlobalPricing == true ? (
<Tag color='success' icon={<CheckIcon />}>
Yes
</Tag>
) : partData.useGlobalPricing == false ? (
<Tag icon={<XMarkIcon />}>No</Tag>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Version' span={1}>
{partData.version || 'n/a'}
</Descriptions.Item>
<Descriptions.Item label='Tags'>
{partData.tags &&
partData.tags.map((tag, index) => (
<Tag key={index}>{tag}</Tag>
))}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.preview ? ['2'] : []}
onChange={(keys) => updateCollapseState('preview', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Part Preview
</Title>
}
key='2'
>
<Card styles={{ body: { padding: '10px' } }}>
{stlLoadError ? (
<div
style={{
height: '40vw',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f5f5f5'
}}
></StlViewer>
)
)}
</Card>
</Collapse.Panel>
</Collapse>
>
<Space direction='vertical' align='center'>
<XMarkIcon style={{ fontSize: '24px', color: '#ff4d4f' }} />
<Typography.Text type='danger'>
{stlLoadError}
</Typography.Text>
</Space>
</div>
) : (
partFileObjectId && (
<StlViewer
url={partFileObjectId}
orbitControls
shadows
style={{ height: '40vw' }}
modelProps={{
color: '#008675'
}}
></StlViewer>
)
)}
</Card>
</Collapse.Panel>
</Collapse>
</Flex>
</div>
)
}

View File

@ -1,29 +1,25 @@
// src/gcodefiles.js
import React, { useEffect, useState, useContext, useCallback } from 'react'
import React, { useState, useContext, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import {
Table,
Button,
Flex,
Space,
Modal,
Dropdown,
message,
Spin,
Tag,
Checkbox,
Popover,
Input
} from 'antd'
import { createStyles } from 'antd-style'
import { LoadingOutlined, DownloadOutlined } from '@ant-design/icons'
import { DownloadOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText'
import TimeDisplay from '../common/TimeDisplay'
import DashboardTable from '../common/DashboardTable'
import NewProduct from './Products/NewProduct'
import ProductIcon from '../../Icons/ProductIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
@ -31,120 +27,17 @@ import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon'
import useColumnVisibility from '../hooks/useColumnVisibility'
import config from '../../../config'
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 [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({})
const [newProductOpen, setNewProductOpen] = useState(false)
const [loading, setLoading] = useState(true)
const tableRef = useRef()
const { authenticated } = useContext(AuthContext)
const fetchProductsData = useCallback(
async (pageNum = 1, append = false) => {
try {
const response = await axios.get(`${config.backendUrl}/products`, {
params: {
page: pageNum,
limit: 25,
...filters,
sort: sorter.field,
order: sorter.order
},
headers: {
Accept: 'application/json'
},
withCredentials: true
})
const newData = response.data
setHasMore(newData.length === 25)
if (append) {
setProductsData((prev) => [...prev, ...newData])
} else {
setProductsData(newData)
}
setLoading(false)
setLazyLoading(false)
} 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.'
)
}
setLoading(false)
setLazyLoading(false)
}
},
[messageApi, filters, sorter]
)
useEffect(() => {
if (authenticated) {
fetchProductsData()
}
}, [authenticated, fetchProductsData])
const handleScroll = useCallback(
(e) => {
const { target } = e
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
if (
scrollHeight - scrollTop - clientHeight < 100 &&
!lazyLoading &&
hasMore
) {
setLazyLoading(true)
const nextPage = page + 1
setPage(nextPage)
fetchProductsData(nextPage, true)
}
},
[page, lazyLoading, hasMore, fetchProductsData]
)
const getProductActionItems = (id) => {
return {
items: [
@ -205,7 +98,7 @@ const Products = () => {
dataIndex: '_id',
key: 'id',
fixed: 'left',
width: 165,
width: 180,
render: (text) => <IdText id={text} type={'product'} longId={false} />,
filterDropdown: ({
setSelectedKeys,
@ -360,7 +253,7 @@ const Products = () => {
],
onClick: ({ key }) => {
if (key === 'reloadList') {
fetchProductsData()
tableRef.current?.reload()
} else if (key === 'newProduct') {
setNewProductOpen(true)
}
@ -427,21 +320,6 @@ const Products = () => {
)
}
const handleTableChange = (pagination, filters, sorter) => {
const newFilters = {}
Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) {
newFilters[key] = value[0]
}
})
setPage(1)
setFilters(newFilters)
setSorter({
field: sorter.field,
order: sorter.order
})
}
const visibleColumns = columns.filter(
(col) => !col.key || columnVisibility[col.key]
)
@ -463,19 +341,12 @@ const Products = () => {
<Button>View</Button>
</Popover>
</Space>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Flex>
<Table
dataSource={productsData}
<DashboardTable
ref={tableRef}
columns={visibleColumns}
className={styles.customTable}
pagination={false}
scroll={{ y: 'calc(100vh - 270px)' }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
url={`${config.backendUrl}/products`}
authenticated={authenticated}
/>
</Flex>
<Modal
@ -485,13 +356,13 @@ const Products = () => {
onCancel={() => {
setNewProductOpen(false)
}}
destroyOnClose
destroyOnHidden={true}
>
<NewProduct
onOk={() => {
setNewProductOpen(false)
messageApi.success('Product created successfully!')
fetchProductsData()
tableRef.current?.reload()
}}
reset={newProductOpen}
/>

View File

@ -1,6 +1,7 @@
import PropTypes from 'prop-types'
import React, { useState, useContext, useEffect, useRef } from 'react'
import axios from 'axios'
import { useMediaQuery } from 'react-responsive'
import {
Input,
Button,
@ -65,6 +66,8 @@ const NewProduct = ({ onOk, reset }) => {
const { token, authenticated } = useContext(AuthContext)
const isMobile = useMediaQuery({ maxWidth: 768 })
useEffect(() => {
newProductForm
.validateFields({
@ -393,16 +396,18 @@ const NewProduct = ({ onOk, reset }) => {
<Flex gap='middle'>
{contextHolder}
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
{!isMobile && (
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
)}
<Divider type='vertical' style={{ height: 'unset' }} />
{!isMobile && <Divider type='vertical' style={{ height: 'unset' }} />}
<Flex vertical gap='middle' style={{ flexGrow: 1 }}>
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>

View File

@ -72,6 +72,7 @@ const ProductInfo = () => {
useEffect(() => {
async function fetchData() {
console.log('hello')
await fetchProductDetails()
}
if (productId) {
@ -114,7 +115,6 @@ const ProductInfo = () => {
console.log(err)
messageApi.error('Failed to fetch product details')
} finally {
fetchProductDetails()
setFetchLoading(false)
}
}
@ -164,6 +164,7 @@ const ProductInfo = () => {
console.error('Failed to update product information:', err)
messageApi.error('Failed to update product information')
} finally {
await fetchProductDetails()
setLoading(false)
}
}
@ -193,285 +194,290 @@ const ProductInfo = () => {
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder}
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Product Information
</Title>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateInfo}
loading={loading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
}
key='1'
<Flex vertical gap={'large'}>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Form
form={productForm}
layout='vertical'
onValuesChange={(changedValues) =>
setProductFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Product Information
</Title>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateInfo}
loading={loading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
}
initialValues={{
name: productData.name || '',
vendor: productData.vendor || { id: null, name: '' },
version: productData.version || '',
tags: productData.tags || []
}}
key='1'
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
<Form
form={productForm}
layout='vertical'
onValuesChange={(changedValues) =>
setProductFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={{
name: productData.name || '',
vendor: productData.vendor || { id: null, name: '' },
version: productData.version || '',
tags: productData.tags || []
}}
>
<Descriptions.Item label='ID' span={1}>
{productData.id ? (
<IdText id={productData.id} type='product'></IdText>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
<TimeDisplay
dateTime={productData.createdAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Name' span={1}>
{isEditing ? (
<Form.Item
name='name'
rules={[
{
required: true,
message: 'Please enter a product name'
},
{ max: 100, message: 'Name cannot exceed 100 characters' }
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter product name' />
</Form.Item>
) : (
productData.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
<TimeDisplay
dateTime={productData.updatedAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Vendor'>
{isEditing ? (
<Form.Item
name='vendor'
rules={[
{ required: true, message: 'Please enter a vendor' }
]}
style={{ margin: 0 }}
>
<VendorSelect />
</Form.Item>
) : (
productData.vendor.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor ID'>
<IdText
id={productData.vendor.id}
type={'vendor'}
showHyperlink={true}
/>
</Descriptions.Item>
<Descriptions.Item
label={!marginOrPrice ? 'Margin' : 'Price'}
span={1}
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
{isEditing ? (
<Flex gap='middle'>
{marginOrPrice == false ? (
<Form.Item
name='margin'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a margin.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonAfter='%'
/>
</Form.Item>
) : (
<Form.Item
name='price'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a price.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonBefore='£'
/>
</Form.Item>
)}
<Descriptions.Item label='ID' span={1}>
{productData.id ? (
<IdText id={productData.id} type='product'></IdText>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
<TimeDisplay
dateTime={productData.createdAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Name' span={1}>
{isEditing ? (
<Form.Item
name='marginOrPrice'
valuePropName='checked'
name='name'
rules={[
{
required: true,
message: 'Please enter a product name'
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Checkbox>Price</Checkbox>
<Input placeholder='Enter product name' />
</Form.Item>
</Flex>
) : productData.margin && marginOrPrice == false ? (
productData.margin + '%'
) : productData.price && marginOrPrice == true ? (
'£' + productData.price
) : (
'n/a'
)}
</Descriptions.Item>
) : (
productData.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Version' span={1}>
{isEditing ? (
<Form.Item name='version' style={{ margin: 0 }}>
<Input placeholder='Enter version' />
</Form.Item>
) : productData.version ? (
<Tag>{productData.version}</Tag>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
<TimeDisplay
dateTime={productData.updatedAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Tags' span={1}>
{isEditing ? (
<Form.Item name='tags' style={{ margin: 0 }}>
<Space
size={[0, 2]}
wrap
style={{ marginBottom: 4, maxWidth: '300px' }}
<Descriptions.Item label='Vendor'>
{isEditing ? (
<Form.Item
name='vendor'
rules={[
{ required: true, message: 'Please enter a vendor' }
]}
style={{ margin: 0 }}
>
{productData.tags.map((tag) => (
<Tag
key={tag}
color='blue'
closable
onClose={() => handleTagClose(tag)}
style={{ marginBottom: 12 }}
<VendorSelect />
</Form.Item>
) : (
productData.vendor.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor ID'>
<IdText
id={productData.vendor.id}
type={'vendor'}
showHyperlink={true}
/>
</Descriptions.Item>
<Descriptions.Item
label={!marginOrPrice ? 'Margin' : 'Price'}
span={1}
>
{isEditing ? (
<Flex gap='middle'>
{marginOrPrice == false ? (
<Form.Item
name='margin'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a margin.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonAfter='%'
/>
</Form.Item>
) : (
<Form.Item
name='price'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a price.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonBefore='£'
/>
</Form.Item>
)}
<Form.Item
name='marginOrPrice'
valuePropName='checked'
style={{ margin: 0 }}
>
<Checkbox>Price</Checkbox>
</Form.Item>
</Flex>
) : productData.margin && marginOrPrice == false ? (
productData.margin + '%'
) : productData.price && marginOrPrice == true ? (
'£' + productData.price
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Version' span={1}>
{isEditing ? (
<Form.Item name='version' style={{ margin: 0 }}>
<Input placeholder='Enter version' />
</Form.Item>
) : productData.version ? (
<Tag>{productData.version}</Tag>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Tags' span={1}>
{isEditing ? (
<Form.Item name='tags' style={{ margin: 0 }}>
<Space
size={[0, 2]}
wrap
style={{ marginBottom: 4, maxWidth: '300px' }}
>
{productData.tags.map((tag) => (
<Tag
key={tag}
color='blue'
closable
onClose={() => handleTagClose(tag)}
style={{ marginBottom: 12 }}
>
{tag}
</Tag>
))}
</Space>
<Space.Compact block>
<Form.Item name='newTag' noStyle>
<Input placeholder='Add new tag' />
</Form.Item>
<Button onClick={handleTagAdd} icon={<PlusIcon />} />
</Space.Compact>
</Form.Item>
) : productData.tags?.length > 0 ? (
<Space size={[0, 2]} wrap style={{ maxWidth: '300px' }}>
{productData.tags.map((tag, index) => (
<Tag key={index} color='blue'>
{tag}
</Tag>
))}
</Space>
<Space.Compact block>
<Form.Item name='newTag' noStyle>
<Input placeholder='Add new tag' />
</Form.Item>
<Button onClick={handleTagAdd} icon={<PlusIcon />} />
</Space.Compact>
</Form.Item>
) : productData.tags?.length > 0 ? (
<Space size={[0, 2]} wrap style={{ maxWidth: '300px' }}>
{productData.tags.map((tag, index) => (
<Tag key={index} color='blue'>
{tag}
</Tag>
))}
</Space>
) : (
'n/a'
)}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
) : (
'n/a'
)}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.parts ? ['2'] : []}
onChange={(keys) => updateCollapseState('parts', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Product Parts
</Title>
}
key='2'
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.parts ? ['2'] : []}
onChange={(keys) => updateCollapseState('parts', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<PartsTable data={productData.parts} />
</Collapse.Panel>
</Collapse>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Product Parts
</Title>
}
key='2'
>
<PartsTable data={productData.parts} />
</Collapse.Panel>
</Collapse>
</Flex>
</div>
)
}

View File

@ -50,67 +50,72 @@ const Settings = () => {
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.appearance ? ['1'] : []}
onChange={(keys) => updateCollapseState('appearance', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Appearance Settings
</Title>
</Flex>
<Flex vertical gap={'large'}>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.appearance ? ['1'] : []}
onChange={(keys) =>
updateCollapseState('appearance', keys.length > 0)
}
key='1'
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse'
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Appearance Settings
</Title>
</Flex>
}
key='1'
>
<Descriptions.Item label='Theme'>
<Select
value={getCurrentThemeValue()}
onChange={handleThemeChange}
style={{ width: '100%' }}
>
<Option value='light'>Light</Option>
<Option value='dark'>Dark</Option>
<Option value='system'>System</Option>
</Select>
</Descriptions.Item>
<Descriptions.Item label='UI Density'>
<Select
value={isCompact ? 'compact' : 'comfortable'}
onChange={handleCompactChange}
style={{ width: '100%' }}
>
<Option value='comfortable'>Comfortable</Option>
<Option value='compact'>Compact</Option>
</Select>
</Descriptions.Item>
</Descriptions>
</Collapse.Panel>
</Collapse>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='Theme'>
<Select
value={getCurrentThemeValue()}
onChange={handleThemeChange}
style={{ width: '100%' }}
>
<Option value='light'>Light</Option>
<Option value='dark'>Dark</Option>
<Option value='system'>System</Option>
</Select>
</Descriptions.Item>
<Descriptions.Item label='UI Density'>
<Select
value={isCompact ? 'compact' : 'comfortable'}
onChange={handleCompactChange}
style={{ width: '100%' }}
>
<Option value='comfortable'>Comfortable</Option>
<Option value='compact'>Compact</Option>
</Select>
</Descriptions.Item>
</Descriptions>
</Collapse.Panel>
</Collapse>
</Flex>
</div>
)
}

View File

@ -1,8 +1,6 @@
import React, { useState, useContext, useCallback, useEffect } from 'react'
import React, { useState, useContext, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import {
Table,
Button,
Flex,
Space,
@ -12,16 +10,15 @@ import {
Typography,
Checkbox,
Popover,
Input,
Spin
Input
} from 'antd'
import { createStyles } from 'antd-style'
import { LoadingOutlined, ExportOutlined } from '@ant-design/icons'
import { ExportOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText'
import NewVendor from './Vendors/NewVendor'
import CountryDisplay from '../common/CountryDisplay'
import TimeDisplay from '../common/TimeDisplay'
import DashboardTable from '../common/DashboardTable'
import VendorIcon from '../../Icons/VendorIcon'
import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
@ -34,103 +31,13 @@ import config from '../../../config'
const { Link } = 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 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 [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({})
const tableRef = useRef()
const { authenticated } = useContext(AuthContext)
const fetchVendorsData = useCallback(
async (pageNum = 1, append = false) => {
try {
const response = await axios.get(`${config.backendUrl}/vendors`, {
params: {
page: pageNum,
limit: 25,
...filters,
sort: sorter.field,
order: sorter.order
},
headers: {
Accept: 'application/json'
},
withCredentials: true
})
const newData = response.data
setHasMore(newData.length === 25)
if (append) {
setVendorsData((prev) => [...prev, ...newData])
} else {
setVendorsData(newData)
}
setLoading(false)
setLazyLoading(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.'
)
}
setLoading(false)
setLazyLoading(false)
}
},
[messageApi, filters, sorter]
)
const handleScroll = useCallback(
(e) => {
const { target } = e
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
if (
scrollHeight - scrollTop - clientHeight < 100 &&
!lazyLoading &&
hasMore
) {
setLazyLoading(true)
const nextPage = page + 1
setPage(nextPage)
fetchVendorsData(nextPage, true)
}
},
[page, lazyLoading, hasMore, fetchVendorsData]
)
const getFilterDropdown = ({
setSelectedKeys,
selectedKeys,
@ -191,21 +98,6 @@ const Vendors = () => {
)
}
const handleTableChange = (pagination, filters, sorter) => {
const newFilters = {}
Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) {
newFilters[key] = value[0]
}
})
setPage(1)
setFilters(newFilters)
setSorter({
field: sorter.field,
order: sorter.order
})
}
const getVendorActionItems = (id) => {
return {
items: [
@ -259,7 +151,7 @@ const Vendors = () => {
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 165,
width: 180,
render: (text) => <IdText id={text} type={'vendor'} longId={false} />,
filterDropdown: ({
setSelectedKeys,
@ -435,19 +327,13 @@ const Vendors = () => {
],
onClick: ({ key }) => {
if (key === 'reloadList') {
fetchVendorsData()
tableRef.current?.reload()
} else if (key === 'newVendor') {
setNewVendorOpen(true)
}
}
}
useEffect(() => {
if (authenticated) {
fetchVendorsData()
}
}, [authenticated, fetchVendorsData])
return (
<>
<Flex vertical={'true'} gap='large'>
@ -465,33 +351,26 @@ const Vendors = () => {
<Button>View</Button>
</Popover>
</Space>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Flex>
<Table
dataSource={vendorsData}
<DashboardTable
ref={tableRef}
columns={visibleColumns}
className={styles.customTable}
pagination={false}
scroll={{ y: 'calc(100vh - 270px)' }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
url={`${config.backendUrl}/vendors`}
authenticated={authenticated}
/>
</Flex>
<Modal
open={newVendorOpen}
onCancel={() => setNewVendorOpen(false)}
footer={null}
destroyOnClose
destroyOnHidden={true}
width={700}
>
<NewVendor
onOk={() => {
setNewVendorOpen(false)
messageApi.success('New vendor created successfully.')
fetchVendorsData()
tableRef.current?.reload()
}}
reset={!newVendorOpen}
/>

View File

@ -1,6 +1,7 @@
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import axios from 'axios'
import { useMediaQuery } from 'react-responsive'
import {
Form,
Input,
@ -37,6 +38,7 @@ const NewVendor = ({ onOk, reset }) => {
const [newVendorForm] = Form.useForm()
const [newVendorFormValues, setNewVendorFormValues] =
useState(initialNewVendorForm)
const isMobile = useMediaQuery({ maxWidth: 768 })
const newVendorFormUpdateValues = Form.useWatch([], newVendorForm)
@ -181,16 +183,18 @@ const NewVendor = ({ onOk, reset }) => {
<Flex gap='middle'>
{contextHolder}
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
{!isMobile && (
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
)}
<Divider type='vertical' style={{ height: 'unset' }} />
{!isMobile && <Divider type='vertical' style={{ height: 'unset' }} />}
<Flex vertical gap='middle' style={{ flexGrow: 1 }}>
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>

View File

@ -156,202 +156,216 @@ const VendorInfo = () => {
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder}
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Vendor Information
</Title>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateInfo}
loading={loading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
}
key='1'
<Flex vertical gap={'large'}>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Form form={form} layout='vertical'>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID'>
<IdText id={vendorData._id} type='vendor' />
</Descriptions.Item>
<Descriptions.Item label='Created At'>
<TimeDisplay dateTime={vendorData.createdAt} showSince={true} />
</Descriptions.Item>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Vendor Information
</Title>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateInfo}
loading={loading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
}
key='1'
>
<Form form={form} layout='vertical'>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID'>
<IdText id={vendorData._id} type='vendor' />
</Descriptions.Item>
<Descriptions.Item label='Created At'>
<TimeDisplay
dateTime={vendorData.createdAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='name'
rules={[
{ required: true, message: 'Please enter a vendor name' },
{ max: 100, message: 'Name cannot exceed 100 characters' }
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : (
vendorData.name
)}
</Descriptions.Item>
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='name'
rules={[
{
required: true,
message: 'Please enter a vendor name'
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : (
vendorData.name
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
<TimeDisplay dateTime={vendorData.updatedAt} showSince={true} />
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
<TimeDisplay
dateTime={vendorData.updatedAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Website'>
{isEditing ? (
<Form.Item
name='website'
rules={[
{ type: 'url', message: 'Please enter a valid URL' },
{
max: 200,
message: 'Website URL cannot exceed 200 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData.website ? (
<Link
href={vendorData.website}
target='_blank'
rel='noopener noreferrer'
>
{new URL(vendorData.website).hostname + ' '}
<ExportOutlined />
</Link>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Website'>
{isEditing ? (
<Form.Item
name='website'
rules={[
{ type: 'url', message: 'Please enter a valid URL' },
{
max: 200,
message: 'Website URL cannot exceed 200 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData.website ? (
<Link
href={vendorData.website}
target='_blank'
rel='noopener noreferrer'
>
{new URL(vendorData.website).hostname + ' '}
<ExportOutlined />
</Link>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Country'>
{isEditing ? (
<Form.Item name='country' style={{ margin: 0 }}>
<CountrySelect countryCode={vendorData.country} />
</Form.Item>
) : vendorData.country ? (
<CountryDisplay countryCode={vendorData.country} />
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Country'>
{isEditing ? (
<Form.Item name='country' style={{ margin: 0 }}>
<CountrySelect countryCode={vendorData.country} />
</Form.Item>
) : vendorData.country ? (
<CountryDisplay countryCode={vendorData.country} />
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Contact'>
{isEditing ? (
<Form.Item
name='contact'
rules={[
{
max: 200,
message: 'Contact info cannot exceed 200 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData.contact ? (
vendorData.contact
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Contact'>
{isEditing ? (
<Form.Item
name='contact'
rules={[
{
max: 200,
message: 'Contact info cannot exceed 200 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData.contact ? (
vendorData.contact
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Phone'>
{isEditing ? (
<Form.Item
name='phone'
rules={[
{
type: 'phone',
message: 'Please enter a valid phone number'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData.phone ? (
vendorData.phone
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Phone'>
{isEditing ? (
<Form.Item
name='phone'
rules={[
{
type: 'phone',
message: 'Please enter a valid phone number'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData.phone ? (
vendorData.phone
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Email'>
{isEditing ? (
<Form.Item
name='email'
rules={[
{
type: 'email',
message: 'Please enter a valid email'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData.email ? (
<Link href={`mailto:${vendorData.email}`}>
{vendorData.email + ' '}
<ExportOutlined />
</Link>
) : (
'n/a'
)}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
<Descriptions.Item label='Email'>
{isEditing ? (
<Form.Item
name='email'
rules={[
{
type: 'email',
message: 'Please enter a valid email'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData.email ? (
<Link href={`mailto:${vendorData.email}`}>
{vendorData.email + ' '}
<ExportOutlined />
</Link>
) : (
'n/a'
)}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
</Flex>
</div>
)
}

View File

@ -1,10 +1,9 @@
// src/gcodefiles.js
import React, { useEffect, useState, useContext, useCallback } from 'react'
import React, { useState, useContext, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import {
Table,
Badge,
Button,
Flex,
@ -16,11 +15,9 @@ import {
Checkbox,
Divider,
Popover,
Input,
Spin
Input
} from 'antd'
import { createStyles } from 'antd-style'
import { LoadingOutlined, DownloadOutlined } from '@ant-design/icons'
import { DownloadOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext'
import NewGCodeFile from './GCodeFiles/NewGCodeFile'
@ -33,33 +30,18 @@ import ReloadIcon from '../../Icons/ReloadIcon'
import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon'
import TimeDisplay from '../common/TimeDisplay'
import DashboardTable from '../common/DashboardTable'
import config from '../../../config'
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 [newGCodeFileOpen, setNewGCodeFileOpen] = useState(false)
const [showDeleted, setShowDeleted] = useState(false)
const tableRef = useRef()
const getFilterDropdown = ({
setSelectedKeys,
@ -96,6 +78,7 @@ const GCodeFiles = () => {
</div>
)
}
// Column definitions
const columns = [
{
@ -133,8 +116,8 @@ const GCodeFiles = () => {
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 165,
render: (text) => <IdText id={text} type={'gcodeFile'} longId={false} />
width: 180,
render: (text) => <IdText id={text} type={'gcodefile'} longId={false} />
},
{
title: 'Filament',
@ -234,15 +217,7 @@ const GCodeFiles = () => {
}
}
]
const [gcodeFilesData, setGCodeFilesData] = useState([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [newGCodeFileOpen, setNewGCodeFileOpen] = useState(false)
const [showDeleted, setShowDeleted] = useState(false)
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({})
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
'GCodeFiles',
columns
@ -250,94 +225,6 @@ const GCodeFiles = () => {
const { authenticated } = useContext(AuthContext)
const fetchGCodeFilesData = useCallback(
async (pageNum = 1, append = false) => {
try {
const params = {
page: pageNum,
limit: 25,
...filters,
sort: sorter.field,
order: sorter.order
}
const response = await axios.get(`${config.backendUrl}/gcodefiles`, {
params,
headers: {
Accept: 'application/json'
},
withCredentials: true
})
const newData = response.data
setHasMore(newData.length === 25) // If we get less than 25 items, we've reached the end
if (append) {
setGCodeFilesData((prev) => [...prev, ...newData])
} else {
setGCodeFilesData(newData)
}
setLoading(false)
setLazyLoading(false)
} catch (error) {
if (error.response) {
messageApi.error('Error fetching gcode files:', error.response.status)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
setLoading(false)
setLazyLoading(false)
}
},
[messageApi, filters, sorter]
)
const handleScroll = useCallback(
(e) => {
const { target } = e
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
// If we're near the bottom (within 100px) and not currently loading
if (
scrollHeight - scrollTop - clientHeight < 100 &&
!lazyLoading &&
hasMore
) {
setLazyLoading(true)
const nextPage = page + 1
setPage(nextPage)
fetchGCodeFilesData(nextPage, true)
}
},
[page, lazyLoading, hasMore, fetchGCodeFilesData]
)
useEffect(() => {
if (authenticated) {
fetchGCodeFilesData()
}
}, [authenticated, fetchGCodeFilesData])
const handleTableChange = (pagination, filters, sorter) => {
const newFilters = {}
Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) {
newFilters[key] = value[0] // Take the first filter value
}
})
setFilters(newFilters)
setSorter({
field: sorter.field,
order: sorter.order
})
}
const getGCodeFileActionItems = (id) => {
return {
items: [
@ -358,7 +245,8 @@ const GCodeFiles = () => {
} else if (key === 'download') {
handleDownloadGCode(
id,
gcodeFilesData.find((file) => file._id === id)?.name + '.gcode'
tableRef.current?.getData().find((file) => file._id === id)?.name +
'.gcode'
)
}
}
@ -376,24 +264,16 @@ const GCodeFiles = () => {
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
withCredentials: true
}
)
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) {
@ -425,7 +305,7 @@ const GCodeFiles = () => {
],
onClick: ({ key }) => {
if (key === 'reloadList') {
fetchGCodeFilesData()
tableRef.current?.reload()
} else if (key === 'newGCodeFile') {
setNewGCodeFileOpen(true)
}
@ -484,19 +364,13 @@ const GCodeFiles = () => {
>
<Button>View</Button>
</Popover>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Space>
<Table
dataSource={gcodeFilesData}
<DashboardTable
ref={tableRef}
columns={visibleColumns}
className={styles.customTable}
pagination={false}
scroll={{ y: 'calc(100vh - 270px)' }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
onChange={handleTableChange}
onScroll={handleScroll}
showSorterTooltip={false}
url={`${config.backendUrl}/gcodefiles`}
authenticated={authenticated}
/>
</Flex>
<Modal
@ -506,13 +380,13 @@ const GCodeFiles = () => {
onCancel={() => {
setNewGCodeFileOpen(false)
}}
destroyOnClose
destroyOnHidden={true}
>
<NewGCodeFile
onOk={() => {
setNewGCodeFileOpen(false)
messageApi.success('Finished uploading GCode file!')
fetchGCodeFilesData()
tableRef.current?.reload()
}}
reset={newGCodeFileOpen}
/>

View File

@ -155,255 +155,263 @@ const GCodeFileInfo = () => {
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder}
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
GCode File Information
</Title>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateInfo}
loading={loading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
}
key='1'
<Flex vertical gap={'large'}>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Form form={form} layout='vertical'>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID' span={1}>
{gcodeFileData.id ? (
<IdText id={gcodeFileData.id} type='gcodeFile'></IdText>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
<TimeDisplay
dateTime={gcodeFileData.createdAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='name'
rules={[
{ required: true, message: 'Please enter a vendor name' },
{ max: 100, message: 'Name cannot exceed 100 characters' }
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : (
gcodeFileData.name
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
<TimeDisplay
dateTime={gcodeFileData.updatedAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Filament Name'>
{isEditing ? (
<Form.Item
name='filament'
rules={[
{ required: true, message: 'Please enter a filament' }
]}
style={{ margin: 0 }}
>
<FilamentSelect />
</Form.Item>
) : gcodeFileData.filament ? (
<Space>
<FilamentIcon />
<Badge
color={gcodeFileData.filament.color}
text={gcodeFileData.filament.name}
/>
</Space>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Filament ID'>
{gcodeFileData.filament ? (
<IdText
id={gcodeFileData.filament.id}
type={'filament'}
showHyperlink={true}
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
GCode File Information
</Title>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateInfo}
loading={loading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
}
key='1'
>
<Form form={form} layout='vertical'>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID' span={1}>
{gcodeFileData.id ? (
<IdText id={gcodeFileData.id} type='gcodeFile'></IdText>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
<TimeDisplay
dateTime={gcodeFileData.createdAt}
showSince={true}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Est Print Time'>
{gcodeFileData.gcodeFileInfo.estimatedPrintingTimeNormalMode ||
'n/a'}
</Descriptions.Item>
<Descriptions.Item label='Cost'>
{'£' + gcodeFileData.cost.toFixed(2) || 'n/a'}
</Descriptions.Item>
<Descriptions.Item label='Infill Density'>
{(() => {
if (gcodeFileData.gcodeFileInfo.sparseInfillDensity) {
return gcodeFileData.gcodeFileInfo.sparseInfillDensity
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Infill Pattern'>
{(() => {
if (gcodeFileData.gcodeFileInfo.sparseInfillPattern) {
return capitalizeFirstLetter(
gcodeFileData.gcodeFileInfo.sparseInfillPattern
)
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Filament Used (mm)'>
{(() => {
if (gcodeFileData.gcodeFileInfo.filamentUsedMm) {
return `${gcodeFileData.gcodeFileInfo.filamentUsedMm}mm`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Filament Used (g)'>
{(() => {
if (gcodeFileData.gcodeFileInfo.filamentUsedG) {
return `${gcodeFileData.gcodeFileInfo.filamentUsedG}g`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Hotend Temperature'>
{(() => {
if (gcodeFileData.gcodeFileInfo.nozzleTemperature) {
return `${gcodeFileData.gcodeFileInfo.nozzleTemperature}°`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Bed Temperature'>
{(() => {
if (gcodeFileData.gcodeFileInfo.hotPlateTemp) {
return `${gcodeFileData.gcodeFileInfo.hotPlateTemp}°`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Filament Profile'>
{(() => {
if (gcodeFileData.gcodeFileInfo.filamentSettingsId) {
return `${gcodeFileData.gcodeFileInfo.filamentSettingsId.replaceAll('"', '')}`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Print Profile'>
{(() => {
if (gcodeFileData.gcodeFileInfo.printSettingsId) {
return `${gcodeFileData.gcodeFileInfo.printSettingsId.replaceAll('"', '')}`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
</Descriptions.Item>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.preview ? ['2'] : []}
onChange={(keys) => updateCollapseState('preview', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
GCode File Preview
</Title>
}
key='2'
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='name'
rules={[
{
required: true,
message: 'Please enter a vendor name'
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : (
gcodeFileData.name
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
<TimeDisplay
dateTime={gcodeFileData.updatedAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Filament Name'>
{isEditing ? (
<Form.Item
name='filament'
rules={[
{ required: true, message: 'Please enter a filament' }
]}
style={{ margin: 0 }}
>
<FilamentSelect />
</Form.Item>
) : gcodeFileData.filament ? (
<Space>
<FilamentIcon />
<Badge
color={gcodeFileData.filament.color}
text={gcodeFileData.filament.name}
/>
</Space>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Filament ID'>
{gcodeFileData.filament ? (
<IdText
id={gcodeFileData.filament.id}
type={'filament'}
showHyperlink={true}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Est Print Time'>
{gcodeFileData.gcodeFileInfo
.estimatedPrintingTimeNormalMode || 'n/a'}
</Descriptions.Item>
<Descriptions.Item label='Cost'>
{'£' + gcodeFileData.cost.toFixed(2) || 'n/a'}
</Descriptions.Item>
<Descriptions.Item label='Infill Density'>
{(() => {
if (gcodeFileData.gcodeFileInfo.sparseInfillDensity) {
return gcodeFileData.gcodeFileInfo.sparseInfillDensity
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Infill Pattern'>
{(() => {
if (gcodeFileData.gcodeFileInfo.sparseInfillPattern) {
return capitalizeFirstLetter(
gcodeFileData.gcodeFileInfo.sparseInfillPattern
)
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Filament Used (mm)'>
{(() => {
if (gcodeFileData.gcodeFileInfo.filamentUsedMm) {
return `${gcodeFileData.gcodeFileInfo.filamentUsedMm}mm`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Filament Used (g)'>
{(() => {
if (gcodeFileData.gcodeFileInfo.filamentUsedG) {
return `${gcodeFileData.gcodeFileInfo.filamentUsedG}g`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Hotend Temperature'>
{(() => {
if (gcodeFileData.gcodeFileInfo.nozzleTemperature) {
return `${gcodeFileData.gcodeFileInfo.nozzleTemperature}°`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Bed Temperature'>
{(() => {
if (gcodeFileData.gcodeFileInfo.hotPlateTemp) {
return `${gcodeFileData.gcodeFileInfo.hotPlateTemp}°`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Filament Profile'>
{(() => {
if (gcodeFileData.gcodeFileInfo.filamentSettingsId) {
return `${gcodeFileData.gcodeFileInfo.filamentSettingsId.replaceAll('"', '')}`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Print Profile'>
{(() => {
if (gcodeFileData.gcodeFileInfo.printSettingsId) {
return `${gcodeFileData.gcodeFileInfo.printSettingsId.replaceAll('"', '')}`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.preview ? ['2'] : []}
onChange={(keys) => updateCollapseState('preview', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Card styles={{ body: { padding: '10px' } }}>
{gcodeFileData.gcodeFileInfo.thumbnail ? (
<img
src={`data:image/png;base64,${gcodeFileData.gcodeFileInfo.thumbnail.data}`}
alt='GCodeFile'
style={{ maxWidth: '100%' }}
/>
) : (
'n/a'
)}
</Card>
</Collapse.Panel>
</Collapse>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
GCode File Preview
</Title>
}
key='2'
>
<Card styles={{ body: { padding: '10px' } }}>
{gcodeFileData.gcodeFileInfo.thumbnail ? (
<img
src={`data:image/png;base64,${gcodeFileData.gcodeFileInfo.thumbnail.data}`}
alt='GCodeFile'
style={{ maxWidth: '100%' }}
/>
) : (
'n/a'
)}
</Card>
</Collapse.Panel>
</Collapse>
</Flex>
</div>
)
}

View File

@ -1,6 +1,7 @@
import PropTypes from 'prop-types'
import React, { useState, useContext, useEffect } from 'react'
import axios from 'axios'
import { useMediaQuery } from 'react-responsive'
import {
capitalizeFirstLetter,
timeStringToMinutes
@ -48,6 +49,7 @@ const initialNewGCodeFileForm = {
const NewGCodeFile = ({ onOk, reset }) => {
const [messageApi] = message.useMessage()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [newGCodeFileLoading, setNewGCodeFileLoading] = useState(false)
const [gcodeParsing, setGcodeParsing] = useState(false)
@ -470,16 +472,18 @@ const NewGCodeFile = ({ onOk, reset }) => {
return (
<Flex gap={'middle'}>
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
{!isMobile && (
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
)}
<Divider type={'vertical'} style={{ height: 'unset' }} />
{!isMobile && <Divider type={'vertical'} style={{ height: 'unset' }} />}
<Flex vertical={'true'} style={{ flexGrow: 1 }} gap='middle'>
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>

View File

@ -1,10 +1,8 @@
// src/PrintJobs.js
// src/Jobs.js
import React, { useEffect, useState, useCallback, useContext } from 'react'
import React, { useState, useContext, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import {
Table,
Button,
Flex,
Space,
@ -15,71 +13,42 @@ import {
Input,
Typography,
Checkbox,
Popover,
Spin
Popover
} from 'antd'
import { createStyles } from 'antd-style'
import { LoadingOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext.js'
import { SocketContext } from '../context/SocketContext'
import NewPrintJob from './PrintJobs/NewPrintJob'
import JobState from '../common/JobState'
import SubJobCounter from '../common/SubJobCounter'
import TimeDisplay from '../common/TimeDisplay'
import IdText from '../common/IdText'
import useColumnVisibility from '../hooks/useColumnVisibility'
import JobIcon from '../../Icons/JobIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
import { SocketContext } from '../context/SocketContext.js'
import NewJob from './Jobs/NewJob.jsx'
import JobState from '../common/JobState.jsx'
import SubJobCounter from '../common/SubJobCounter.jsx'
import TimeDisplay from '../common/TimeDisplay.jsx'
import IdText from '../common/IdText.jsx'
import useColumnVisibility from '../hooks/useColumnVisibility.js'
import JobIcon from '../../Icons/JobIcon.jsx'
import InfoCircleIcon from '../../Icons/InfoCircleIcon.jsx'
import PlusIcon from '../../Icons/PlusIcon.jsx'
import ReloadIcon from '../../Icons/ReloadIcon.jsx'
import EditIcon from '../../Icons/EditIcon.jsx'
import XMarkIcon from '../../Icons/XMarkIcon.jsx'
import CheckIcon from '../../Icons/CheckIcon.jsx'
import PlayCircleIcon from '../../Icons/PlayCircleIcon.jsx'
import config from '../../../config.js'
import CheckCircleIcon from '../../Icons/CheckCircleIcon.jsx'
import PauseCircleIcon from '../../Icons/PauseCircleIcon.jsx'
import XMarkCircleIcon from '../../Icons/XMarkCircleIcon.jsx'
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon.jsx'
import DashboardTable from '../common/DashboardTable'
import config from '../../../config.js'
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 Jobs = () => {
const [messageApi, contextHolder] = message.useMessage()
const [notificationApi, notificationContextHolder] =
notification.useNotification()
const navigate = useNavigate()
const [printJobsData, setPrintJobsData] = useState([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({})
const [newPrintJobOpen, setNewPrintJobOpen] = useState(false)
const [loading, setLoading] = useState(true)
const [newJobOpen, setNewJobOpen] = useState(false)
const tableRef = useRef()
const getFilterDropdown = ({
setSelectedKeys,
@ -154,7 +123,7 @@ const PrintJobs = () => {
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 165,
width: 180,
render: (text) => <IdText id={text} type={'job'} longId={false} />,
filterDropdown: ({
setSelectedKeys,
@ -266,19 +235,17 @@ const PrintJobs = () => {
{record.state.type === 'draft' ? (
<Button
icon={<PlayCircleIcon />}
onClick={() => handleDeployPrintJob(record.id)}
onClick={() => handleDeployJob(record.id)}
/>
) : (
<Button
icon={<InfoCircleIcon />}
onClick={() =>
navigate(
`/dashboard/production/printjobs/info?printJobId=${record.id}`
)
navigate(`/dashboard/production/jobs/info?jobId=${record.id}`)
}
/>
)}
<Dropdown menu={getPrintJobActionItems(record.id)}>
<Dropdown menu={getJobActionItems(record.id)}>
<Button>Actions</Button>
</Dropdown>
</Space>
@ -291,14 +258,14 @@ const PrintJobs = () => {
const { socket } = useContext(SocketContext)
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
'PrintJobs',
'Jobs',
columns
)
const handleDeployPrintJob = (printJobId) => {
const handleDeployJob = (jobId) => {
if (socket) {
messageApi.info(`Print job ${printJobId} deployment initiated`)
socket.emit('server.job_queue.deploy', { printJobId }, (response) => {
messageApi.info(`Print job ${jobId} deployment initiated`)
socket.emit('server.job_queue.deploy', { jobId }, (response) => {
if (response == false) {
notificationApi.error({
message: 'Print job deployment failed',
@ -311,110 +278,13 @@ const PrintJobs = () => {
})
}
})
navigate(`/dashboard/production/printjobs/info?printJobId=${printJobId}`)
navigate(`/dashboard/production/jobs/info?jobId=${jobId}`)
} else {
messageApi.error('Socket connection not available')
}
}
const fetchPrintJobsData = useCallback(
async (pageNum = 1, append = false) => {
if (!authenticated) {
return
}
try {
const params = {
page: pageNum,
limit: 25,
...filters,
sort: sorter.field,
order: sorter.order
}
const response = await axios.get(`${config.backendUrl}/printjobs`, {
params,
headers: {
Accept: 'application/json'
},
withCredentials: true
})
const newData = response.data
setHasMore(newData.length === 25)
if (append) {
setPrintJobsData((prev) => [...prev, ...newData])
} else {
setPrintJobsData(newData)
}
setLoading(false)
setLazyLoading(false)
} catch (error) {
setLoading(false)
setLazyLoading(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, filters, sorter]
)
const handleScroll = useCallback(
(e) => {
const { target } = e
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
// If we're near the bottom (within 100px) and not currently loading
if (
scrollHeight - scrollTop - clientHeight < 100 &&
!lazyLoading &&
hasMore
) {
setLazyLoading(true)
const nextPage = page + 1
setPage(nextPage)
fetchPrintJobsData(nextPage, true)
}
},
[page, lazyLoading, hasMore, fetchPrintJobsData]
)
useEffect(() => {
// Fetch initial data
if (authenticated) {
fetchPrintJobsData()
}
}, [authenticated, fetchPrintJobsData])
const handleTableChange = (pagination, filters, sorter) => {
const newFilters = {}
Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) {
newFilters[key] = value[0]
}
})
setFilters(newFilters)
setSorter({
field: sorter.field,
order: sorter.order
})
setPage(1)
fetchPrintJobsData(1)
}
const getPrintJobActionItems = (printJobId) => {
const getJobActionItems = (jobId) => {
return {
items: [
{
@ -430,11 +300,9 @@ const PrintJobs = () => {
],
onClick: ({ key }) => {
if (key === 'edit') {
showNewPrintJobModal(printJobId)
showNewJobModal(jobId)
} else if (key === 'info') {
navigate(
`/dashboard/production/printjobs/info?printJobId=${printJobId}`
)
navigate(`/dashboard/production/jobs/info?jobId=${jobId}`)
}
}
}
@ -444,7 +312,7 @@ const PrintJobs = () => {
items: [
{
label: 'New Print Job',
key: 'newPrintJob',
key: 'newJob',
icon: <PlusIcon />
},
{ type: 'divider' },
@ -455,16 +323,16 @@ const PrintJobs = () => {
}
],
onClick: ({ key }) => {
if (key === 'newPrintJob') {
showNewPrintJobModal()
if (key === 'newJob') {
showNewJobModal()
} else if (key === 'reloadList') {
fetchPrintJobsData()
tableRef.current?.reload()
}
}
}
const showNewPrintJobModal = () => {
setNewPrintJobOpen(true)
const showNewJobModal = () => {
setNewJobOpen(true)
}
const getViewDropdownItems = () => {
@ -511,41 +379,34 @@ const PrintJobs = () => {
>
<Button>View</Button>
</Popover>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Space>
<Table
className={styles.customTable}
dataSource={printJobsData}
<DashboardTable
ref={tableRef}
columns={visibleColumns}
rowKey='id'
pagination={false}
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
scroll={{ y: 'calc(100vh - 270px)' }}
onChange={handleTableChange}
onScroll={handleScroll}
showSorterTooltip={false}
url={`${config.backendUrl}/jobs`}
authenticated={authenticated}
/>
</Flex>
<Modal
open={newPrintJobOpen}
open={newJobOpen}
footer={null}
width={700}
onCancel={() => {
setNewPrintJobOpen(false)
setNewJobOpen(false)
}}
>
<NewPrintJob
<NewJob
onOk={() => {
setNewPrintJobOpen(false)
setNewJobOpen(false)
messageApi.success('New print job created successfully.')
fetchPrintJobsData()
tableRef.current?.reload()
}}
reset={newPrintJobOpen}
reset={newJobOpen}
/>
</Modal>
</>
)
}
export default PrintJobs
export default Jobs

View File

@ -0,0 +1,398 @@
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,
Collapse,
Flex,
Dropdown,
Popover,
Checkbox,
Card
} from 'antd'
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
import TimeDisplay from '../../common/TimeDisplay'
import JobState from '../../common/JobState'
import IdText from '../../common/IdText'
import SubJobsTree from '../../common/SubJobsTree'
import { SocketContext } from '../../context/SocketContext'
import GCodeFileIcon from '../../../Icons/GCodeFileIcon'
import ReloadIcon from '../../../Icons/ReloadIcon'
import useCollapseState from '../../hooks/useCollapseState'
import config from '../../../../config'
import AuditLogTable from '../../common/AuditLogTable'
import DashboardNotes from '../../common/DashboardNotes'
const { Title, Text } = Typography
const JobInfo = () => {
const [jobData, setJobData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const location = useLocation()
const [messageApi] = message.useMessage()
const jobId = new URLSearchParams(location.search).get('jobId')
const { socket } = useContext(SocketContext)
const [collapseState, updateCollapseState] = useCollapseState('JobInfo', {
info: true,
subJobs: true,
notes: true,
auditLogs: true
})
useEffect(() => {
if (jobId) {
fetchJobDetails()
}
}, [jobId])
useEffect(() => {
if (socket && jobId) {
socket.on('notify_job_update', (updateData) => {
if (updateData._id === jobId) {
setJobData((prevData) => {
if (!prevData) return prevData
return {
...prevData,
state: updateData.state,
...updateData
}
})
}
})
}
return () => {
if (socket) {
socket.off('notify_job_update')
}
}
}, [socket, jobId])
const fetchJobDetails = async () => {
try {
setLoading(true)
const response = await axios.get(`${config.backendUrl}/jobs/${jobId}`, {
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
})
setJobData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch print job details')
messageApi.error('Failed to fetch print job details')
} finally {
setLoading(false)
}
}
const getViewDropdownItems = () => {
const sections = [
{ key: 'info', label: 'Job Information' },
{ key: 'subJobs', label: 'Sub Jobs' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]
return (
<Flex vertical>
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
{sections.map((section) => (
<Checkbox
checked={collapseState[section.key]}
key={section.key}
onChange={(e) => {
updateCollapseState(section.key, e.target.checked)
}}
>
{section.label}
</Checkbox>
))}
</Flex>
</Flex>
)
}
const actionItems = {
items: [
{
label: 'Reload Job',
key: 'reload',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'edit') {
// TODO: Implement edit functionality
messageApi.info('Edit functionality coming soon')
} else if (key === 'reload') {
fetchJobDetails()
}
}
}
return (
<>
<Flex
gap='large'
vertical='true'
style={{ height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button>View</Button>
</Popover>
</Space>
</Flex>
{error ? (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Print job not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchJobDetails}>
Retry
</Button>
</Space>
) : (
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical style={{ flexGrow: 1 }} gap={'large'}>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.info ? ['info'] : []}
onChange={(keys) =>
updateCollapseState('info', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Job Information
</Title>
}
key='info'
>
<Spin spinning={loading} indicator={<LoadingOutlined />}>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID'>
{jobData?._id ? (
<IdText id={jobData._id} type={'job'} />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Status'>
{jobData?.state ? (
<JobState
job={jobData}
showProgress={false}
showQuantity={false}
showId={false}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='GCode File Name'>
{jobData?.gcodeFile ? (
<Space>
<GCodeFileIcon />
<Text>
{jobData.gcodeFile.name || 'Not specified'}
</Text>
</Space>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='GCode File ID'>
{jobData?.gcodeFile?._id ? (
<IdText
id={jobData.gcodeFile._id}
type={'gcodefile'}
showHyperlink={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Quantity'>
{jobData?.quantity ? (
<Text>{jobData.quantity}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{jobData?.createdAt ? (
<TimeDisplay
dateTime={jobData.createdAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Started At'>
{jobData?.startedAt ? (
<TimeDisplay
dateTime={jobData.startedAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
{jobData?.state?.type === 'printing' && (
<Descriptions.Item label='Progress'>
<Progress
percent={Math.round(
(jobData.state.progress || 0) * 100
)}
/>
</Descriptions.Item>
)}
<Descriptions.Item label='Assigned Printers'>
{jobData?.printers ? (
<Text>
{jobData.printers.length} printers assigned
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</Descriptions>
</Spin>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.subJobs ? ['2'] : []}
onChange={(keys) =>
updateCollapseState('subJobs', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Sub Job Information
</Title>
}
key='2'
>
<SubJobsTree jobData={jobData} loading={loading} />
</Collapse.Panel>
</Collapse>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.notes ? ['notes'] : []}
onChange={(keys) =>
updateCollapseState('notes', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Notes
</Title>
}
key='notes'
>
<Card>
<DashboardNotes />
</Card>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
onChange={(keys) =>
updateCollapseState('auditLogs', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Audit Logs
</Title>
}
key='auditLogs'
>
<AuditLogTable
items={jobData?.auditLogs || []}
loading={loading}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</Flex>
</div>
)}
</Flex>
</>
)
}
export default JobInfo

View File

@ -1,5 +1,6 @@
import React, { useState } from 'react'
import axios from 'axios'
import { useMediaQuery } from 'react-responsive'
import {
Form,
Button,
@ -20,70 +21,66 @@ import config from '../../../../config'
const { Title } = Typography
const initialNewPrintJobForm = {
const initialNewJobForm = {
gcodeFile: null,
quantity: 1
}
const NewPrintJob = ({ onOk, reset }) => {
NewPrintJob.propTypes = {
const NewJob = ({ onOk, reset }) => {
NewJob.propTypes = {
onOk: PropTypes.func.isRequired,
reset: PropTypes.bool.isRequired
}
const [messageApi, contextHolder] = message.useMessage()
const [newPrintJobLoading, setNewPrintJobLoading] = useState(false)
const [newJobLoading, setNewJobLoading] = useState(false)
const [currentStep, setCurrentStep] = useState(0)
const [nextEnabled, setNextEnabled] = useState(false)
const [newPrintJobForm] = Form.useForm()
const [newPrintJobFormValues, setNewPrintJobFormValues] = useState(
initialNewPrintJobForm
)
const [newJobForm] = Form.useForm()
const [newJobFormValues, setNewJobFormValues] = useState(initialNewJobForm)
const newPrintJobFormUpdateValues = Form.useWatch([], newPrintJobForm)
const newJobFormUpdateValues = Form.useWatch([], newJobForm)
const isMobile = useMediaQuery({ maxWidth: 768 })
React.useEffect(() => {
newPrintJobForm
newJobForm
.validateFields({
validateOnly: true
})
.then(() => setNextEnabled(true))
.catch(() => setNextEnabled(false))
}, [newPrintJobForm, newPrintJobFormUpdateValues])
}, [newJobForm, newJobFormUpdateValues])
const summaryItems = [
{
key: 'quantity',
label: 'Quantity',
children: newPrintJobFormValues.quantity
children: newJobFormValues.quantity
}
]
React.useEffect(() => {
if (reset) {
newPrintJobForm.resetFields()
newJobForm.resetFields()
}
}, [reset, newPrintJobForm])
}, [reset, newJobForm])
const handleNewPrintJob = async () => {
setNewPrintJobLoading(true)
const handleNewJob = async () => {
setNewJobLoading(true)
try {
await axios.post(
`${config.backendUrl}/printjobs`,
newPrintJobFormValues,
{
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
}
)
await axios.post(`${config.backendUrl}/jobs`, newJobFormValues, {
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
})
onOk()
} catch (error) {
messageApi.error('Error creating new print job: ' + error.message)
} finally {
setNewPrintJobLoading(false)
setNewJobLoading(false)
}
}
@ -108,7 +105,6 @@ const NewPrintJob = ({ onOk, reset }) => {
<Form.Item
label='Quantity'
name='quantity'
defaultValue={1}
rules={[
{
required: true,
@ -121,7 +117,7 @@ const NewPrintJob = ({ onOk, reset }) => {
}
]}
>
<InputNumber min={1} defaultValue={1} style={{ width: '100%' }} />
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label='Printers'
@ -152,33 +148,35 @@ const NewPrintJob = ({ onOk, reset }) => {
return (
<Flex gap={'middle'}>
{contextHolder}
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
{!isMobile && (
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
)}
<Divider type={'vertical'} style={{ height: 'unset' }} />
{!isMobile && <Divider type={'vertical'} style={{ height: 'unset' }} />}
<Flex vertical={'true'} style={{ flexGrow: 1 }} gap='middle'>
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
New PrintJob
New Job
</Title>
<Form
name='basic'
autoComplete='off'
form={newPrintJobForm}
onFinish={handleNewPrintJob}
form={newJobForm}
onFinish={handleNewJob}
onValuesChange={(changedValues) =>
setNewPrintJobFormValues((prevValues) => ({
setNewJobFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={initialNewPrintJobForm}
initialValues={initialNewJobForm}
>
<div style={{ minHeight: '260px' }}>{steps[currentStep].content}</div>
@ -204,11 +202,7 @@ const NewPrintJob = ({ onOk, reset }) => {
</Button>
)}
{currentStep === steps.length - 1 && (
<Button
type='primary'
htmlType='submit'
loading={newPrintJobLoading}
>
<Button type='primary' htmlType='submit' loading={newJobLoading}>
Done
</Button>
)}
@ -219,4 +213,4 @@ const NewPrintJob = ({ onOk, reset }) => {
)
}
export default NewPrintJob
export default NewJob

View File

@ -1,236 +0,0 @@
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,
Collapse
} from 'antd'
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
import TimeDisplay from '../../common/TimeDisplay'
import JobState from '../../common/JobState'
import IdText from '../../common/IdText'
import SubJobsTree from '../../common/SubJobsTree'
import { SocketContext } from '../../context/SocketContext'
import GCodeFileIcon from '../../../Icons/GCodeFileIcon'
import ReloadIcon from '../../../Icons/ReloadIcon'
import useCollapseState from '../../hooks/useCollapseState'
import config from '../../../../config'
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)
const [collapseState, updateCollapseState] = useCollapseState(
'PrintJobInfo',
{
info: true,
subJobs: true
}
)
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(
`${config.backendUrl}/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 (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
</div>
)
}
if (error || !printJobData) {
return (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Print job not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchPrintJobDetails}>
Retry
</Button>
</Space>
)
}
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Print Job Information
</Title>
}
key='1'
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID'>
<IdText id={printJobData._id} type={'job'} />
</Descriptions.Item>
<Descriptions.Item label='Status'>
<JobState
job={printJobData}
showProgress={false}
showQuantity={false}
showId={false}
/>
</Descriptions.Item>
<Descriptions.Item label='GCode File Name'>
<Space>
<GCodeFileIcon />
{printJobData.gcodeFile?.name || 'Not specified'}
</Space>
</Descriptions.Item>
<Descriptions.Item label='GCode File ID'>
<IdText
id={printJobData.gcodeFile.id}
type={'gcodeFile'}
showHyperlink={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Quantity'>
{printJobData.quantity || 1}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
<TimeDisplay dateTime={printJobData.createdAt} showSince={true} />
</Descriptions.Item>
<Descriptions.Item label='Started At'>
{printJobData.startedAt ? (
<TimeDisplay
dateTime={printJobData.startedAt}
showSince={true}
/>
) : (
'n/a'
)}
</Descriptions.Item>
{printJobData.state.type === 'printing' && (
<Descriptions.Item label='Progress'>
<Progress
percent={Math.round((printJobData.state.progress || 0) * 100)}
/>
</Descriptions.Item>
)}
<Descriptions.Item label='Assigned Printers'>
{printJobData.printers?.length > 0 ? (
<span>{printJobData.printers.length} printers assigned</span>
) : (
'Any available printer'
)}
</Descriptions.Item>
</Descriptions>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.subJobs ? ['2'] : []}
onChange={(keys) => updateCollapseState('subJobs', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Sub Job Information
</Title>
}
key='2'
>
<SubJobsTree printJobData={printJobData} />
</Collapse.Panel>
</Collapse>
</div>
)
}
export default PrintJobInfo

View File

@ -1,10 +1,8 @@
// src/Printers.js
import React, { useEffect, useState, useContext, useCallback } from 'react'
import React, { useState, useContext, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import {
Table,
Button,
message,
Dropdown,
@ -14,11 +12,8 @@ import {
Tag,
Modal,
Popover,
Checkbox,
Spin
Checkbox
} from 'antd'
import { createStyles } from 'antd-style'
import { LoadingOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext'
import PrinterState from '../common/PrinterState'
@ -31,36 +26,16 @@ import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon'
import DashboardTable from '../common/DashboardTable'
import config from '../../../config'
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 [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({})
const [messageApi, contextHolder] = message.useMessage()
const { authenticated } = useContext(AuthContext)
const [newPrinterOpen, setNewPrinterOpen] = useState(false)
const navigate = useNavigate()
const tableRef = useRef()
// Column definitions
const columns = [
@ -98,7 +73,7 @@ const Printers = () => {
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 165,
width: 180,
render: (text) => <IdText id={text} type='printer' longId={false} />
},
{
@ -186,11 +161,6 @@ const Printers = () => {
}, {})
)
const [messageApi] = message.useMessage()
const { authenticated } = useContext(AuthContext)
const [newPrinterOpen, setNewPrinterOpen] = useState(false)
const navigate = useNavigate()
const getFilterDropdown = ({
setSelectedKeys,
selectedKeys,
@ -227,91 +197,6 @@ const Printers = () => {
)
}
const fetchPrintersData = useCallback(
async (pageNum = 1, append = false) => {
try {
const params = {
page: pageNum,
limit: 25,
...filters,
sort: sorter.field,
order: sorter.order
}
const response = await axios.get(`${config.backendUrl}/printers`, {
params,
headers: {
Accept: 'application/json'
},
withCredentials: true
})
const newData = response.data
setHasMore(newData.length === 25) // If we get less than 25 items, we've reached the end
if (append) {
setPrinterData((prev) => [...prev, ...newData])
} else {
setPrinterData(newData)
}
setLoading(false)
setLazyLoading(false)
} 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.'
)
}
setLoading(false)
setLazyLoading(false)
}
},
[messageApi, filters, sorter]
)
const handleScroll = useCallback(
(e) => {
const { target } = e
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
// If we're near the bottom (within 100px) and not currently loading
if (
scrollHeight - scrollTop - clientHeight < 100 &&
!lazyLoading &&
hasMore
) {
setLazyLoading(true)
const nextPage = page + 1
setPage(nextPage)
fetchPrintersData(nextPage, true)
}
},
[page, lazyLoading, hasMore, fetchPrintersData]
)
const handleTableChange = (pagination, filters, sorter) => {
const newFilters = {}
Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) {
newFilters[key] = value[0] // Take the first filter value
}
})
setFilters(newFilters)
setSorter({
field: sorter.field,
order: sorter.order
})
}
const getPrinterActionItems = (printerId) => {
return {
items: [
@ -343,7 +228,7 @@ const Printers = () => {
const getViewDropdownItems = () => {
const columnItems = columns
.filter((col) => col.key && col.title !== '') // Filter out empty title columns and ensure key exists
.filter((col) => col.key && col.title !== '')
.map((col) => (
<Checkbox
checked={columnVisibility[col.key]}
@ -388,22 +273,17 @@ const Printers = () => {
],
onClick: ({ key }) => {
if (key === 'reloadList') {
fetchPrintersData()
tableRef.current?.reload()
} else if (key === 'newPrinter') {
setNewPrinterOpen(true)
}
}
}
useEffect(() => {
if (authenticated) {
fetchPrintersData()
}
}, [fetchPrintersData, authenticated])
return (
<>
<Flex vertical={'true'} gap='large'>
{contextHolder}
<Space>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
@ -415,21 +295,15 @@ const Printers = () => {
>
<Button>View</Button>
</Popover>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Space>
<Table
className={styles.customTable}
dataSource={printerData}
<DashboardTable
ref={tableRef}
columns={visibleColumns}
pagination={false}
rowKey='id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
scroll={{ y: 'calc(100vh - 270px)' }}
onChange={handleTableChange}
onScroll={handleScroll}
showSorterTooltip={false}
url={`${config.backendUrl}/printers`}
authenticated={authenticated}
/>
<Modal
open={newPrinterOpen}
footer={null}
@ -442,7 +316,7 @@ const Printers = () => {
onOk={() => {
setNewPrinterOpen(false)
messageApi.success('New printer added successfully.')
fetchPrintersData()
tableRef.current?.reload()
}}
reset={newPrinterOpen}
/>

View File

@ -1,6 +1,7 @@
import React, { useState, useContext, useCallback, useEffect } from 'react'
import axios from 'axios'
import { useLocation } from 'react-router-dom'
import { useMediaQuery } from 'react-responsive'
import {
Button,
message,
@ -26,6 +27,7 @@ import { SocketContext } from '../../context/SocketContext'
import PrinterTemperaturePanel from '../../common/PrinterTemperaturePanel'
import PrinterPositionPanel from '../../common/PrinterPositionPanel'
import PrinterMovementPanel from '../../common/PrinterMovementPanel'
import PrinterMiscPanel from '../../common/PrinterMiscPanel'
import PrinterState from '../../common/PrinterState'
import { AuthContext } from '../../context/AuthContext'
import PrinterSubJobsTree from '../../common/PrinterJobsTree'
@ -47,6 +49,7 @@ import PlayCircleIcon from '../../../Icons/PlayCircleIcon'
import XMarkCircleIcon from '../../../Icons/XMarkCircleIcon'
import PauseCircleIcon from '../../../Icons/PauseCircleIcon'
import ExclamationOctagonIcon from '../../../Icons/ExclamationOctagonIcon'
import TimeDisplay from '../../common/TimeDisplay'
const { Text, Title } = Typography
@ -59,6 +62,7 @@ const ControlPrinter = () => {
const [messageApi] = message.useMessage()
const query = useQuery()
const printerId = query.get('printerId')
const isMobile = useMediaQuery({ maxWidth: 768 })
const [printerData, setPrinterData] = useState(null)
const [initialized, setInitialized] = useState(false)
@ -390,6 +394,17 @@ const ControlPrinter = () => {
>
Sub Jobs
</Checkbox>
<Checkbox
checked={componentVisibility.misc}
onChange={(e) => {
setComponentVisibility((prev) => ({
...prev,
misc: e.target.checked
}))
}}
>
Misc Panel
</Checkbox>
</Flex>
</Flex>
)
@ -471,8 +486,8 @@ const ControlPrinter = () => {
</Flex>
<div style={{ height: '100%', overflow: 'auto' }}>
{printerData ? (
<Flex gap={16}>
<Flex vertical style={{ flexGrow: 1 }}>
<Flex gap={'large'} wrap>
<Flex vertical style={{ flexGrow: 1 }} gap={'large'}>
<Flex gap={16} vertical style={{ flexGrow: 1 }}>
{printerData?.alerts?.some(
(alert) => alert.type === 'klippyError'
@ -540,14 +555,16 @@ const ControlPrinter = () => {
</Descriptions.Item>
<Descriptions.Item label='GCode File Name'>
{
{printerData.currentJob?.gcodeFile?.name ? (
<Space>
<GCodeFileIcon />
<Text ellipsis style={{ maxWidth: 200 }}>
{printerData.currentJob?.gcodeFile?.name || 'n/a'}
{printerData.currentJob?.gcodeFile?.name}
</Text>
</Space>
}
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='GCode File ID'>
{printerData.currentJob?.gcodeFile ? (
@ -601,7 +618,14 @@ const ControlPrinter = () => {
/>
</Descriptions.Item>
<Descriptions.Item label='Started At' span={1}>
{printerData.name}
{printerData.currentSubJob?.startedAt ? (
<TimeDisplay
dateTime={printerData.currentSubJob.startedAt}
showSince={true}
/>
) : (
'n/a'
)}
</Descriptions.Item>
</>
)}
@ -740,18 +764,24 @@ const ControlPrinter = () => {
</Descriptions.Item>
<Descriptions.Item label='Weight'>
{printerData.currentFilamentStock?.currentNetWeight ? (
<Descriptions style={{ width: '250px' }} column={2}>
<Descriptions.Item label='Net'>
{printerData.currentFilamentStock.currentNetWeight.toFixed(
2
) + 'g'}
</Descriptions.Item>
<Descriptions.Item label='Gross'>
{printerData.currentFilamentStock.currentGrossWeight.toFixed(
2
) + 'g'}
</Descriptions.Item>
</Descriptions>
<div>
<Descriptions
style={{ width: isMobile ? '100%' : '250px' }}
column={2}
size={'small'}
>
<Descriptions.Item label='Net'>
{printerData.currentFilamentStock.currentNetWeight.toFixed(
2
) + 'g'}
</Descriptions.Item>
<Descriptions.Item label='Gross'>
{printerData.currentFilamentStock.currentGrossWeight.toFixed(
2
) + 'g'}
</Descriptions.Item>
</Descriptions>
</div>
) : (
'n/a'
)}
@ -793,7 +823,7 @@ const ControlPrinter = () => {
</Collapse.Panel>
</Collapse>
</Flex>
<Flex gap={16} vertical>
<Flex gap={'large'} wrap vertical>
{componentVisibility.temperature && (
<Card>
<PrinterTemperaturePanel
@ -820,6 +850,12 @@ const ControlPrinter = () => {
></PrinterMovementPanel>
</Card>
)}
{componentVisibility.misc && (
<Card>
<PrinterMiscPanel printerId={printerId} />
</Card>
)}
</Flex>
</Flex>
) : (

View File

@ -31,6 +31,7 @@ import CheckIcon from '../../../Icons/CheckIcon.jsx'
import useCollapseState from '../../hooks/useCollapseState'
import config from '../../../../config.js'
import AuditLogTable from '../../common/AuditLogTable.jsx'
const { Title } = Typography
@ -46,7 +47,8 @@ const PrinterInfo = () => {
const [form] = Form.useForm()
const [collapseState, updateCollapseState] = useCollapseState('PrinterInfo', {
info: true,
jobs: true
jobs: true,
auditLogs: true
})
useEffect(() => {
@ -199,311 +201,348 @@ const PrinterInfo = () => {
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder}
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Printer Information
</Title>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updatePrinterInfo}
loading={loading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
}
key='1'
<Flex vertical gap={'large'}>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Form
form={form}
layout='vertical'
initialValues={{
name: printerData.name || '',
vendor: printerData.vendor || { id: null, name: '' },
moonraker: {
host: printerData.moonraker?.host || '',
port: printerData.moonraker?.port || null,
protocol: printerData.moonraker?.protocol || 'ws',
apiKey: printerData.moonraker?.apiKey || ''
},
tags: printerData.tags || []
}}
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Printer Information
</Title>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updatePrinterInfo}
loading={loading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
}
key='1'
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
<Form
form={form}
layout='vertical'
initialValues={{
name: printerData.name || '',
vendor: printerData.vendor || { id: null, name: '' },
moonraker: {
host: printerData.moonraker?.host || '',
port: printerData.moonraker?.port || null,
protocol: printerData.moonraker?.protocol || 'ws',
apiKey: printerData.moonraker?.apiKey || ''
},
tags: printerData.tags || []
}}
>
{/* Read-only fields */}
<Descriptions.Item label='ID'>
<IdText id={printerData.id} type='printer' />
</Descriptions.Item>
<Descriptions.Item label='Connected At'>
<TimeDisplay
dateTime={printerData.connectedAt}
showSince={true}
/>
</Descriptions.Item>
{/* Editable fields */}
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='name'
rules={[
{
required: true,
message: 'Please enter a printer name'
},
{ max: 100, message: 'Name cannot exceed 100 characters' }
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter printer name' />
</Form.Item>
) : (
printerData.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Host'>
{isEditing ? (
<Form.Item
name={['moonraker', 'host']}
rules={[
{ required: true, message: 'Please enter a host' },
{
pattern:
/^[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$|\b(?:\d{1,3}\.){3}\d{1,3}\b/,
message: 'Please enter a valid hostname or IP address'
}
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter host (e.g., 192.168.1.100)' />
</Form.Item>
) : (
printerData.moonraker?.host || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor'>
{isEditing ? (
<Form.Item
name='vendor'
rules={[
{ required: true, message: 'Please enter a vendor' }
]}
style={{ margin: 0 }}
>
<VendorSelect />
</Form.Item>
) : (
<Space>
<VendorIcon />
{printerData?.vendor?.name || 'n/a'}
</Space>
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor ID'>
{printerData?.vendor ? (
<IdText
id={printerData?.vendor?.id}
type={'vendor'}
showHyperlink={true}
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
{/* Read-only fields */}
<Descriptions.Item label='ID'>
<IdText id={printerData._id} type='printer' />
</Descriptions.Item>
<Descriptions.Item label='Connected At'>
<TimeDisplay
dateTime={printerData.connectedAt}
showSince={true}
/>
) : (
'n/a'
)}
</Descriptions.Item>
</Descriptions.Item>
<Descriptions.Item label='Port'>
{isEditing ? (
<Form.Item
name={['moonraker', 'port']}
rules={[
{ required: true, message: 'Please enter a port number' },
{
type: 'number',
min: 1,
max: 65535,
message: 'Port must be between 1 and 65535'
}
]}
style={{ margin: 0 }}
>
<InputNumber
min={1}
max={65535}
placeholder='Enter port'
style={{ width: '100%' }}
/>
</Form.Item>
) : (
printerData.moonraker.port
)}
</Descriptions.Item>
<Descriptions.Item label='Protocol'>
{isEditing ? (
<Form.Item
name={['moonraker', 'protocol']}
rules={[{ required: true, message: 'Port is required' }]}
style={{ margin: 0 }}
>
<Select
defaultValue='ws'
options={[
{ value: 'ws', label: 'Websocket' },
{ value: 'wss', label: 'Websocket Secure' }
{/* Editable fields */}
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='name'
rules={[
{
required: true,
message: 'Please enter a printer name'
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter printer name' />
</Form.Item>
) : (
printerData.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Host'>
{isEditing ? (
<Form.Item
name={['moonraker', 'host']}
rules={[
{ required: true, message: 'Please enter a host' },
{
pattern:
/^[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$|\b(?:\d{1,3}\.){3}\d{1,3}\b/,
message: 'Please enter a valid hostname or IP address'
}
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter host (e.g., 192.168.1.100)' />
</Form.Item>
) : (
printerData.moonraker?.host || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor'>
{isEditing ? (
<Form.Item
name='vendor'
rules={[
{ required: true, message: 'Please enter a vendor' }
]}
style={{ margin: 0 }}
>
<VendorSelect />
</Form.Item>
) : (
<Space>
<VendorIcon />
{printerData?.vendor?.name || 'n/a'}
</Space>
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor ID'>
{printerData?.vendor ? (
<IdText
id={printerData?.vendor?.id}
type={'vendor'}
showHyperlink={true}
/>
</Form.Item>
) : printerData.moonraker.protocol == 'ws' ? (
'Websocket'
) : (
'Websocket Secure'
)}
</Descriptions.Item>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='API Key'>
{isEditing ? (
<Form.Item
name={['moonraker', 'apiKey']}
style={{ margin: 0 }}
>
<Input.Password placeholder='Enter API key' />
</Form.Item>
) : printerData.moonraker?.apiKey ? (
'Configured'
) : (
'Not configured'
)}
</Descriptions.Item>
<Descriptions.Item label='Port'>
{isEditing ? (
<Form.Item
name={['moonraker', 'port']}
rules={[
{
required: true,
message: 'Please enter a port number'
},
{
type: 'number',
min: 1,
max: 65535,
message: 'Port must be between 1 and 65535'
}
]}
style={{ margin: 0 }}
>
<InputNumber
min={1}
max={65535}
placeholder='Enter port'
style={{ width: '100%' }}
/>
</Form.Item>
) : (
printerData.moonraker.port
)}
</Descriptions.Item>
<Descriptions.Item label='Status'>
<PrinterState
printer={printerData}
showPrinterName={false}
showControls={false}
showProgress={false}
/>
</Descriptions.Item>
<Descriptions.Item label='Protocol'>
{isEditing ? (
<Form.Item
name={['moonraker', 'protocol']}
rules={[{ required: true, message: 'Port is required' }]}
style={{ margin: 0 }}
>
<Select
defaultValue='ws'
options={[
{ value: 'ws', label: 'Websocket' },
{ value: 'wss', label: 'Websocket Secure' }
]}
/>
</Form.Item>
) : printerData.moonraker.protocol == 'ws' ? (
'Websocket'
) : (
'Websocket Secure'
)}
</Descriptions.Item>
<Descriptions.Item label='Tags'>
{isEditing ? (
<Form.Item name='tags' style={{ margin: 0 }}>
<Descriptions.Item label='API Key'>
{isEditing ? (
<Form.Item
name={['moonraker', 'apiKey']}
style={{ margin: 0 }}
>
<Input.Password placeholder='Enter API key' />
</Form.Item>
) : printerData.moonraker?.apiKey ? (
'Configured'
) : (
'Not configured'
)}
</Descriptions.Item>
<Descriptions.Item label='Status'>
<PrinterState
printer={printerData}
showPrinterName={false}
showControls={false}
showProgress={false}
/>
</Descriptions.Item>
<Descriptions.Item label='Tags'>
{isEditing ? (
<Form.Item name='tags' style={{ margin: 0 }}>
<Space
size={[0, 2]}
wrap
style={{ marginBottom: 4, maxWidth: '300px' }}
>
{printerData.tags.map((tag) => (
<Tag
key={tag}
color='blue'
closable
onClose={() => handleTagClose(tag)}
style={{ marginBottom: 12 }}
>
{tag}
</Tag>
))}
</Space>
<Space.Compact block>
<Form.Item name='newTag' noStyle>
<Input placeholder='Add new tag' />
</Form.Item>
<Button onClick={handleTagAdd} icon={<PlusIcon />} />
</Space.Compact>
</Form.Item>
) : printerData.tags?.length > 0 ? (
<Space
size={[0, 2]}
wrap
style={{ marginBottom: 4, maxWidth: '300px' }}
>
{printerData.tags.map((tag) => (
<Tag
key={tag}
color='blue'
closable
onClose={() => handleTagClose(tag)}
style={{ marginBottom: 12 }}
>
{printerData.tags.map((tag, index) => (
<Tag key={index} color='blue'>
{tag}
</Tag>
))}
</Space>
<Space.Compact block>
<Form.Item name='newTag' noStyle>
<Input placeholder='Add new tag' />
</Form.Item>
<Button onClick={handleTagAdd} icon={<PlusIcon />} />
</Space.Compact>
</Form.Item>
) : printerData.tags?.length > 0 ? (
<Space
size={[0, 2]}
wrap
style={{ marginBottom: 4, maxWidth: '300px' }}
>
{printerData.tags.map((tag, index) => (
<Tag key={index} color='blue'>
{tag}
</Tag>
))}
</Space>
) : (
'No tags'
)}
</Descriptions.Item>
) : (
'No tags'
)}
</Descriptions.Item>
<Descriptions.Item label='Firmware Version'>
{printerData.firmware || 'Unknown'}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
<Descriptions.Item label='Firmware Version'>
{printerData.firmware || 'Unknown'}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.jobs ? ['2'] : []}
onChange={(keys) => updateCollapseState('jobs', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Printer Jobs
</Title>
}
key='2'
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.jobs ? ['2'] : []}
onChange={(keys) => updateCollapseState('jobs', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<PrinterSubJobsList subJobs={printerData.subJobs} />
</Collapse.Panel>
</Collapse>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Printer Jobs
</Title>
}
key='2'
>
<PrinterSubJobsList subJobs={printerData.subJobs} />
</Collapse.Panel>
</Collapse>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.auditLogs ? ['3'] : []}
onChange={(keys) => updateCollapseState('auditLogs', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Audit Log
</Title>
}
key='3'
>
<AuditLogTable
items={printerData.auditLogs || []}
loading={false}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</Flex>
</div>
)
}

View File

@ -9,17 +9,16 @@ import {
message,
Button,
Collapse,
Segmented
Segmented,
Card
} from 'antd'
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
import { Line } from '@ant-design/charts'
import axios from 'axios'
import PrinterIcon from '../../Icons/PrinterIcon'
import JobIcon from '../../Icons/JobIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
import PauseIcon from '../../Icons/PauseIcon'
import XMarkIcon from '../../Icons/XMarkIcon'
import useCollapseState from '../hooks/useCollapseState'
import CheckIcon from '../../Icons/CheckIcon'
import config from '../../../config'
@ -29,6 +28,7 @@ const ProductionOverview = () => {
const [messageApi, contextHolder] = message.useMessage()
const [error, setError] = useState(null)
const [fetchPrinterStatsLoading, setFetchPrinterStatsLoading] = useState(true)
const [chartData, setChartData] = useState([])
const [collapseState, updateCollapseState] = useCollapseState(
'ProductionOverview',
{
@ -46,9 +46,9 @@ const ProductionOverview = () => {
const [stats, setStats] = useState({
totalPrinters: 0,
activePrinters: 0,
totalPrintJobs: 0,
activePrintJobs: 0,
completedPrintJobs: 0,
totalJobs: 0,
activeJobs: 0,
completedJobs: 0,
printerStatus: {
idle: 0,
printing: 0,
@ -59,7 +59,8 @@ const ProductionOverview = () => {
const fetchAllStats = useCallback(async () => {
await fetchPrinterStats()
await fetchPrintJobStats()
await fetchJobstats()
await fetchChartData()
console.log(stats)
}, [])
@ -84,17 +85,17 @@ const ProductionOverview = () => {
}
}
const fetchPrintJobStats = async () => {
const fetchJobstats = async () => {
try {
setFetchPrinterStatsLoading(true)
const response = await axios.get(`${config.backendUrl}/printjobs/stats`, {
const response = await axios.get(`${config.backendUrl}/jobs/stats`, {
headers: {
Accept: 'application/json'
},
withCredentials: true
})
const printJobStats = response.data
setStats((prev) => ({ ...prev, printJobs: printJobStats }))
const jobstats = response.data
setStats((prev) => ({ ...prev, jobs: jobstats }))
setError(null)
} catch (err) {
setError('Failed to fetch printer details')
@ -104,6 +105,20 @@ const ProductionOverview = () => {
}
}
const fetchChartData = async () => {
try {
const response = await axios.get(`${config.backendUrl}/stats/history`, {
headers: {
Accept: 'application/json'
},
withCredentials: true
})
setChartData(response.data)
} catch (err) {
console.error('Failed to fetch chart data:', err)
}
}
useEffect(() => {
fetchAllStats()
}, [fetchAllStats])
@ -131,287 +146,298 @@ const ProductionOverview = () => {
}
return (
<Flex vertical>
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder}
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.overview ? ['1'] : []}
onChange={(keys) => updateCollapseState('overview', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Status Overview
</Title>
}
key='1'
<Flex gap='large' vertical>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.overview ? ['1'] : []}
onChange={(keys) => updateCollapseState('overview', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Flex
justify='flex-start'
gap='middle'
wrap='wrap'
align='flex-start'
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Status Overview
</Title>
}
key='1'
>
<Alert
type='success'
style={{ minWidth: 150 }}
description={
<Flex vertical>
<Text type='secondary'>Ready</Text>
<Flex gap={'small'}>
<PrinterIcon style={{ fontSize: 26 }} />
<Text style={{ fontSize: 28, fontWeight: 600 }}>
{(stats.printers.standby || 0) +
(stats.printers.complete || 0)}
</Text>
<Flex
justify='flex-start'
gap='middle'
wrap='wrap'
align='flex-start'
>
<Alert
type='success'
style={{ minWidth: 150 }}
description={
<Flex vertical>
<Text type='secondary'>Ready</Text>
<Flex gap={'small'}>
<PrinterIcon style={{ fontSize: 26 }} />
<Text style={{ fontSize: 28, fontWeight: 600 }}>
{(stats.printers.standby || 0) +
(stats.printers.complete || 0)}
</Text>
</Flex>
</Flex>
</Flex>
}
/>
<Alert
type='info'
style={{ minWidth: 150 }}
description={
<Flex vertical>
<Text type='secondary'>Printing</Text>
<Flex gap={'small'}>
<PrinterIcon style={{ fontSize: 26 }} />
<Text style={{ fontSize: 28, fontWeight: 600 }}>
{stats.printers.printing || 0}
</Text>
}
/>
<Alert
type='info'
style={{ minWidth: 150 }}
description={
<Flex vertical>
<Text type='secondary'>Printing</Text>
<Flex gap={'small'}>
<PrinterIcon style={{ fontSize: 26 }} />
<Text style={{ fontSize: 28, fontWeight: 600 }}>
{stats.printers.printing || 0}
</Text>
</Flex>
</Flex>
</Flex>
}
/>
<Alert
type='warning'
style={{ minWidth: 150 }}
description={
<Flex vertical>
<Text type='secondary'>Queued</Text>
<Flex gap={'small'}>
<JobIcon style={{ fontSize: 26 }} />
<Text style={{ fontSize: 28, fontWeight: 600 }}>
{stats.printJobs.queued || 0}
</Text>
}
/>
<Alert
type='warning'
style={{ minWidth: 150 }}
description={
<Flex vertical>
<Text type='secondary'>Queued</Text>
<Flex gap={'small'}>
<JobIcon style={{ fontSize: 26 }} />
<Text style={{ fontSize: 28, fontWeight: 600 }}>
{stats.jobs.queued || 0}
</Text>
</Flex>
</Flex>
</Flex>
}
/>
<Alert
type='info'
style={{ minWidth: 150 }}
description={
<Flex vertical>
<Text type='secondary'>Printing</Text>
<Flex gap={'small'}>
<JobIcon style={{ fontSize: 26 }} />
<Text style={{ fontSize: 28, fontWeight: 600 }}>
{stats.printJobs.printing || 0}
</Text>
}
/>
<Alert
type='info'
style={{ minWidth: 150 }}
description={
<Flex vertical>
<Text type='secondary'>Printing</Text>
<Flex gap={'small'}>
<JobIcon style={{ fontSize: 26 }} />
<Text style={{ fontSize: 28, fontWeight: 600 }}>
{stats.jobs.printing || 0}
</Text>
</Flex>
</Flex>
</Flex>
}
/>
<Alert
type='error'
style={{ minWidth: 150 }}
description={
<Flex vertical>
<Text type='secondary'>Failed</Text>
<Flex gap={'small'}>
<JobIcon style={{ fontSize: 26 }} />
<Text style={{ fontSize: 28, fontWeight: 600 }}>
{(stats.printJobs.failed || 0) +
(stats.printJobs.cancelled || 0)}
</Text>
}
/>
<Alert
type='error'
style={{ minWidth: 150 }}
description={
<Flex vertical>
<Text type='secondary'>Failed</Text>
<Flex gap={'small'}>
<JobIcon style={{ fontSize: 26 }} />
<Text style={{ fontSize: 28, fontWeight: 600 }}>
{(stats.jobs.failed || 0) + (stats.jobs.cancelled || 0)}
</Text>
</Flex>
</Flex>
</Flex>
}
/>
<Alert
type='success'
style={{ minWidth: 150 }}
description={
<Flex vertical>
<Text type='secondary'>Complete</Text>
<Flex gap={'small'}>
<JobIcon style={{ fontSize: 26 }} />
<Text style={{ fontSize: 28, fontWeight: 600 }}>
{stats.printJobs.complete || 0}
</Text>
}
/>
<Alert
type='success'
style={{ minWidth: 150 }}
description={
<Flex vertical>
<Text type='secondary'>Complete</Text>
<Flex gap={'small'}>
<JobIcon style={{ fontSize: 26 }} />
<Text style={{ fontSize: 28, fontWeight: 600 }}>
{stats.jobs.complete || 0}
</Text>
</Flex>
</Flex>
</Flex>
}
/>
</Flex>
</Collapse.Panel>
</Collapse>
}
/>
</Flex>
</Collapse.Panel>
</Collapse>
<Flex gap='middle' wrap='wrap'>
<Flex flex={1} vertical>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.printerStats ? ['2'] : []}
onChange={(keys) =>
updateCollapseState('printerStats', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Printer Statistics
</Title>
<Segmented
options={['4h', '8h', '12h', '24h']}
value={timeRanges.printerStats}
onChange={(value) =>
setTimeRanges((prev) => ({
...prev,
printerStats: value
}))
}
size='small'
/>
</Flex>
<Flex gap='large' wrap='wrap'>
<Flex flex={1} vertical>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.printerStats ? ['2'] : []}
onChange={(keys) =>
updateCollapseState('printerStats', keys.length > 0)
}
key='2'
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Space direction='vertical' style={{ width: '100%' }}>
<Descriptions column={1} bordered>
<Descriptions.Item
label={
<Space>
<CheckIcon />
{'Completed'}
</Space>
}
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
{stats.totalPrinters}
</Descriptions.Item>
<Descriptions.Item
label={
<Space>
<XMarkIcon />
{'Error'}
</Space>
}
>
{stats.activePrinters}
</Descriptions.Item>
<Descriptions.Item
label={
<Space>
<PauseIcon />
{'Paused'}
</Space>
}
>
{stats.activePrinters}
</Descriptions.Item>
</Descriptions>
</Space>
</Collapse.Panel>
</Collapse>
</Flex>
<Flex flex={1} vertical>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.jobStats ? ['3'] : []}
onChange={(keys) =>
updateCollapseState('jobStats', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Job Statistics
</Title>
<Segmented
options={['4h', '8h', '12h', '24h']}
value={timeRanges.jobStats}
onChange={(value) =>
setTimeRanges((prev) => ({ ...prev, jobStats: value }))
}
size='small'
/>
<Title level={5} style={{ margin: 0 }}>
Production Statistics
</Title>
<Segmented
options={['4h', '8h', '12h', '24h']}
value={timeRanges.printerStats}
onChange={(value) =>
setTimeRanges((prev) => ({
...prev,
printerStats: value
}))
}
size='small'
/>
</Flex>
}
key='2'
>
<Flex vertical gap={'middle'}>
<Card style={{ height: 250, width: '100%' }}>
<Line
data={chartData}
xField='timestamp'
yField='value'
seriesField='type'
smooth
animation={{
appear: {
animation: 'wave-in',
duration: 1000
}
}}
point={{
size: 4,
shape: 'circle'
}}
tooltip={{
showMarkers: false
}}
legend={{
position: 'top'
}}
/>
</Card>
<Descriptions column={1} bordered>
<Descriptions.Item label='Completed'>
{stats.totalPrinters}
</Descriptions.Item>
<Descriptions.Item label='Error'>
{stats.activePrinters}
</Descriptions.Item>
<Descriptions.Item label='Paused'>
{stats.activePrinters}
</Descriptions.Item>
</Descriptions>
</Flex>
</Collapse.Panel>
</Collapse>
</Flex>
<Flex flex={1} vertical>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.jobStats ? ['3'] : []}
onChange={(keys) =>
updateCollapseState('jobStats', keys.length > 0)
}
key='3'
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Space direction='vertical' style={{ width: '100%' }}>
<Descriptions column={1} bordered>
<Descriptions.Item
label={
<Space>
<CheckIcon />
{'Completed'}
</Space>
}
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
{stats.totalPrintJobs}
</Descriptions.Item>
<Descriptions.Item
label={
<Space>
<XMarkIcon />
{'Failed'}
</Space>
}
<Title level={5} style={{ margin: 0 }}>
Job Statistics
</Title>
<Segmented
options={['4h', '8h', '12h', '24h']}
value={timeRanges.jobStats}
onChange={(value) =>
setTimeRanges((prev) => ({ ...prev, jobStats: value }))
}
size='small'
/>
</Flex>
}
key='3'
>
<Flex vertical gap={'middle'}>
<Card
style={{ height: 250, width: '100%', minWidth: '300px' }}
>
{stats.activePrintJobs}
</Descriptions.Item>
<Descriptions.Item
label={
<Space>
<PauseIcon />
{'Queued'}
</Space>
}
>
{stats.completedPrintJobs}
</Descriptions.Item>
</Descriptions>
</Space>
</Collapse.Panel>
</Collapse>
<Line
data={chartData}
xField='timestamp'
yField='value'
seriesField='type'
smooth
animation={{
appear: {
animation: 'wave-in',
duration: 1000
}
}}
point={{
size: 4,
shape: 'circle'
}}
tooltip={{
showMarkers: false
}}
legend={{
position: 'top'
}}
/>
</Card>
<Descriptions column={1} bordered>
<Descriptions.Item label='Completed'>
{stats.totalJobs}
</Descriptions.Item>
<Descriptions.Item label='Failed'>
{stats.activeJobs}
</Descriptions.Item>
<Descriptions.Item label='Queued'>
{stats.completedJobs}
</Descriptions.Item>
</Descriptions>
</Flex>
</Collapse.Panel>
</Collapse>
</Flex>
</Flex>
</Flex>
</Flex>
</div>
)
}

View File

@ -0,0 +1,214 @@
import React, { forwardRef, useState } from 'react'
import { Typography, Space, Descriptions, Badge, Tag, Table } from 'antd'
import PropTypes from 'prop-types'
import IdText from './IdText'
import { AuditOutlined, LoadingOutlined } from '@ant-design/icons'
import TimeDisplay from '../common/TimeDisplay'
const { Text } = Typography
const formatPropertyName = (name) => {
return name
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase())
}
const isObjectId = (value) => {
return typeof value === 'string' && /^[0-9a-fA-F]{24}$/.test(value)
}
const formatValue = (value, propertyName) => {
if (value === null || value === undefined || value === '') {
return <Text type='secondary'>n/a</Text>
}
// Handle colors specifically
if (propertyName === 'color') {
return <Badge color={value} text={value} />
}
if (propertyName === 'state' && typeof value === 'object' && value.type) {
return (
<Text>{value.type.charAt(0).toUpperCase() + value.type.slice(1)}</Text>
)
}
// Check if the value is a timestamp (ISO date string)
if (
typeof value === 'string' &&
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)
) {
return <TimeDisplay dateTime={value} />
}
if (typeof value === 'boolean' || value === true || value === false) {
return (
<Tag color={value ? 'success' : 'error'} style={{ margin: 0 }}>
{value ? 'Yes' : 'No'}
</Tag>
)
}
if (isObjectId(value)) {
return (
<IdText
id={value}
type={propertyName.toLowerCase().replaceAll('current', '')}
longId={false}
showHyperlink={true}
/>
)
}
if (typeof value === 'object') {
return <Text>{JSON.stringify(value)}</Text>
}
return <Text>{value}</Text>
}
const AuditLogTable = forwardRef(
({ items, loading, showTargetColumn, showOwnerColumn }, ref) => {
const [sortedInfo, setSortedInfo] = useState({
columnKey: 'createdAt',
order: 'descend'
})
const handleChange = (pagination, filters, sorter) => {
setSortedInfo(sorter)
}
const columns = [
{
title: '',
key: 'icon',
width: 50,
render: () => <AuditOutlined />
},
{
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 180,
render: (text) => <IdText id={text} type={'auditlog'} longId={false} />,
sorter: (a, b) => a._id.localeCompare(b._id)
}
]
if (showOwnerColumn) {
columns.push(
{
title: 'Owner Name',
dataIndex: ['owner', 'name'],
key: 'name',
width: 200,
sorter: (a, b) =>
(a.owner?.name || '').localeCompare(b.owner?.name || '')
},
{
title: 'Owner',
key: 'owner',
width: 180,
render: (record) => (
<IdText
id={record.owner._id}
type={record.ownerModel.toLowerCase()}
longId={false}
showHyperlink={true}
/>
)
}
)
}
if (showTargetColumn) {
columns.push({
title: 'Target',
key: 'target',
width: 180,
render: (record) => (
<IdText
id={record.target}
type={record.targetModel.toLowerCase()}
longId={false}
showHyperlink={true}
/>
)
})
}
columns.push({
title: 'Properties',
dataIndex: 'type',
key: 'type',
width: 550,
render: (_, record) => {
const oldValue = record.oldValue || {}
const newValue = record.newValue || {}
return (
<Descriptions size='small' column={1}>
{Object.keys(newValue).map((key) => (
<Descriptions.Item key={key} label={formatPropertyName(key)}>
<Space>
{formatValue(oldValue[key], key)}
<Text type='secondary'></Text>
{formatValue(newValue[key], key)}
</Space>
</Descriptions.Item>
))}
</Descriptions>
)
}
})
columns.push({
title: 'Timestamp',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
defaultSortOrder: 'descend',
sorter: (a, b) => new Date(a.createdAt) - new Date(b.createdAt),
render: (createdAt) => {
if (createdAt) {
return <TimeDisplay dateTime={createdAt} />
} else {
return 'n/a'
}
}
})
return (
<Table
ref={ref}
columns={columns}
dataSource={items}
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
pagination={false}
scroll={{ x: 'max-content' }}
onChange={handleChange}
sortDirections={['ascend', 'descend']}
sortOrder={
sortedInfo.columnKey === 'createdAt' ? sortedInfo.order : null
}
/>
)
}
)
AuditLogTable.displayName = 'AuditLogTable'
AuditLogTable.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
loading: PropTypes.bool,
showTargetColumn: PropTypes.bool,
showOwnerColumn: PropTypes.bool
}
AuditLogTable.defaultProps = {
loading: false,
showTargetColumn: true,
showOwnerColumn: true
}
export default AuditLogTable

View File

@ -13,8 +13,8 @@ const breadcrumbNameMap = {
'/dashboard/production/printers': 'Printers',
'/dashboard/production/printers/control': 'Control',
'/dashboard/production/printers/info': 'Info',
'/dashboard/production/printjobs': 'Print Jobs',
'/dashboard/production/printjobs/info': 'Info',
'/dashboard/production/jobs': 'Print Jobs',
'/dashboard/production/jobs/info': 'Info',
'/dashboard/production/gcodefiles': 'G Code Files',
'/dashboard/production/gcodefiles/info': 'Info',
'/dashboard/management/filaments': 'Filaments',
@ -27,7 +27,10 @@ const breadcrumbNameMap = {
'/dashboard/management/vendors/info': 'Info',
'/dashboard/management/materials': 'Materials',
'/dashboard/management/materials/info': 'Info',
'/dashboard/management/notetypes': 'Note Types',
'/dashboard/management/notetypes/info': 'Info',
'/dashboard/management/settings': 'Settings',
'/dashboard/management/auditlogs': 'Audit Logs',
'/dashboard/inventory/filamentstocks': 'Filament Stocks',
'/dashboard/inventory/filamentstocks/info': 'Info',
'/dashboard/inventory/partstocks': 'Part Stocks',
@ -70,7 +73,7 @@ const DashboardBreadcrumb = () => {
})
return (
<Flex align='center' gap={'large'}>
<Flex align='center' gap={'small'}>
<Flex gap={'small'}>
<Space.Compact>
<Button

View File

@ -6,11 +6,8 @@
padding: 0 !important;
}
.no-t-padding-collapse .ant-collapse-header {
padding-top: 0 !important;
}
.no-h-padding-collapse .ant-collapse-header {
padding-top: 0 !important;
padding-left: 0 !important;
padding-right: 0 !important;
padding-bottom: 4px !important;
@ -19,6 +16,7 @@
.no-h-padding-collapse .ant-collapse-content-box {
padding-left: 0 !important;
padding-right: 0 !important;
padding-bottom: 0 !important;
}
.no-padding-collapse .ant-collapse-item,

View File

@ -13,9 +13,6 @@ import {
} from 'antd'
import {
LogoutOutlined,
SettingOutlined,
ShoppingCartOutlined,
PoundOutlined,
MailOutlined,
MenuOutlined,
LoadingOutlined
@ -25,14 +22,17 @@ import { SocketContext } from '../context/SocketContext'
import { SpotlightContext } from '../context/SpotlightContext'
import { useNavigate, useLocation } from 'react-router-dom'
import { Header } from 'antd/es/layout/layout'
import { useMediaQuery } from 'react-responsive'
import FarmControlLogo from '../../Logos/FarmControlLogo'
import FarmControlLogoSmall from '../../Logos/FarmControlLogoSmall'
import ProductionIcon from '../../Icons/ProductionIcon'
import InventoryIcon from '../../Icons/InventoryIcon'
import PersonIcon from '../../Icons/PersonIcon'
import CloudIcon from '../../Icons/CloudIcon'
import BellIcon from '../../Icons/BellIcon'
import SearchIcon from '../../Icons/SearchIcon'
import SettingsIcon from '../../Icons/SettingsIcon'
const { Text } = Typography
@ -44,6 +44,7 @@ const DashboardNavigation = () => {
const navigate = useNavigate()
const location = useLocation()
const [selectedKey, setSelectedKey] = useState('production')
const isMobile = useMediaQuery({ maxWidth: 768 })
useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean)
@ -73,21 +74,10 @@ const DashboardNavigation = () => {
label: 'Inventory',
icon: <InventoryIcon />
},
{
key: 'sales',
label: 'Sales',
icon: <ShoppingCartOutlined />
},
{
key: 'finance',
label: 'Finance',
icon: <PoundOutlined />
},
{
key: 'management',
label: 'Management',
icon: <SettingOutlined />
icon: <SettingsIcon />
}
]
@ -151,7 +141,11 @@ const DashboardNavigation = () => {
borderBottom: '1px solid rgba(5, 5, 5, 0.00)'
}}
>
<FarmControlLogo style={{ fontSize: '200px' }} />
{!isMobile ? (
<FarmControlLogo style={{ fontSize: '200px' }} />
) : (
<FarmControlLogoSmall style={{ fontSize: '48px' }} />
)}
<Menu
mode='horizontal'
items={mainMenuItems}
@ -162,7 +156,7 @@ const DashboardNavigation = () => {
}}
onClick={handleMainMenuClick}
selectedKeys={[selectedKey]}
overflowedIndicator={<MenuOutlined />}
overflowedIndicator={<Button type='text' icon={<MenuOutlined />} />}
/>
<Flex gap={'middle'} align='center'>
<Space>
@ -223,7 +217,7 @@ const DashboardNavigation = () => {
<Space>
<Dropdown menu={userMenuItems} placement='bottomRight'>
<Tag style={{ marginRight: 0 }} icon={<PersonIcon />}>
{userProfile?.name ? userProfile.name : userProfile.username}
{!isMobile && (userProfile?.name || userProfile.username)}
</Tag>
</Dropdown>
</Space>

View File

@ -0,0 +1,177 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import {
Card,
Button,
Space,
Typography,
Flex,
Modal,
Form,
Input,
Select,
Switch
} from 'antd'
import PlusIcon from '../../Icons/PlusIcon'
import TimeDisplay from './TimeDisplay'
import MarkdownDisplay from './MarkdownDisplay'
const { Text } = Typography
const { TextArea } = Input
const DashboardNotes = ({ notes = [], onNewNote }) => {
const [isModalOpen, setIsModalOpen] = useState(false)
const [showMarkdown, setShowMarkdown] = useState(false)
const [form] = Form.useForm()
const handleNewNote = () => {
setIsModalOpen(true)
}
const handleModalOk = async () => {
try {
const values = await form.validateFields()
onNewNote(values)
form.resetFields()
setIsModalOpen(false)
setShowMarkdown(false)
} catch (error) {
console.error('Validation failed:', error)
}
}
const handleModalCancel = () => {
form.resetFields()
setIsModalOpen(false)
setShowMarkdown(false)
}
return (
<Space direction='vertical' size='large' style={{ width: '100%' }}>
<Flex justify='space-between'>
<Space size={'small'}>
<Button>Actions</Button>
</Space>
<Space size={'small'}>
<Button type='primary' icon={<PlusIcon />} onClick={handleNewNote} />
</Space>
</Flex>
<Space direction='vertical' size='middle' style={{ width: '100%' }}>
{notes.map((note) => (
<Card key={note._id} size='small'>
<Space direction='vertical' style={{ width: '100%' }}>
<Flex justify='space-between' align='center'>
<Text type='secondary'>
<TimeDisplay dateTime={note.createdAt} showSince={true} />
</Text>
</Flex>
<Text>{note.content}</Text>
</Space>
</Card>
))}
</Space>
<Modal
title='New Note'
open={isModalOpen}
onOk={handleModalOk}
onCancel={handleModalCancel}
width={800}
>
<Form
form={form}
layout='vertical'
initialValues={{
type: 'general',
showMarkdown: false
}}
>
<Form.Item
name='type'
label='Note Type'
rules={[{ required: true, message: 'Please select a note type' }]}
>
<Select>
<Select.Option value='general'>General</Select.Option>
<Select.Option value='task'>Task</Select.Option>
<Select.Option value='idea'>Idea</Select.Option>
<Select.Option value='bug'>Bug</Select.Option>
</Select>
</Form.Item>
<Form.Item
name='title'
label='Title'
rules={[{ required: true, message: 'Please enter a title' }]}
>
<Input placeholder='Enter note title' />
</Form.Item>
<Form.Item name='showMarkdown' valuePropName='checked'>
<Switch
checkedChildren='Show Markdown'
unCheckedChildren='Hide Markdown'
onChange={setShowMarkdown}
/>
</Form.Item>
<Flex gap='middle'>
<Form.Item
name='content'
label='Content'
rules={[{ required: true, message: 'Please enter note content' }]}
style={{ flex: 1 }}
>
<TextArea
rows={6}
placeholder='Enter note content'
style={{ resize: 'none' }}
/>
</Form.Item>
{showMarkdown && (
<div style={{ flex: 1 }}>
<Text
type='secondary'
style={{ marginBottom: 8, display: 'block' }}
>
Preview
</Text>
<div
style={{
border: '1px solid #d9d9d9',
borderRadius: '6px',
padding: '8px',
minHeight: '150px',
maxHeight: '300px',
overflow: 'auto'
}}
>
<MarkdownDisplay
content={form.getFieldValue('content') || ''}
/>
</div>
</div>
)}
</Flex>
</Form>
</Modal>
</Space>
)
}
DashboardNotes.propTypes = {
notes: PropTypes.arrayOf(
PropTypes.shape({
_id: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
createdAt: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
user: PropTypes.object.isRequired
})
),
onNewNote: PropTypes.func.isRequired
}
export default DashboardNotes

View File

@ -0,0 +1,318 @@
import React, {
forwardRef,
useImperativeHandle,
useRef,
useEffect,
useState,
useCallback
} from 'react'
import { Table, message, Skeleton } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import PropTypes from 'prop-types'
import { useMediaQuery } from 'react-responsive'
import axios from 'axios'
const DashboardTable = forwardRef(
(
{
columns,
url,
pageSize = 25,
scrollHeight = 'calc(100vh - 270px)',
onDataChange,
authenticated,
initialPage = 1
},
ref
) => {
const isMobile = useMediaQuery({ maxWidth: 768 })
const adjustedScrollHeight = isMobile ? 'calc(100vh - 316px)' : scrollHeight
const [, contextHolder] = message.useMessage()
const tableRef = useRef(null)
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({})
const [initialized, setInitialized] = useState(false)
// Table state
const [pages, setPages] = useState([])
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [totalPages, setTotalPages] = useState(0)
const createSkeletonData = useCallback(() => {
return Array(pageSize)
.fill(null)
.map(() => ({
_id: `skeleton-${Math.random().toString(36).substring(2, 15)}`,
isSkeleton: true
}))
}, [pageSize])
const fetchData = useCallback(
async (pageNum = 1) => {
try {
const response = await axios.get(url, {
params: {
page: pageNum,
limit: pageSize,
...filters,
sort: sorter.field,
order: sorter.order
},
headers: {
Accept: 'application/json'
},
withCredentials: true
})
const newData = response.data
const totalCount = parseInt(
response.headers['x-total-count'] || '0',
10
)
setTotalPages(Math.ceil(totalCount / pageSize))
setHasMore(newData.length >= pageSize)
setPages((prev) => {
const existingPageIndex = prev.findIndex(
(p) => p.pageNum === pageNum
)
console.log(prev.map((p) => p.pageNum))
if (existingPageIndex !== -1) {
// Update existing page
const newPages = [...prev]
newPages[existingPageIndex] = { pageNum, items: newData }
return newPages
}
// If page doesn't exist, return unchanged
return prev
})
if (onDataChange) {
onDataChange(newData)
}
setLoading(false)
setLazyLoading(false)
} catch (error) {
setPages((prev) =>
prev.map((page) => ({
...page,
items: page.items.filter((item) => !item.isSkeleton)
}))
)
setLoading(false)
setLazyLoading(false)
throw error
}
},
[url, pageSize, filters, sorter, onDataChange]
)
const loadNextPage = useCallback(() => {
const highestPage = Math.max(...pages.map((p) => p.pageNum))
const nextPage = highestPage + 1
console.log('Next page', nextPage)
if (hasMore) {
setPages((prev) => {
const filteredPages = prev.map((page) => ({
...page,
items: page.items.filter((item) => !item.isSkeleton)
}))
const minPage = Math.min(...filteredPages.map((p) => p.pageNum))
const relevantPages = filteredPages.filter(
(p) => p.pageNum !== minPage
)
return [
...relevantPages,
{ pageNum: nextPage, items: createSkeletonData() }
]
})
fetchData(nextPage)
}
}, [pages, createSkeletonData, fetchData, hasMore])
const loadPreviousPage = useCallback(() => {
const lowestPage = Math.min(...pages.map((p) => p.pageNum))
const prevPage = lowestPage - 1
if (prevPage > 0) {
setPages((prev) => {
const filteredPages = prev.map((page) => ({
...page,
items: page.items.filter((item) => !item.isSkeleton)
}))
const maxPage = Math.max(...filteredPages.map((p) => p.pageNum))
const relevantPages = filteredPages.filter(
(p) => p.pageNum !== maxPage
)
return [
{ pageNum: prevPage, items: createSkeletonData() },
...relevantPages
]
})
fetchData(prevPage)
}
}, [pages, createSkeletonData, fetchData])
const handleScroll = useCallback(
(e) => {
const { target } = e
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
const lowestPage = Math.min(...pages.map((p) => p.pageNum))
const prevPage = lowestPage - 1
console.log(
'Down',
scrollHeight - scrollTop - clientHeight < 100,
lazyLoading
)
// Load more data when scrolling down
if (scrollHeight - scrollTop - clientHeight < 100 && hasMore) {
setTimeout(() => {
target.scrollTop = scrollHeight / 2
}, 0)
setLazyLoading(true)
console.log('Loading next page...')
loadNextPage()
}
// Load previous data when scrolling up
if (scrollTop < 100 && prevPage > 0) {
setTimeout(() => {
target.scrollTop = scrollHeight / 2
}, 0)
setLazyLoading(true)
console.log('Loading previous page...')
loadPreviousPage()
}
},
[lazyLoading, loadNextPage, loadPreviousPage]
)
const reload = useCallback(() => {
return fetchData(1)
}, [fetchData])
const updateData = useCallback((_id, updatedData) => {
setPages((prevPages) =>
prevPages.map((page) => ({
...page,
items: page.items.map((item) =>
item._id === _id ? { ...item, ...updatedData } : item
)
}))
)
}, [])
const goToPage = useCallback(
(pageNum) => {
if (pageNum > 0 && pageNum <= totalPages) {
const pagesToLoad = [pageNum - 1, pageNum, pageNum + 1].filter(
(p) => p > 0 && p <= totalPages
)
return Promise.all(pagesToLoad.map((p) => fetchData(p)))
}
},
[fetchData, totalPages]
)
const loadInitialPage = useCallback(() => {
// Create initial page with skeletons
setPages([
{ pageNum: initialPage, items: createSkeletonData() },
{ pageNum: initialPage + 1, items: createSkeletonData() }
])
// Fetch both pages
return Promise.all([fetchData(initialPage), fetchData(initialPage + 1)])
}, [initialPage, createSkeletonData, fetchData])
useImperativeHandle(ref, () => ({
reload,
setData: (newData) => {
setPages([{ pageNum: 1, items: newData }])
},
updateData,
goToPage
}))
useEffect(() => {
if (authenticated && !pages.includes(initialPage) && !initialized) {
loadInitialPage()
setInitialized(true)
}
}, [authenticated, loadInitialPage, initialPage, pages, initialized])
const handleTableChange = (pagination, filters, sorter) => {
const newFilters = {}
Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) {
newFilters[key] = value[0]
}
})
setFilters(newFilters)
setSorter({
field: sorter.field,
order: sorter.order
})
setPages([])
fetchData(1)
}
const columnsWithSkeleton = columns.map((col) => ({
...col,
render: (text, record) => {
if (record.isSkeleton) {
return (
<Skeleton.Input active size='small' style={{ width: '100%' }} />
)
}
return col.render ? col.render(text, record) : text
}
}))
// Flatten pages array for table display
const tableData = pages.flatMap((page) => page.items)
return (
<>
{contextHolder}
<Table
ref={tableRef}
dataSource={tableData}
columns={columnsWithSkeleton}
className={'dashboard-table'}
pagination={false}
scroll={{ y: adjustedScrollHeight }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
style={{ height: '100%' }}
/>
</>
)
}
)
DashboardTable.displayName = 'DashboardTable'
DashboardTable.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
url: PropTypes.string.isRequired,
pageSize: PropTypes.number,
scrollHeight: PropTypes.string,
onDataChange: PropTypes.func,
authenticated: PropTypes.bool.isRequired,
initialPage: PropTypes.number
}
export default DashboardTable

View File

@ -16,7 +16,7 @@ const propertyOrder = [
const { Text } = Typography
const GCodeFileSelect = ({ onChange, filter, useFilter, style }) => {
const GCodeFileSelect = ({ onChange, filter, useFilter = false, style }) => {
const [gcodeFilesTreeData, setGCodeFilesTreeData] = useState(null)
const [loading, setLoading] = useState(true)
const [searchValue, setSearchValue] = useState('')
@ -98,7 +98,7 @@ const GCodeFileSelect = ({ onChange, filter, useFilter, style }) => {
value: gcodeFile._id,
key: gcodeFile._id,
title: (
<Flex gap={'small'} align='center'>
<Flex gap={'small'} align='center' style={{ width: '100%' }}>
<GCodeFileIcon />
<Badge color={gcodeFile.filament.color} />
<Text ellipsis>
@ -211,9 +211,9 @@ const GCodeFileSelect = ({ onChange, filter, useFilter, style }) => {
}
GCodeFileSelect.propTypes = {
onChange: PropTypes.func.isRequired,
filter: PropTypes.string.isRequired,
useFilter: PropTypes.bool.isRequired,
onChange: PropTypes.func,
filter: PropTypes.string,
useFilter: PropTypes.bool,
style: PropTypes.object
}

View File

@ -1,9 +1,27 @@
// PrinterSelect.js
import React from 'react'
import PropTypes from 'prop-types'
import { Flex, Typography, Button, Tooltip, message } from 'antd'
import { Flex, Typography, Button, Tooltip, message, Space } from 'antd'
import { useNavigate } from 'react-router-dom'
import { useMediaQuery } from 'react-responsive'
import CopyIcon from '../../Icons/CopyIcon'
import PrinterIcon from '../../Icons/PrinterIcon'
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
import FilamentIcon from '../../Icons/FilamentIcon'
import FilamentStockIcon from '../../Icons/FilamentStockIcon'
import GCodeFileIcon from '../../Icons/GCodeFileIcon'
import JobIcon from '../../Icons/JobIcon'
import PartIcon from '../../Icons/PartIcon'
import ProductIcon from '../../Icons/ProductIcon'
import VendorIcon from '../../Icons/VendorIcon'
import SubJobIcon from '../../Icons/SubJobIcon'
import StockEventIcon from '../../Icons/StockEventIcon'
import StockAuditIcon from '../../Icons/StockAuditIcon'
import PartStockIcon from '../../Icons/PartStockIcon'
import ProductStockIcon from '../../Icons/ProductStockIcon'
import AuditLogIcon from '../../Icons/AuditLogIcon'
import PersonIcon from '../../Icons/PersonIcon'
import NoteTypeIcon from '../../Icons/NoteTypeIcon'
const { Text, Link } = Typography
@ -16,73 +34,109 @@ const IdText = ({
}) => {
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
var prefix = 'UNK'
var hyperlink = '#'
var icon = <QuestionCircleIcon style={{ paddingTop: '4px' }} />
switch (type) {
case 'printer':
prefix = 'PRN'
hyperlink = `/dashboard/production/printers/info?printerId=${id}`
icon = <PrinterIcon style={{ paddingTop: '4px' }} />
break
case 'filament':
prefix = 'FIL'
hyperlink = `/dashboard/management/filaments/info?filamentId=${id}`
icon = <FilamentIcon style={{ paddingTop: '4px' }} />
break
case 'spool':
prefix = 'SPL'
hyperlink = `/dashboard/inventory/spool/info?spoolId=${id}`
icon = <FilamentIcon style={{ paddingTop: '4px' }} />
break
case 'gcodeFile':
case 'gcodefile':
prefix = 'GCF'
hyperlink = `/dashboard/production/gcodefiles/info?gcodeFileId=${id}`
icon = <GCodeFileIcon style={{ paddingTop: '4px' }} />
break
case 'job':
prefix = 'JOB'
hyperlink = `/dashboard/production/printjobs/info?printJobId=${id}`
hyperlink = `/dashboard/production/jobs/info?jobId=${id}`
icon = <JobIcon style={{ paddingTop: '4px' }} />
break
case 'part':
prefix = 'PRT'
hyperlink = `/dashboard/management/parts/info?partId=${id}`
icon = <PartIcon style={{ paddingTop: '4px' }} />
break
case 'product':
prefix = 'PRD'
hyperlink = `/dashboard/management/products/info?productId=${id}`
icon = <ProductIcon style={{ paddingTop: '4px' }} />
break
case 'vendor':
prefix = 'VEN'
hyperlink = `/dashboard/management/vendors/info?vendorId=${id}`
icon = <VendorIcon style={{ paddingTop: '4px' }} />
break
case 'subjob':
prefix = 'SJB'
hyperlink = `#`
icon = <SubJobIcon style={{ paddingTop: '4px' }} />
break
case 'filamentstock':
prefix = 'FLS'
hyperlink = `/dashboard/inventory/filamentstocks/info?filamentStockId=${id}`
icon = <FilamentStockIcon style={{ paddingTop: '4px' }} />
break
case 'stockevent':
prefix = 'SEV'
hyperlink = `#`
icon = <StockEventIcon style={{ paddingTop: '4px' }} />
break
case 'stockaudit':
prefix = 'SAU'
hyperlink = `/dashboard/inventory/stockaudits/info?stockAuditId=${id}`
icon = <StockAuditIcon style={{ paddingTop: '4px' }} />
break
case 'partstock':
prefix = 'PTS'
hyperlink = `/dashboard/management/partstocks/info?partStockId=${id}`
icon = <PartStockIcon style={{ paddingTop: '4px' }} />
break
case 'productstock':
prefix = 'PDS'
hyperlink = `/dashboard/management/productstocks/info?productStockId=${id}`
icon = <ProductStockIcon style={{ paddingTop: '4px' }} />
break
case 'auditlog':
prefix = 'ADL'
hyperlink = `#`
icon = <AuditLogIcon style={{ paddingTop: '4px' }} />
break
case 'user':
prefix = 'USR'
hyperlink = `#`
icon = <PersonIcon style={{ paddingTop: '4px' }} />
break
case 'notetype':
prefix = 'NTY'
hyperlink = `/dashboard/management/notetypes/info?noteTypeId=${id}`
icon = <NoteTypeIcon style={{ paddingTop: '4px' }} />
break
default:
hyperlink = `#`
prefix = 'UNK'
icon = <QuestionCircleIcon style={{ paddingTop: '4px' }} />
}
id = id.toString().toUpperCase()
id = typeof id === 'string' ? id.toString().toUpperCase() : 'XXXXXX'
var displayId = prefix + ':' + id
var copyId = prefix + ':' + id
if (longId == false) {
if (longId == false || isMobile) {
displayId = prefix + ':' + id.toString().slice(-6)
}
@ -99,14 +153,20 @@ const IdText = ({
}}
>
<Text code ellipsis>
{displayId}
<Space size={4}>
{icon}
{displayId}
</Space>
</Text>
</Link>
)}
{!showHyperlink && (
<Text code ellipsis>
{displayId}
<Space size={4}>
{icon}
{displayId}
</Space>
</Text>
)}
{showCopy && (

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { Layout, Menu, Flex, Button } from 'antd'
import { DashboardOutlined } from '@ant-design/icons'
import { DashboardOutlined, CaretDownFilled } from '@ant-design/icons'
import FilamentStockIcon from '../../Icons/FilamentStockIcon'
import PartStockIcon from '../../Icons/PartStockIcon'
import ProductStockIcon from '../../Icons/ProductStockIcon'
@ -9,6 +9,7 @@ import StockAuditIcon from '../../Icons/StockAuditIcon'
import StockEventIcon from '../../Icons/StockEventIcon'
import CollapseSidebarIcon from '../../Icons/CollapseSidebarIcon'
import ExpandSidebarIcon from '../../Icons/ExpandSidebarIcon'
import { useMediaQuery } from 'react-responsive'
const { Sider } = Layout
@ -21,6 +22,7 @@ const InventorySidebar = () => {
const savedState = sessionStorage.getItem(SIDEBAR_COLLAPSED_KEY)
return savedState ? JSON.parse(savedState) : false
})
const isMobile = useMediaQuery({ maxWidth: 768 })
useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean)
@ -73,6 +75,20 @@ const InventorySidebar = () => {
}
]
if (isMobile) {
return (
<Menu
mode='horizontal'
selectedKeys={[selectedKey]}
defaultSelectedKeys={['overview']}
items={items}
style={{ width: '100%' }}
_internalDisableMenuItemTitleTooltip
overflowedIndicator={<Button type='text' icon={<CaretDownFilled />} />}
/>
)
}
return (
<Sider width={250} theme='light' collapsed={collapsed}>
<Flex

View File

@ -72,7 +72,7 @@ const JobState = ({
{showId && (
<IdText id={job._id} showCopy={false} type='job' longId={false} />
)}
{showQuantity && <Text>(Quantity: {job.quantity})</Text>}
{showQuantity && <Text>({job.quantity})</Text>}
{showStatus && (
<Space>
<Tag color={badgeStatus} style={{ marginRight: 0 }}>

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { Layout, Menu, Flex, Button } from 'antd'
import { SettingOutlined, AuditOutlined } from '@ant-design/icons'
import { CaretDownFilled } from '@ant-design/icons'
import FilamentIcon from '../../Icons/FilamentIcon'
import PartIcon from '../../Icons/PartIcon'
import ProductIcon from '../../Icons/ProductIcon'
@ -9,6 +9,10 @@ import VendorIcon from '../../Icons/VendorIcon'
import MaterialIcon from '../../Icons/MaterialIcon'
import CollapseSidebarIcon from '../../Icons/CollapseSidebarIcon'
import ExpandSidebarIcon from '../../Icons/ExpandSidebarIcon'
import AuditLogIcon from '../../Icons/AuditLogIcon'
import NoteTypeIcon from '../../Icons/NoteTypeIcon'
import { useMediaQuery } from 'react-responsive'
import SettingsIcon from '../../Icons/SettingsIcon'
const { Sider } = Layout
@ -21,6 +25,7 @@ const ManagementSidebar = () => {
const savedState = sessionStorage.getItem(SIDEBAR_COLLAPSED_KEY)
return savedState ? JSON.parse(savedState) : false
})
const isMobile = useMediaQuery({ maxWidth: 768 })
useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean)
@ -60,18 +65,38 @@ const ManagementSidebar = () => {
label: <Link to='/dashboard/management/materials'>Materials</Link>,
icon: <MaterialIcon />
},
{
key: 'notetypes',
label: <Link to='/dashboard/management/notetypes'>Note Types</Link>,
icon: <NoteTypeIcon />
},
{ type: 'divider' },
{
key: 'settings',
label: <Link to='/dashboard/management/settings'>Settings</Link>,
icon: <SettingOutlined />
icon: <SettingsIcon />
},
{
key: 'audit',
label: <Link to='/dashboard/management/audit'>Audit Log</Link>,
icon: <AuditOutlined />
key: 'auditlogs',
label: <Link to='/dashboard/management/auditlogs'>Audit Log</Link>,
icon: <AuditLogIcon />
}
]
if (isMobile) {
return (
<Menu
mode='horizontal'
selectedKeys={[selectedKey]}
defaultSelectedKeys={['filaments']}
items={items}
style={{ width: '100%' }}
_internalDisableMenuItemTitleTooltip
overflowedIndicator={<Button type='text' icon={<CaretDownFilled />} />}
/>
)
}
return (
<Sider width={250} theme='light' collapsed={collapsed}>
<Flex

View File

@ -0,0 +1,89 @@
import React from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Typography, List, Space } from 'antd'
import PropTypes from 'prop-types'
const { Title, Paragraph, Text } = Typography
const UlComponent = ({ children }) => (
<List
size='small'
dataSource={children}
renderItem={(item) => <List.Item>{item}</List.Item>}
/>
)
UlComponent.propTypes = { children: PropTypes.node }
const OlComponent = ({ children }) => (
<List
size='small'
dataSource={children}
renderItem={(item) => <List.Item>{item}</List.Item>}
/>
)
OlComponent.propTypes = { children: PropTypes.node }
const LiComponent = ({ children }) => <List.Item>{children}</List.Item>
LiComponent.propTypes = { children: PropTypes.node }
const BlockquoteComponent = ({ children }) => (
<Paragraph
style={{
borderLeft: '4px solid #f0f0f0',
paddingLeft: '16px',
margin: '16px 0'
}}
>
{children}
</Paragraph>
)
BlockquoteComponent.propTypes = { children: PropTypes.node }
const MarkdownDisplay = ({ content }) => {
const components = {
h1: (props) => <Title level={1} {...props} />,
h2: (props) => <Title level={2} {...props} />,
h3: (props) => <Title level={3} {...props} />,
h4: (props) => <Title level={4} {...props} />,
h5: (props) => <Title level={5} {...props} />,
h6: (props) => <Title level={6} {...props} />,
p: (props) => <Paragraph {...props} />,
ul: UlComponent,
ol: OlComponent,
li: LiComponent,
strong: (props) => <Text strong {...props} />,
em: (props) => <Text italic {...props} />,
code: ({ inline, ...props }) =>
inline ? (
<Text code {...props} />
) : (
<Paragraph>
<pre
style={{
background: '#f5f5f5',
padding: '16px',
borderRadius: '4px'
}}
>
<code {...props} />
</pre>
</Paragraph>
),
blockquote: BlockquoteComponent
}
return (
<Space direction='vertical' style={{ width: '100%' }}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{content}
</ReactMarkdown>
</Space>
)
}
MarkdownDisplay.propTypes = {
content: PropTypes.string.isRequired
}
export default MarkdownDisplay

View File

@ -27,7 +27,7 @@ const PartsTable = ({ data = [], loading = false, showHeader = false }) => {
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 165,
width: 180,
render: (text) => <IdText id={text} type={'part'} showHyperlink={true} />
}
]

View File

@ -13,9 +13,12 @@ import ReloadIcon from '../../Icons/ReloadIcon'
import config from '../../../config'
const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
const PrinterJobsTree = ({
subJobs: initialSubJobs,
loading: initialLoading
}) => {
const [subJobs, setSubJobs] = useState(initialSubJobs || [])
const [loading, setLoading] = useState(false)
const [treeLoading, setTreeLoading] = useState(initialLoading)
const [error, setError] = useState(null)
const { socket } = useContext(SocketContext)
const [messageApi] = message.useMessage()
@ -25,9 +28,9 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
const handleNodeClick = (selectedKeys) => {
const key = selectedKeys[0]
if (key.startsWith('printjob-')) {
const jobId = key.replace('printjob-', '')
navigate(`/dashboard/production/printjobs/info?printJobId=${jobId}`)
if (key.startsWith('job-')) {
const jobId = key.replace('job-', '')
navigate(`/dashboard/production/jobs/info?jobId=${jobId}`)
}
}
@ -38,52 +41,50 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
return
}
// Group subjobs by printJob
const printJobGroups = subJobsData.reduce((acc, subJob) => {
const printJobId = subJob.printJob._id
if (!acc[printJobId]) {
acc[printJobId] = {
printJob: subJob.printJob,
// Group subjobs by job
const jobGroups = subJobsData.reduce((acc, subJob) => {
const jobId = subJob.job._id
if (!acc[jobId]) {
acc[jobId] = {
job: subJob.job,
subJobs: []
}
}
acc[printJobId].subJobs.push(subJob)
acc[jobId].subJobs.push(subJob)
return acc
}, {})
// Create tree nodes for each printJob
const printJobNodes = Object.values(printJobGroups).map(
({ printJob, subJobs }) => {
setExpandedKeys((prev) => [...prev, `printjob-${printJob._id}`])
return {
// Create tree nodes for each job
const jobNodes = Object.values(jobGroups).map(({ job, subJobs }) => {
setExpandedKeys((prev) => [...prev, `job-${job._id}`])
return {
title: (
<Space size={5}>
<JobIcon />
{'Job'}
<JobState job={job} />
</Space>
),
key: `job-${job._id}`,
children: subJobs.map((subJob) => ({
title: (
<Space size={5}>
<JobIcon />
{'Job'}
<JobState job={printJob} />
<Space>
<SubJobIcon />
{'Sub Job'}
<SubJobState
subJob={subJob}
showProgress={false}
showControls={false}
/>
</Space>
),
key: `printjob-${printJob._id}`,
children: subJobs.map((subJob) => ({
title: (
<Space>
<SubJobIcon />
{'Sub Job'}
<SubJobState
subJob={subJob}
showProgress={false}
showControls={false}
/>
</Space>
),
key: `subjob-${subJob._id}`,
isLeaf: true
}))
}
key: `subjob-${subJob._id}`,
isLeaf: true
}))
}
)
})
setTreeData(printJobNodes)
setTreeData(jobNodes)
}
useEffect(() => {
@ -94,8 +95,8 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
const initializeData = async () => {
if (!initialSubJobs) {
try {
setLoading(true)
const response = await axios.get(`${config.backendUrl}/printjobs`, {
setTreeLoading(true)
const response = await axios.get(`${config.backendUrl}/jobs`, {
headers: { Accept: 'application/json' },
withCredentials: true
})
@ -106,7 +107,7 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
setError('Failed to fetch sub jobs')
messageApi.error('Failed to fetch sub jobs')
} finally {
setLoading(false)
setTreeLoading(false)
}
} else {
setSubJobs(initialSubJobs)
@ -142,14 +143,6 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
}
}, [initialSubJobs, socket])
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
</div>
)
}
if (error) {
return (
<Space
@ -165,15 +158,17 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
}
return (
<Card>
<Tree
treeData={treeData}
expandedKeys={expandedKeys}
onExpand={setExpandedKeys}
onSelect={handleNodeClick}
showLine={true}
/>
</Card>
<Spin indicator={<LoadingOutlined />} spinning={treeLoading}>
<Card>
<Tree
treeData={treeData}
expandedKeys={expandedKeys}
onExpand={setExpandedKeys}
onSelect={handleNodeClick}
showLine={true}
/>
</Card>
</Spin>
)
}
@ -183,7 +178,7 @@ PrinterJobsTree.propTypes = {
state: PropTypes.object.isRequired,
_id: PropTypes.string.isRequired,
printer: PropTypes.string.isRequired,
printJob: PropTypes.shape({
job: PropTypes.shape({
state: PropTypes.object.isRequired,
_id: PropTypes.string.isRequired,
printers: PropTypes.arrayOf(PropTypes.string).isRequired,
@ -199,7 +194,8 @@ PrinterJobsTree.propTypes = {
createdAt: PropTypes.string.isRequired,
updatedAt: PropTypes.string.isRequired
})
)
),
loading: PropTypes.bool
}
export default PrinterJobsTree

View File

@ -0,0 +1,230 @@
import React, { useContext, useState, useEffect } from 'react'
import { Typography, Spin, Flex, Space, Slider, Descriptions, Tag } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import { SocketContext } from '../context/SocketContext'
import PropTypes from 'prop-types'
const { Text } = Typography
const PrinterMiscPanel = ({
printerId,
showControls = true,
shouldUnsubscribe = true
}) => {
const [miscData, setMiscData] = useState({
fan: {
speed: 0,
target: 0
},
ledBacklight: {
// eslint-disable-line camelcase
brightness: 0
},
beeper: {
value: 0
},
filamentSensor: {
enabled: false,
filamentDetected: false
},
heaterFan: {
speed: 0
}
})
const [initialized, setInitialized] = useState(false)
const { socket } = useContext(SocketContext)
const [fanSpeed, setFanSpeed] = useState(0)
const [ledBrightness, setLedBrightness] = useState(0)
const [beeperValue, setBeeperValue] = useState(0)
useEffect(() => {
const params = {
printerId,
objects: {
fan: null,
'filament_switch_sensor fsensor': null,
'output_pin BEEPER_pin': null,
'output_pin LCD_backlight_pin': null,
'heater_fan nozzle_cooling_fan': null
}
}
const notifyMiscStatusUpdate = (statusUpdate) => {
if (statusUpdate?.fan) {
setMiscData((prevData) => ({
...prevData,
fan: statusUpdate.fan
}))
setFanSpeed(statusUpdate.fan.speed)
}
if (statusUpdate?.['heater_fan nozzle_cooling_fan']) {
setMiscData((prevData) => ({
...prevData,
heaterFan: statusUpdate['heater_fan nozzle_cooling_fan']
}))
}
if (statusUpdate?.['output_pin LCD_backlight_pin']) {
setMiscData((prevData) => ({
...prevData,
ledBacklight: statusUpdate['output_pin LCD_backlight_pin'].value
}))
setLedBrightness(statusUpdate['output_pin LCD_backlight_pin'].value)
}
if (statusUpdate?.['output_pin BEEPER_pin']) {
setMiscData((prevData) => ({
...prevData,
beeper: statusUpdate['output_pin BEEPER_pin']
}))
setBeeperValue(statusUpdate['output_pin BEEPER_pin'].value)
}
if (statusUpdate?.['filament_switch_sensor fsensor']) {
setMiscData((prevData) => ({
...prevData,
filamentSensor: {
enabled: statusUpdate['filament_switch_sensor fsensor'].enabled,
filamentDetected:
statusUpdate['filament_switch_sensor fsensor'].filament_detected
}
}))
}
}
if (!initialized && socket.connected) {
setInitialized(true)
socket.on('connect', () => {
socket.emit('printer.objects.subscribe', params)
socket.emit('printer.objects.query', params)
})
socket.emit('printer.objects.subscribe', params)
socket.emit('printer.objects.query', params)
socket.on('notify_status_update', notifyMiscStatusUpdate)
}
return () => {
if (socket.connected && initialized && shouldUnsubscribe) {
socket.off('notify_status_update', notifyMiscStatusUpdate)
socket.emit('printer.objects.unsubscribe', params)
}
}
}, [socket, initialized, printerId, shouldUnsubscribe])
const handleSetFanSpeed = (value) => {
if (socket) {
socket.emit('printer.gcode.script', {
printerId,
script: `M106 S${Math.round(value * 255)}`
})
}
}
const handleSetLedBrightness = (value) => {
if (socket) {
socket.emit('printer.gcode.script', {
printerId,
script: `SET_LED LED=led_backlight BRIGHTNESS=${value}`
})
}
}
const handleSetBeeperValue = (value) => {
if (socket) {
socket.emit('printer.gcode.script', {
printerId,
script: `M300 S440 P200 V${Math.round(value * 100)}`
})
}
}
return (
<div style={{ minWidth: 190 }}>
{miscData ? (
<Flex vertical gap='middle'>
<Flex vertical gap={'middle'}>
{showControls && (
<>
<Space direction='vertical' style={{ width: '100%' }}>
<Text>Fan Speed: {Math.round(fanSpeed * 100)}%</Text>
<Slider
value={fanSpeed}
min={0}
max={1}
step={0.01}
onChange={setFanSpeed}
onAfterChange={handleSetFanSpeed}
tooltip={{ open: false }}
/>
<Text>LED Backlight: {Math.round(ledBrightness * 100)}%</Text>
<Slider
value={ledBrightness}
min={0}
max={1}
step={0.01}
onChange={setLedBrightness}
onAfterChange={handleSetLedBrightness}
tooltip={{ open: false }}
/>
<Text>Beeper Pitch: {Math.round(beeperValue * 100)}%</Text>
<Slider
value={beeperValue}
min={0}
max={1}
step={0.01}
onChange={setBeeperValue}
onAfterChange={handleSetBeeperValue}
tooltip={{ open: false }}
/>
</Space>
</>
)}
<Descriptions column={1} size='small' bordered>
<Descriptions.Item label='Filament Sensor'>
{miscData.filamentSensor.enabled ? (
miscData.filamentSensor.filamentDetected ? (
<Tag color='green'>Detected</Tag>
) : (
<Tag color='red'>Open</Tag>
)
) : (
<Tag color='default'>Disabled</Tag>
)}
</Descriptions.Item>
<Descriptions.Item label='Part Cooling Fan'>
{miscData.fan.speed > 0 ? (
<Text>Running ({Math.round(miscData.fan.speed * 100)}%)</Text>
) : (
<Text>Off</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Nozzle Cooling Fan'>
{miscData.heaterFan.speed > 0 ? (
<Text>
Running ({Math.round(miscData.heaterFan.speed * 100)}%)
</Text>
) : (
<Text>Off</Text>
)}
</Descriptions.Item>
</Descriptions>
</Flex>
</Flex>
) : (
<Flex justify='centre'>
<Spin indicator={<LoadingOutlined spin />} size='large' />
</Flex>
)}
</div>
)
}
PrinterMiscPanel.propTypes = {
printerId: PropTypes.string.isRequired,
showControls: PropTypes.bool,
shouldUnsubscribe: PropTypes.bool
}
export default PrinterMiscPanel

View File

@ -6,8 +6,9 @@ import {
Space,
Collapse,
InputNumber,
Switch,
Descriptions
Descriptions,
Button,
Tag
} from 'antd'
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
import { SocketContext } from '../context/SocketContext'
@ -46,6 +47,10 @@ const PrinterPositionPanel = ({
const [initialized, setInitialized] = useState(false)
const { socket } = useContext(SocketContext)
const [speedFactor, setSpeedFactor] = useState(positionData.speed_factor)
const [extrudeFactor, setExtrudeFactor] = useState(
positionData.extrude_factor
)
useEffect(() => {
const params = {
@ -57,7 +62,6 @@ const PrinterPositionPanel = ({
}
const notifyPositionStatusUpdate = (statusUpdate) => {
console.log(statusUpdate)
if (statusUpdate?.toolhead) {
setPositionData((prevData) => ({
...prevData,
@ -72,44 +76,44 @@ const PrinterPositionPanel = ({
}
}
if (!initialized && socket) {
if (!initialized && socket.connected) {
setInitialized(true)
socket.on('connect', () => {
console.log('Connected to socket!')
socket.emit('printer.objects.subscribe', params)
socket.emit('printer.objects.query', params)
})
console.log('Subscribing to position data')
socket.emit('printer.objects.subscribe', params)
socket.emit('printer.objects.query', params)
socket.on('notify_status_update', notifyPositionStatusUpdate)
}
setSpeedFactor(positionData.speed_factor)
setExtrudeFactor(positionData.extrude_factor)
return () => {
if (socket && initialized && shouldUnsubscribe) {
console.log('Unsubscribing...')
if (socket.connected && initialized && shouldUnsubscribe) {
socket.off('notify_status_update', notifyPositionStatusUpdate)
socket.emit('printer.objects.unsubscribe', params)
}
}
}, [socket, initialized, printerId, shouldUnsubscribe])
const handleSetSpeedFactor = (value) => {
const handleSetSpeedFactor = () => {
if (socket) {
socket.emit('printer.gcode.script', {
printerId,
script: `M220 S${value * 100}`
script: `M220 S${speedFactor * 100}`
})
}
}
const handleSetExtrudeFactor = (value) => {
const handleSetExtrudeFactor = () => {
if (socket) {
socket.emit('printer.gcode.script', {
printerId,
script: `M221 S${value * 100}`
script: `M221 S${extrudeFactor * 100}`
})
}
}
@ -181,49 +185,69 @@ const PrinterPositionPanel = ({
{positionData.position[3].toFixed(2)}mm
</Descriptions.Item>
</Descriptions>
<Descriptions column={1} size='small' bordered>
<Descriptions.Item label='Current Speed'>
{positionData.speed}mm/s
</Descriptions.Item>
</Descriptions>
{showControls && (
<>
<Space direction='vertical' style={{ width: '100%' }}>
<Space direction='horizontal'>
<Text>Speed Factor:</Text>
<InputNumber
value={positionData.speed_factor}
min={0.1}
max={2}
step={0.1}
style={{ width: '100px' }}
onChange={(value) => handleSetSpeedFactor(value)}
/>
<Space.Compact block size='small'>
<InputNumber
value={speedFactor.toFixed(2)}
min={0.1}
max={2}
step={0.1}
style={{ width: '100px' }}
onChange={(value) => setSpeedFactor(value)}
onPressEnter={handleSetSpeedFactor}
size='small'
/>
<Button
type='default'
style={{ width: 40 }}
onClick={handleSetSpeedFactor}
>
Set
</Button>
</Space.Compact>
</Space>
<Space direction='horizontal'>
<Text>Extrude Factor:</Text>
<InputNumber
value={positionData.extrude_factor}
min={0.1}
max={2}
step={0.1}
style={{ width: '100px' }}
onChange={(value) => handleSetExtrudeFactor(value)}
/>
</Space>
<Space direction='horizontal'>
<Text>Absolute Coordinates:</Text>
<Switch
checked={positionData.absolute_coordinates}
disabled={true}
/>
<Space.Compact block size='small'>
<InputNumber
value={extrudeFactor.toFixed(2)}
min={0.1}
max={2}
step={0.1}
style={{ width: '100px' }}
onChange={(value) => setExtrudeFactor(value)}
onPressEnter={handleSetExtrudeFactor}
size='small'
/>
<Button
type='default'
style={{ width: 40 }}
onClick={handleSetExtrudeFactor}
>
Set
</Button>
</Space.Compact>
</Space>
</Space>
</>
)}
<Descriptions column={1} size='small' bordered>
<Descriptions.Item label='Current Speed'>
{positionData.speed.toFixed(2)}mm/s
</Descriptions.Item>
<Descriptions.Item label='Absolute Coordinates'>
{positionData.absolute_coordinates ? (
<Tag color='green'>Yes</Tag>
) : (
<Tag color='red'>No</Tag>
)}
</Descriptions.Item>
</Descriptions>
</Flex>
{showMoreInfo && (

View File

@ -1,7 +1,7 @@
// PrinterSelect.js
import PropTypes from 'prop-types'
import { TreeSelect, message, Tag } from 'antd'
import React, { useEffect, useState, useContext } from 'react'
import React, { useEffect, useState, useContext, useCallback } from 'react'
import axios from 'axios'
import PrinterState from './PrinterState'
import { AuthContext } from '../context/AuthContext'
@ -16,7 +16,7 @@ const PrinterSelect = ({ onChange, disabled, checkable, value = [] }) => {
const { authenticated } = useContext(AuthContext)
const fetchPrintersTreeData = async () => {
const fetchPrintersTreeData = useCallback(async () => {
if (!authenticated) {
return
}
@ -41,11 +41,10 @@ const PrinterSelect = ({ onChange, disabled, checkable, value = [] }) => {
)
}
}
}
}, [authenticated, messageApi])
const generatePrinterItems = async () => {
const generatePrinterItems = useCallback(async () => {
const printerData = await fetchPrintersTreeData()
setPrintersData(printerData)
// Create a map to store tags and their printers
@ -92,7 +91,7 @@ const PrinterSelect = ({ onChange, disabled, checkable, value = [] }) => {
return [...filtered, newNode]
})
})
}
}, [fetchPrintersTreeData])
const handleOnChange = (value, selectedOptions) => {
if (checkable) {
@ -113,18 +112,10 @@ const PrinterSelect = ({ onChange, disabled, checkable, value = [] }) => {
}
useEffect(() => {
if (value) {
if (Array.isArray(value)) {
setDefaultValue(value)
} else {
setDefaultValue([value])
}
if (authenticated) {
generatePrinterItems()
}
}, [value])
useEffect(() => {
generatePrinterItems()
}, [])
}, [authenticated, generatePrinterItems])
return (
<TreeSelect
@ -135,18 +126,18 @@ const PrinterSelect = ({ onChange, disabled, checkable, value = [] }) => {
treeDefaultExpandAll
treeCheckable={checkable}
treeNodeFilterProp='title'
placeholder='Select printer'
placeholder='Select Printer'
style={{ width: '100%' }}
value={
checkable ? defaultValue.map((item) => item._id) : defaultValue[0]?._id
checkable ? defaultValue.map((item) => item._id) : defaultValue?._id
}
/>
)
}
PrinterSelect.propTypes = {
onChange: PropTypes.func.isRequired,
disabled: PropTypes.bool.isRequired,
onChange: PropTypes.func,
disabled: PropTypes.bool,
checkable: PropTypes.bool,
value: PropTypes.oneOfType([PropTypes.object, PropTypes.array])
}

View File

@ -1,12 +1,13 @@
import React, { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { Layout, Menu, Flex, Button } from 'antd'
import { DashboardOutlined } from '@ant-design/icons'
import { DashboardOutlined, CaretDownFilled } from '@ant-design/icons'
import GCodeFileIcon from '../../Icons/GCodeFileIcon'
import JobIcon from '../../Icons/JobIcon'
import PrinterIcon from '../../Icons/PrinterIcon'
import CollapseSidebarIcon from '../../Icons/CollapseSidebarIcon'
import ExpandSidebarIcon from '../../Icons/ExpandSidebarIcon'
import { useMediaQuery } from 'react-responsive'
const { Sider } = Layout
@ -19,6 +20,7 @@ const ProductionSidebar = () => {
const savedState = sessionStorage.getItem(SIDEBAR_COLLAPSED_KEY)
return savedState ? JSON.parse(savedState) : false
})
const isMobile = useMediaQuery({ maxWidth: 768 })
useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean)
@ -45,8 +47,8 @@ const ProductionSidebar = () => {
icon: <PrinterIcon />
},
{
key: 'printjobs',
label: <Link to='/dashboard/production/printjobs'>Print Jobs</Link>,
key: 'jobs',
label: <Link to='/dashboard/production/jobs'>Print Jobs</Link>,
icon: <JobIcon />
},
{
@ -55,6 +57,20 @@ const ProductionSidebar = () => {
icon: <GCodeFileIcon />
}
]
if (isMobile) {
return (
<Menu
mode='horizontal'
selectedKeys={[selectedKey]}
defaultSelectedKeys={['overview']}
items={items}
_internalDisableMenuItemTitleTooltip
overflowedIndicator={<Button type='text' icon={<CaretDownFilled />} />}
/>
)
}
return (
<Sider width={250} theme='light' collapsed={collapsed}>
<Flex

View File

@ -0,0 +1,67 @@
import React, { forwardRef, useImperativeHandle } from 'react'
import { Card } from 'antd'
import { ProFormTextArea } from '@ant-design/pro-components'
import PropTypes from 'prop-types'
const RichTextEditor = forwardRef(
(
{
value,
onChange,
placeholder = 'Enter text here...',
height = '200px',
readOnly = false,
className
},
ref
) => {
useImperativeHandle(ref, () => ({
getValue: () => value,
setValue: (newValue) => onChange?.({ target: { value: newValue } }),
focus: () => {
const textarea = document.querySelector(
'.ant-pro-form-textarea textarea'
)
if (textarea) {
textarea.focus()
}
}
}))
return (
<Card className={className} bodyStyle={{ padding: '12px' }}>
<ProFormTextArea
name='content'
fieldProps={{
value,
onChange,
placeholder,
style: {
height,
resize: 'vertical',
fontFamily: 'monospace'
},
readOnly
}}
placeholder={placeholder}
allowClear
showCount
maxLength={10000}
/>
</Card>
)
}
)
RichTextEditor.displayName = 'RichTextEditor'
RichTextEditor.propTypes = {
value: PropTypes.string,
onChange: PropTypes.func,
placeholder: PropTypes.string,
height: PropTypes.string,
readOnly: PropTypes.bool,
className: PropTypes.string
}
export default RichTextEditor

View File

@ -14,14 +14,14 @@ import ReloadIcon from '../../Icons/ReloadIcon'
import config from '../../../config'
const SubJobsTree = ({ printJobData }) => {
const SubJobsTree = ({ jobData, loading }) => {
const [treeData, setTreeData] = useState([])
const [loading, setLoading] = useState(false)
const [treeLoading, setTreeLoading] = useState(loading)
const [error, setError] = useState(null)
const { socket } = useContext(SocketContext)
const [messageApi] = message.useMessage()
const [expandedKeys, setExpandedKeys] = useState([])
const [currentPrintJobData, setCurrentPrintJobData] = useState(null)
const [currentJobData, setCurrentJobData] = useState(null)
const navigate = useNavigate()
const buildTreeData = useCallback(
@ -83,29 +83,29 @@ const SubJobsTree = ({ printJobData }) => {
}
useEffect(() => {
buildTreeData(currentPrintJobData)
}, [currentPrintJobData])
buildTreeData(currentJobData)
}, [currentJobData])
useEffect(() => {
const initializeData = async () => {
if (!printJobData) {
if (!jobData) {
try {
setLoading(true)
const response = await axios.get(`${config.backendUrl}/printjobs`, {
setTreeLoading(true)
const response = await axios.get(`${config.backendUrl}/jobs`, {
headers: { Accept: 'application/json' },
withCredentials: true
})
if (response.data) {
setCurrentPrintJobData(response.data)
setCurrentJobData(response.data)
}
} catch (err) {
setError('Failed to fetch print job details')
messageApi.error('Failed to fetch print job details')
} finally {
setLoading(false)
setTreeLoading(false)
}
} else {
setCurrentPrintJobData(printJobData)
setCurrentJobData(jobData)
}
}
@ -115,7 +115,7 @@ const SubJobsTree = ({ printJobData }) => {
if (socket) {
socket.on('notify_deployment_update', (updateData) => {
console.log('Received deployment update:', updateData)
setCurrentPrintJobData((prevData) => {
setCurrentJobData((prevData) => {
if (!prevData) return prevData
// Handle printer updates
@ -152,7 +152,7 @@ const SubJobsTree = ({ printJobData }) => {
// Handle sub-job updates
if (updateData.subJobId) {
console.log('Received subjob update:', updateData)
setCurrentPrintJobData((prevData) => {
setCurrentJobData((prevData) => {
if (!prevData) return prevData
return {
...prevData,
@ -178,15 +178,7 @@ const SubJobsTree = ({ printJobData }) => {
socket.off('notify_deployment_update')
}
}
}, [printJobData, socket])
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
</div>
)
}
}, [jobData, socket])
if (error) {
return (
@ -195,10 +187,7 @@ const SubJobsTree = ({ printJobData }) => {
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error}</p>
<Button
icon={<ReloadIcon />}
onClick={() => buildTreeData(printJobData)}
>
<Button icon={<ReloadIcon />} onClick={() => buildTreeData(jobData)}>
Retry
</Button>
</Space>
@ -206,20 +195,23 @@ const SubJobsTree = ({ printJobData }) => {
}
return (
<Card>
<Tree
treeData={treeData}
expandedKeys={expandedKeys}
onExpand={setExpandedKeys}
onSelect={handleNodeClick}
showLine={true}
/>
</Card>
<Spin indicator={<LoadingOutlined />} spinning={treeLoading}>
<Card style={{ minHeight: 160 }}>
<Tree
treeData={treeData}
expandedKeys={expandedKeys}
onExpand={setExpandedKeys}
onSelect={handleNodeClick}
showLine={true}
/>
</Card>
</Spin>
)
}
SubJobsTree.propTypes = {
printJobData: PropTypes.object.isRequired
jobData: PropTypes.object.isRequired,
loading: PropTypes.bool
}
export default SubJobsTree

View File

@ -215,7 +215,7 @@ const AuthProvider = ({ children }) => {
open={showSessionExpiredModal}
onOk={handleSessionExpiredModalOk}
okText='Log In'
style={{ maxWidth: 430 }}
style={{ maxWidth: 430, top: '50%', transform: 'translateY(-50%)' }}
closable={false}
centered
maskClosable={false}
@ -244,9 +244,8 @@ const AuthProvider = ({ children }) => {
loginWithSSO()
}}
okText='Log In'
style={{ maxWidth: 430 }}
style={{ maxWidth: 430, top: '50%', transform: 'translateY(-50%)' }}
closable={false}
centered
maskClosable={false}
footer={[
<Button

View File

@ -0,0 +1,176 @@
import { useState, useCallback } from 'react'
import axios from 'axios'
export const useTableData = ({
url,
pageSize,
initialPage = 1,
onDataChange,
filters = {},
sorter = {}
}) => {
const [pages, setPages] = useState([])
const [hasMore, setHasMore] = useState(true)
const [hasPrevious, setHasPrevious] = useState(initialPage > 1)
const [loading, setLoading] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [totalPages, setTotalPages] = useState(0)
const [loadedPages, setLoadedPages] = useState([])
const [loadingPages, setLoadingPages] = useState(new Set())
const [currentLoadedPageNumber, setCurrentLoadedPageNumber] =
useState(initialPage)
const createSkeletonData = useCallback(() => {
return Array(pageSize)
.fill(null)
.map(() => ({
_id: `skeleton-${Math.random().toString(36).substring(2, 15)}`,
isSkeleton: true
}))
}, [pageSize])
const fetchData = useCallback(
async (pageNum = 1, append = false, prepend = false) => {
if (loadingPages.has(pageNum)) {
return
}
try {
setLoadingPages((prev) => new Set([...prev, pageNum]))
const response = await axios.get(url, {
params: {
page: pageNum,
limit: pageSize,
...filters,
sort: sorter.field,
order: sorter.order
},
headers: {
Accept: 'application/json'
},
withCredentials: true
})
const newData = response.data
const totalCount = parseInt(
response.headers['x-total-count'] || '0',
10
)
setTotalPages(Math.ceil(totalCount / pageSize))
setHasMore(newData.length >= pageSize)
setHasPrevious(pageNum > 1)
if (append) {
setPages((prev) => {
const filteredPages = prev.map((page) => ({
...page,
items: page.items.filter((item) => !item.isSkeleton)
}))
const relevantPages = filteredPages.slice(-2)
return [...relevantPages, { pageNum, items: newData }]
})
setLoadedPages((prev) => {
const relevantPages = prev.slice(-2)
return [...relevantPages, pageNum]
})
} else if (prepend) {
setPages((prev) => {
const filteredPages = prev.map((page) => ({
...page,
items: page.items.filter((item) => !item.isSkeleton)
}))
const relevantPages = filteredPages.slice(0, 2)
return [{ pageNum, items: newData }, ...relevantPages]
})
setLoadedPages((prev) => {
const relevantPages = prev.slice(0, 2)
return [pageNum, ...relevantPages]
})
} else {
setPages([{ pageNum, items: newData }])
setLoadedPages([pageNum])
}
if (onDataChange) {
onDataChange(newData)
}
setLoading(false)
setLazyLoading(false)
} catch (error) {
setPages((prev) =>
prev.map((page) => ({
...page,
items: page.items.filter((item) => !item.isSkeleton)
}))
)
setLoading(false)
setLazyLoading(false)
throw error
} finally {
setLoadingPages((prev) => {
const newSet = new Set(prev)
newSet.delete(pageNum)
return newSet
})
}
},
[url, pageSize, filters, sorter, onDataChange, loadingPages]
)
const reload = useCallback(() => {
setCurrentLoadedPageNumber(1)
setLoadedPages([1])
return fetchData(1)
}, [fetchData])
const updateData = useCallback((_id, updatedData) => {
setPages((prevPages) =>
prevPages.map((page) => ({
...page,
items: page.items.map((item) =>
item._id === _id ? { ...item, ...updatedData } : item
)
}))
)
}, [])
const goToPage = useCallback(
(pageNum) => {
if (pageNum > 0 && pageNum <= totalPages) {
setCurrentLoadedPageNumber(pageNum)
const pagesToLoad = [pageNum - 1, pageNum, pageNum + 1].filter(
(p) => p > 0 && p <= totalPages
)
setLoadedPages(pagesToLoad)
return Promise.all(
pagesToLoad.map((p) => fetchData(p, p > pageNum, p < pageNum))
)
}
},
[fetchData, totalPages]
)
return {
pages,
hasMore,
hasPrevious,
loading,
lazyLoading,
totalPages,
loadedPages,
loadingPages,
currentLoadedPageNumber,
createSkeletonData,
fetchData,
reload,
updateData,
goToPage,
setCurrentLoadedPageNumber,
setLazyLoading,
setPages,
setLoadedPages
}
}

View File

@ -0,0 +1,121 @@
import { useCallback } from 'react'
export const useTableScroll = ({
lazyLoading,
hasMore,
hasPrevious,
currentLoadedPageNumber,
loadingPages,
createSkeletonData,
fetchData,
setCurrentLoadedPageNumber,
setLazyLoading,
setPages,
setLoadedPages,
loadedPages
}) => {
const handleScroll = useCallback(
(e) => {
const { target } = e
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
// Load more data when scrolling down
if (
scrollHeight - scrollTop - clientHeight < 100 &&
!lazyLoading &&
hasMore
) {
console.log(loadedPages)
const lowestPage = Math.max(...loadedPages)
const nextPage = lowestPage + 1
if (!loadingPages.has(nextPage)) {
setLazyLoading(true)
setCurrentLoadedPageNumber(nextPage)
setPages((prev) => {
const filteredPages = prev.map((page) => ({
...page,
items: page.items.filter((item) => !item.isSkeleton)
}))
const relevantPages = filteredPages.slice(-2)
console.log('Pages after scroll down:', {
current: currentLoadedPageNumber,
next: nextPage,
keeping: relevantPages.map((p) => p.pageNum)
})
return [
...relevantPages,
{ pageNum: nextPage, items: createSkeletonData() }
]
})
// Adjust scroll position to center the new content
setTimeout(() => {
const newScrollTop = (target.scrollHeight - clientHeight) / 2
target.scrollTo({ top: newScrollTop })
}, 0)
fetchData(nextPage, true)
}
}
// Load previous data when scrolling up
if (
scrollTop < 100 &&
!lazyLoading &&
hasPrevious &&
currentLoadedPageNumber > 1
) {
const lowestPage = Math.min(...loadedPages)
const prevPage = lowestPage - 1
if (!loadingPages.has(prevPage)) {
setLazyLoading(true)
setCurrentLoadedPageNumber(prevPage)
setPages((prev) => {
const relevantPages = filteredPages.slice(0, 1)
const filteredPages = prev.map((page) => ({
...page,
items: page.items.filter((item) => !item.isSkeleton)
}))
console.log('Pages after scroll up:', {
current: currentLoadedPageNumber,
prev: prevPage,
keeping: relevantPages.map((p) => p.pageNum)
})
return [
{ pageNum: prevPage, items: createSkeletonData() },
...relevantPages
]
})
setLoadedPages((prev) => {
const relevantPages = prev.slice(0, 1)
return [prevPage, ...relevantPages]
})
// Adjust scroll position to center the new content
setTimeout(() => {
const newScrollTop = (target.scrollHeight - clientHeight) / 2
target.scrollTo({ top: newScrollTop })
}, 0)
fetchData(prevPage, false, true)
}
}
},
[
lazyLoading,
hasMore,
hasPrevious,
currentLoadedPageNumber,
loadingPages,
createSkeletonData,
fetchData,
setCurrentLoadedPageNumber,
setLazyLoading,
setPages,
setLoadedPages,
loadedPages
]
)
return { handleScroll }
}

View File

@ -0,0 +1,7 @@
import React from 'react'
import Icon from '@ant-design/icons'
import { ReactComponent as CustomIconSvg } from '../../assets/icons/auditlogicon.min.svg'
const AuditLogIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default AuditLogIcon

View File

@ -0,0 +1,7 @@
import React from 'react'
import Icon from '@ant-design/icons'
import { ReactComponent as CustomIconSvg } from '../../assets/icons/notetypeicon.min.svg'
const NoteTypeIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default NoteTypeIcon

View File

@ -0,0 +1,7 @@
import React from 'react'
import Icon from '@ant-design/icons'
import { ReactComponent as CustomIconSvg } from '../../assets/icons/settingsicon.min.svg'
const SettingsIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default SettingsIcon

View File

@ -0,0 +1,9 @@
import React from 'react'
import Icon from '@ant-design/icons'
import { ReactComponent as CustomIconSvg } from '../../assets/logos/farmcontrollogosmall.svg'
const FarmControlLogoSmall = (props) => (
<Icon component={CustomIconSvg} {...props} />
)
export default FarmControlLogoSmall

View File

@ -1,12 +1,23 @@
// PrivateRoute.js
import PropTypes from 'prop-types'
import React, { useContext } from 'react'
import React, { useContext, useState, useEffect } from 'react'
import { AuthContext } from './Dashboard/context/AuthContext'
import AuthLoading from './App/AppLoading'
import { useThemeContext } from './Dashboard/context/ThemeContext'
const PrivateRoute = ({ component: Component }) => {
const { isDarkMode } = useThemeContext()
const { authenticated, loading, showSessionExpiredModal } =
useContext(AuthContext)
const [fadeIn, setFadeIn] = useState(false)
useEffect(() => {
if (!loading) {
// Small delay to ensure smooth transition
const timer = setTimeout(() => setFadeIn(true), 50)
return () => clearTimeout(timer)
}
}, [loading])
// Show loading state while auth state is being determined
if (loading) {
@ -14,10 +25,21 @@ const PrivateRoute = ({ component: Component }) => {
}
// Redirect to login if not authenticated
return authenticated || showSessionExpiredModal ? (
<Component />
) : (
<Component />
return (
<div style={{ background: isDarkMode ? '#000000' : '#ffffff' }}>
<div
style={{
opacity: fadeIn ? 1 : 0,
transition: 'opacity 0.3s ease-in-out'
}}
>
{authenticated || showSessionExpiredModal ? (
<Component />
) : (
<Component />
)}
</div>
</div>
)
}

View File

@ -1,7 +1,7 @@
const config = {
development: {
backendUrl: 'http://localhost:8080',
wsUrl: 'ws://localhost:8081'
backendUrl: 'http://192.168.68.53:8080',
wsUrl: 'ws://192.168.68.53:8081'
},
production: {
backendUrl: 'http://localhost:8080', // Replace with your production backend URL

View File

@ -1,15 +1,5 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
.ant-modal-mask {