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

View File

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

View File

@ -1,6 +1,18 @@
body, :root {
.ant-typography { --unit-100vh: 100vh;
font-family: 'SF Pro'; }
@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 { .App {

View File

@ -13,8 +13,8 @@ import Printers from './components/Dashboard/Production/Printers'
import ControlPrinter from './components/Dashboard/Production/Printers/ControlPrinter.jsx' import ControlPrinter from './components/Dashboard/Production/Printers/ControlPrinter.jsx'
import PrinterInfo from './components/Dashboard/Production/Printers/PrinterInfo.jsx' import PrinterInfo from './components/Dashboard/Production/Printers/PrinterInfo.jsx'
import PrintJobs from './components/Dashboard/Production/PrintJobs.jsx' import Jobs from './components/Dashboard/Production/Jobs.jsx'
import PrintJobInfo from './components/Dashboard/Production/PrintJobs/PrintJobInfo.jsx' import JobInfo from './components/Dashboard/Production/Jobs/JobInfo.jsx'
import Filaments from './components/Dashboard/Management/Filaments' import Filaments from './components/Dashboard/Management/Filaments'
import FilamentInfo from './components/Dashboard/Management/Filaments/FilamentInfo.jsx' 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 { AuthProvider } from './components/Dashboard/context/AuthContext.js'
import { SpotlightProvider } from './components/Dashboard/context/SpotlightContext.js' import { SpotlightProvider } from './components/Dashboard/context/SpotlightContext.js'
import StockEvents from './components/Dashboard/Inventory/StockEvents.jsx' import StockEvents from './components/Dashboard/Inventory/StockEvents.jsx'
import Settings from './components/Dashboard/Management/Settings' import Settings from './components/Dashboard/Management/Settings'
import AuditLogs from './components/Dashboard/Management/AuditLogs.jsx'
import { import {
ThemeProvider, ThemeProvider,
useThemeContext useThemeContext
} from './components/Dashboard/context/ThemeContext' } from './components/Dashboard/context/ThemeContext'
import AppError from './components/App/AppError' 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 AppContent = () => {
const { themeConfig } = useThemeContext() const { themeConfig } = useThemeContext()
@ -97,14 +102,8 @@ const AppContent = () => {
path='production/printers/info' path='production/printers/info'
element={<PrinterInfo />} element={<PrinterInfo />}
/> />
<Route <Route path='production/jobs' element={<Jobs />} />
path='production/printjobs' <Route path='production/jobs/info' element={<JobInfo />} />
element={<PrintJobs />}
/>
<Route
path='production/printjobs/info'
element={<PrintJobInfo />}
/>
<Route <Route
path='production/gcodefiles' path='production/gcodefiles'
element={<GCodeFiles />} element={<GCodeFiles />}
@ -168,7 +167,19 @@ const AppContent = () => {
path='management/materials' path='management/materials'
element={<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/settings' element={<Settings />} />
<Route
path='management/auditlogs'
element={<AuditLogs />}
/>
</Route> </Route>
<Route <Route
path='*' 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"?> <?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'> <!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"> <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="translate(.09375 1.5681)"> <g transform="matrix(0.89681,0,0,0.89681,3.03125,0.598183)">
<g transform="matrix(.93038 0 0 .93038 2.2234 .097899)"> <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="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"/> <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;"/>
</g> <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 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>
</g> </g>
</svg> </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> <Card>
<Flex vertical align='center'> <Flex vertical align='center'>
<FarmControlLogo style={{ fontSize: '350px', height: '31px' }} /> <FarmControlLogo style={{ fontSize: '500px', height: '40px' }} />
</Flex> </Flex>
</Card> </Card>
<Alert <Alert

View File

@ -1,32 +1,55 @@
import React from 'react' import React, { useState, useEffect } from 'react'
import { Flex, Card, Alert } from 'antd' import { Flex, Card, Alert } from 'antd'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import AuthParticles from './AppParticles' import AuthParticles from './AppParticles'
import FarmControlLogo from '../Logos/FarmControlLogo' import FarmControlLogo from '../Logos/FarmControlLogo'
const AppLoading = () => { const AppLoading = () => {
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(true)
}, 1000)
return () => clearTimeout(timer)
}, [])
return ( return (
<> <div
<AuthParticles /> style={{
<Flex backgroundColor: 'black'
align='center' }}
justify='center' >
vertical <div
style={{ height: '100vh' }} style={{
gap={'large'} backgroundColor: 'black',
minHeight: '100vh',
transition: 'opacity 0.5s ease-in-out',
opacity: isVisible ? 1 : 0
}}
> >
<Card> <AuthParticles />
<Flex vertical align='center'> <Flex
<FarmControlLogo style={{ fontSize: '350px', height: '31px' }} /> align='center'
</Flex> justify='center'
</Card> vertical
<Alert style={{ height: '100vh' }}
message='Loading Farm Control please wait...' gap={'large'}
icon={<LoadingOutlined />} >
showIcon <Card>
/> <Flex vertical align='center'>
</Flex> <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 // 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 { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { import {
Table,
Button, Button,
Flex, Flex,
Space, Space,
Modal, Modal,
message,
Dropdown, Dropdown,
Typography, Typography,
Popover, message,
Checkbox, Checkbox,
Input, Popover,
Spin Input
} from 'antd' } from 'antd'
import { createStyles } from 'antd-style'
import { LoadingOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { SocketContext } from '../context/SocketContext' import { SocketContext } from '../context/SocketContext'
@ -34,49 +29,20 @@ import TimeDisplay from '../common/TimeDisplay'
import XMarkIcon from '../../Icons/XMarkIcon' import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon' import CheckIcon from '../../Icons/CheckIcon'
import useColumnVisibility from '../hooks/useColumnVisibility' import useColumnVisibility from '../hooks/useColumnVisibility'
import DashboardTable from '../common/DashboardTable'
import config from '../../../config' import config from '../../../config'
const { Text } = Typography 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 FilamentStocks = () => {
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate() const navigate = useNavigate()
const { styles } = useStyle()
const { socket } = useContext(SocketContext) const { socket } = useContext(SocketContext)
const [initialized, setInitialized] = useState(false)
const [filamentStocksData, setFilamentStocksData] = useState([]) const tableRef = useRef()
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 [newFilamentStockOpen, setNewFilamentStockOpen] = useState(false) const [newFilamentStockOpen, setNewFilamentStockOpen] = useState(false)
const [initialized, setInitialized] = useState(false)
const { authenticated } = useContext(AuthContext) 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 // Column definitions
const columns = [ const columns = [
{ {
@ -281,7 +118,7 @@ const FilamentStocks = () => {
title: 'ID', title: 'ID',
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 165, width: 180,
render: (text) => ( render: (text) => (
<IdText id={text} type={'filamentstock'} longId={false} /> <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 getViewDropdownItems = () => {
const columnItems = columns const columnItems = columns
.filter((col) => col.key && col.title !== '') .filter((col) => col.key && col.title !== '')
@ -390,39 +293,10 @@ const FilamentStocks = () => {
) )
} }
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
'FilamentStocks',
columns
)
const visibleColumns = columns.filter( const visibleColumns = columns.filter(
(col) => !col.key || columnVisibility[col.key] (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 ( return (
<> <>
<Flex vertical={'true'} gap='large'> <Flex vertical={'true'} gap='large'>
@ -440,19 +314,13 @@ const FilamentStocks = () => {
<Button>View</Button> <Button>View</Button>
</Popover> </Popover>
</Space> </Space>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Flex> </Flex>
<Table
dataSource={filamentStocksData} <DashboardTable
className={styles.customTable} ref={tableRef}
columns={visibleColumns} columns={visibleColumns}
pagination={false} url={`${config.backendUrl}/filamentstocks`}
rowKey='_id' authenticated={authenticated}
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
scroll={{ y: 'calc(100vh - 270px)' }}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
/> />
</Flex> </Flex>
<Modal <Modal
@ -463,13 +331,13 @@ const FilamentStocks = () => {
onCancel={() => { onCancel={() => {
setNewFilamentStockOpen(false) setNewFilamentStockOpen(false)
}} }}
destroyOnClose destroyOnHidden={true}
> >
<NewFilamentStock <NewFilamentStock
onOk={() => { onOk={() => {
setNewFilamentStockOpen(false) setNewFilamentStockOpen(false)
messageApi.success('New filament stock created successfully.') messageApi.success('New filament stock created successfully.')
fetchFilamentStocksData() tableRef.current?.reload()
}} }}
reset={newFilamentStockOpen} reset={newFilamentStockOpen}
/> />

View File

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

View File

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

View File

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

View File

@ -1,20 +1,8 @@
// src/partStocks.js // 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 { useNavigate } from 'react-router-dom'
import axios from 'axios' import { Button, Flex, Space, Modal, message, Dropdown, Typography } from 'antd'
import {
Table,
Button,
Flex,
Space,
Modal,
message,
Dropdown,
Typography
} from 'antd'
import { createStyles } from 'antd-style'
import { LoadingOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
@ -26,64 +14,21 @@ import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon' import ReloadIcon from '../../Icons/ReloadIcon'
import PartStockState from '../common/PartStockState' import PartStockState from '../common/PartStockState'
import TimeDisplay from '../common/TimeDisplay' import TimeDisplay from '../common/TimeDisplay'
import DashboardTable from '../common/DashboardTable'
import config from '../../../config' import config from '../../../config'
const { Text } = Typography 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 PartStocks = () => {
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate() const navigate = useNavigate()
const { styles } = useStyle() const tableRef = useRef()
const [partStocksData, setPartStocksData] = useState([])
const [newPartStockOpen, setNewPartStockOpen] = useState(false) const [newPartStockOpen, setNewPartStockOpen] = useState(false)
const [loading, setLoading] = useState(true)
const { authenticated } = useContext(AuthContext) 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) => { const getPartStockActionItems = (id) => {
return { return {
items: [ items: [
@ -123,7 +68,7 @@ const PartStocks = () => {
title: 'ID', title: 'ID',
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 165, width: 180,
render: (text) => <IdText id={text} type={'partstock'} longId={false} /> render: (text) => <IdText id={text} type={'partstock'} longId={false} />
}, },
{ {
@ -213,7 +158,7 @@ const PartStocks = () => {
], ],
onClick: ({ key }) => { onClick: ({ key }) => {
if (key === 'reloadList') { if (key === 'reloadList') {
fetchPartStocksData() tableRef.current?.reload()
} else if (key === 'newPartStock') { } else if (key === 'newPartStock') {
setNewPartStockOpen(true) setNewPartStockOpen(true)
} }
@ -229,14 +174,11 @@ const PartStocks = () => {
<Button>Actions</Button> <Button>Actions</Button>
</Dropdown> </Dropdown>
</Space> </Space>
<Table <DashboardTable
dataSource={partStocksData} ref={tableRef}
className={styles.customTable}
columns={columns} columns={columns}
pagination={false} url={`${config.backendUrl}/partstocks`}
rowKey='_id' authenticated={authenticated}
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
scroll={{ y: 'calc(100vh - 270px)' }}
/> />
</Flex> </Flex>
<Modal <Modal
@ -247,13 +189,13 @@ const PartStocks = () => {
onCancel={() => { onCancel={() => {
setNewPartStockOpen(false) setNewPartStockOpen(false)
}} }}
destroyOnClose destroyOnHidden={true}
> >
<NewPartStock <NewPartStock
onOk={() => { onOk={() => {
setNewPartStockOpen(false) setNewPartStockOpen(false)
messageApi.success('New part stock created successfully.') messageApi.success('New part stock created successfully.')
fetchPartStocksData() tableRef.current?.reload()
}} }}
reset={newPartStockOpen} 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 { useNavigate } from 'react-router-dom'
import axios from 'axios' import { Button, Flex, Space, message, Dropdown, Typography } from 'antd'
import { Table, Button, Flex, Space, message, Dropdown, Typography } from 'antd'
import { createStyles } from 'antd-style'
import { LoadingOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { SocketContext } from '../context/SocketContext' import { SocketContext } from '../context/SocketContext'
@ -15,78 +11,29 @@ import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import PlusIcon from '../../Icons/PlusIcon' import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon' import ReloadIcon from '../../Icons/ReloadIcon'
import TimeDisplay from '../common/TimeDisplay' import TimeDisplay from '../common/TimeDisplay'
import DashboardTable from '../common/DashboardTable'
import config from '../../../config' import config from '../../../config'
const { Text } = Typography 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 StockAudits = () => {
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate() const navigate = useNavigate()
const { styles } = useStyle()
const { socket } = useContext(SocketContext) const { socket } = useContext(SocketContext)
const [stockAuditsData, setStockAuditsData] = useState([])
const [loading, setLoading] = useState(true)
const [initialized, setInitialized] = useState(false) const [initialized, setInitialized] = useState(false)
const tableRef = useRef()
const { authenticated } = useContext(AuthContext) const { authenticated } = useContext(AuthContext)
const fetchStockAuditsData = useCallback(async () => { React.useEffect(() => {
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(() => {
if (socket && !initialized) { if (socket && !initialized) {
setInitialized(true) setInitialized(true)
socket.on('notify_stockaudit_update', (updateData) => { socket.on('notify_stockaudit_update', (updateData) => {
console.log('Received stock audit update:', updateData) console.log('Received stock audit update:', updateData)
setStockAuditsData((prevData) => { if (tableRef.current) {
return prevData.map((audit) => { tableRef.current.updateData(updateData._id, updateData)
if (audit._id === updateData._id) { }
return {
...audit,
...updateData
}
}
return audit
})
})
}) })
} }
@ -128,7 +75,7 @@ const StockAudits = () => {
title: 'ID', title: 'ID',
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 165, width: 180,
render: (text) => <IdText id={text} type={'stockaudit'} longId={false} /> render: (text) => <IdText id={text} type={'stockaudit'} longId={false} />
}, },
{ {
@ -203,7 +150,7 @@ const StockAudits = () => {
], ],
onClick: ({ key }) => { onClick: ({ key }) => {
if (key === 'reloadList') { if (key === 'reloadList') {
fetchStockAuditsData() tableRef.current?.reload()
} else if (key === 'newStockAudit') { } else if (key === 'newStockAudit') {
// TODO: Implement new stock audit creation // TODO: Implement new stock audit creation
messageApi.info('New stock audit creation not implemented yet') messageApi.info('New stock audit creation not implemented yet')
@ -220,14 +167,11 @@ const StockAudits = () => {
<Button>Actions</Button> <Button>Actions</Button>
</Dropdown> </Dropdown>
</Space> </Space>
<Table <DashboardTable
dataSource={stockAuditsData} ref={tableRef}
className={styles.customTable}
columns={columns} columns={columns}
pagination={false} url={`${config.backendUrl}/stockaudits`}
rowKey='_id' authenticated={authenticated}
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
scroll={{ y: 'calc(100vh - 270px)' }}
/> />
</Flex> </Flex>
</> </>

View File

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

View File

@ -1,21 +1,15 @@
import React, { useEffect, useState, useContext, useCallback } from 'react' import React, { useState, useContext, useRef } from 'react'
import axios from 'axios'
import { import {
Button, Button,
Flex, Flex,
Space, Space,
message,
Spin,
Popover, Popover,
Checkbox, Checkbox,
Dropdown, Dropdown,
Table,
Typography, Typography,
Input Input
} from 'antd' } from 'antd'
import { createStyles } from 'antd-style' import { AuditOutlined } from '@ant-design/icons'
import { LoadingOutlined, AuditOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { SocketContext } from '../context/SocketContext' import { SocketContext } from '../context/SocketContext'
@ -28,63 +22,24 @@ import PlayCircleIcon from '../../Icons/PlayCircleIcon'
import XMarkIcon from '../../Icons/XMarkIcon' import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon' import CheckIcon from '../../Icons/CheckIcon'
import useColumnVisibility from '../hooks/useColumnVisibility' import useColumnVisibility from '../hooks/useColumnVisibility'
import DashboardTable from '../common/DashboardTable'
import config from '../../../config' import config from '../../../config'
const { Text } = Typography 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 StockEvents = () => {
const [messageApi, contextHolder] = message.useMessage()
const { styles } = useStyle()
const { socket } = useContext(SocketContext) const { socket } = useContext(SocketContext)
const [initialized, setInitialized] = useState(false) const [initialized, setInitialized] = useState(false)
const tableRef = useRef()
// 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'
})
// Column definitions for visibility // Column definitions for visibility
const columns = [ const columns = [
{ {
title: '', title: '',
key: 'icon', key: 'icon',
width: 50, width: 40,
fixed: 'left',
render: (record) => { render: (record) => {
switch (record.type.toLowerCase()) { switch (record.type.toLowerCase()) {
case 'subjob': case 'subjob':
@ -103,6 +58,7 @@ const StockEvents = () => {
dataIndex: 'type', dataIndex: 'type',
key: 'type', key: 'type',
width: 200, width: 200,
fixed: 'left',
sorter: true, sorter: true,
filterDropdown: ({ filterDropdown: ({
setSelectedKeys, setSelectedKeys,
@ -118,6 +74,15 @@ const StockEvents = () => {
propertyName: 'type' propertyName: 'type'
}) })
}, },
{
title: 'ID',
key: 'id',
dataIndex: '_id',
width: 170,
render: (id) => {
return <IdText id={id} longId={false} type={'stockevent'} />
}
},
{ {
title: <PlusMinusIcon />, title: <PlusMinusIcon />,
dataIndex: 'value', dataIndex: 'value',
@ -134,26 +99,17 @@ const StockEvents = () => {
} }
}, },
{ {
title: 'Linked ID', title: 'Stock ID',
key: 'linkedId', key: 'stockId',
width: 100, width: 170,
render: (record) => { render: (record) => {
if (record.subJob?.number) { if (record.filamentStock?._id) {
return ( return (
<IdText <IdText
id={record.subJob.number.toString().padStart(6, '0')} id={record.filamentStock._id}
longId={false} longId={false}
type={'subjob'}
/>
)
}
if (record.stockAudit) {
return (
<IdText
id={record.stockAudit._id}
longId={false}
type={'stockaudit'}
showHyperlink={true} showHyperlink={true}
type={'filamentstock'}
/> />
) )
} }
@ -161,21 +117,41 @@ const StockEvents = () => {
} }
}, },
{ {
title: 'Job ID', title: 'Linked IDs',
key: 'jobId', key: 'linkedIds',
width: 100, width: 170 * 2,
render: (record) => { render: (record) => {
if (record.subJob) { const ids = (
return ( <Space size={'middle'}>
<IdText {record.job ? (
id={record.job} <IdText
longId={false} id={record.job}
type={'job'} longId={false}
showHyperlink={true} 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 { authenticated } = useContext(AuthContext)
const fetchStockEventsData = useCallback( React.useEffect(() => {
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(() => {
// Add WebSocket event listener for real-time updates // Add WebSocket event listener for real-time updates
if (socket && !initialized) { if (socket && !initialized) {
setInitialized(true) setInitialized(true)
socket.on('notify_stockevent_update', (updateData) => { socket.on('notify_stockevent_update', (updateData) => {
console.log('Received stock event update:', updateData) console.log('Received stock event update:', updateData)
setStockEventsData((prevData) => { if (tableRef.current) {
return prevData.map((stockEvent) => { tableRef.current.updateData(updateData._id, updateData)
if (stockEvent?._id) { }
if (stockEvent._id === updateData._id) {
return {
...stockEvent,
...updateData
}
} else {
return stockEvent
}
}
})
})
}) })
} }
@ -336,27 +248,6 @@ const StockEvents = () => {
} }
}, [socket, initialized]) }, [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 = { const actionItems = {
items: [ items: [
{ {
@ -367,34 +258,11 @@ const StockEvents = () => {
], ],
onClick: ({ key }) => { onClick: ({ key }) => {
if (key === 'reloadList') { if (key === 'reloadList') {
setPage(1) tableRef.current?.reload()
fetchStockEventsData(1)
} }
} }
} }
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 getViewDropdownItems = () => {
const columnItems = columns const columnItems = columns
.filter((col) => col.key && col.title !== '') .filter((col) => col.key && col.title !== '')
@ -426,7 +294,6 @@ const StockEvents = () => {
return ( return (
<> <>
<Flex vertical={'true'} gap='large'> <Flex vertical={'true'} gap='large'>
{contextHolder}
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
<Dropdown menu={actionItems}> <Dropdown menu={actionItems}>
@ -440,19 +307,13 @@ const StockEvents = () => {
<Button>View</Button> <Button>View</Button>
</Popover> </Popover>
</Space> </Space>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Flex> </Flex>
<Table
dataSource={stockEventsData} <DashboardTable
ref={tableRef}
columns={visibleColumns} columns={visibleColumns}
className={styles.customTable} url={`${config.backendUrl}/stockevents`}
pagination={false} authenticated={authenticated}
scroll={{ y: 'calc(100vh - 270px)' }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
/> />
</Flex> </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', title: 'ID',
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 165, width: 180,
render: (text) => <IdText id={text} type={'filament'} longId={false} />, render: (text) => <IdText id={text} type={'filament'} longId={false} />,
filterDropdown: ({ filterDropdown: ({
setSelectedKeys, setSelectedKeys,
@ -493,7 +493,7 @@ const Filaments = () => {
onCancel={() => { onCancel={() => {
setNewFilamentOpen(false) setNewFilamentOpen(false)
}} }}
destroyOnClose destroyOnHidden={true}
> >
<NewFilament <NewFilament
onOk={() => { onOk={() => {

View File

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

View File

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

View File

@ -168,7 +168,7 @@ const Materials = () => {
title: 'ID', title: 'ID',
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 165, width: 180,
render: (text) => <IdText id={text} type={'material'} longId={false} /> 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 // 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 { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { import {
Table,
Button, Button,
Flex, Flex,
Space, Space,
Modal, Modal,
Dropdown, Dropdown,
message,
Typography, Typography,
Spin,
Checkbox, Checkbox,
Popover, Popover,
Input Input,
message
} from 'antd' } from 'antd'
import { createStyles } from 'antd-style' import { DownloadOutlined } from '@ant-design/icons'
import { LoadingOutlined, DownloadOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText' import IdText from '../common/IdText'
import DashboardTable from '../common/DashboardTable'
import NewProduct from './Products/NewProduct' import NewProduct from './Products/NewProduct'
import PartIcon from '../../Icons/PartIcon' import PartIcon from '../../Icons/PartIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../Icons/InfoCircleIcon'
@ -37,35 +33,12 @@ import config from '../../../config'
const { Text } = Typography 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 Parts = () => {
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate() 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 [newProductOpen, setNewProductOpen] = useState(false)
const tableRef = useRef()
const { authenticated } = useContext(AuthContext)
// Column definitions // Column definitions
const columns = [ const columns = [
@ -104,7 +77,7 @@ const Parts = () => {
title: 'ID', title: 'ID',
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 165, width: 180,
render: (text) => <IdText id={text} type={'part'} longId={false} /> render: (text) => <IdText id={text} type={'part'} longId={false} />
}, },
{ {
@ -131,7 +104,7 @@ const Parts = () => {
{ {
title: 'Product ID', title: 'Product ID',
key: 'productId', key: 'productId',
width: 165, width: 180,
render: (record) => ( render: (record) => (
<IdText <IdText
id={record.product._id} id={record.product._id}
@ -153,8 +126,7 @@ const Parts = () => {
return 'n/a' return 'n/a'
} }
}, },
sorter: true, sorter: true
defaultSortOrder: 'descend'
}, },
{ {
title: 'Updated At', title: 'Updated At',
@ -168,8 +140,7 @@ const Parts = () => {
return 'n/a' return 'n/a'
} }
}, },
sorter: true, sorter: true
defaultSortOrder: 'descend'
}, },
{ {
title: 'Actions', title: 'Actions',
@ -196,89 +167,11 @@ const Parts = () => {
} }
] ]
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({})
const [columnVisibility, updateColumnVisibility] = useColumnVisibility( const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
'Parts', 'Parts',
columns 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) => { const getPartActionItems = (id) => {
return { return {
items: [ items: [
@ -353,29 +246,13 @@ const Parts = () => {
], ],
onClick: ({ key }) => { onClick: ({ key }) => {
if (key === 'reloadList') { if (key === 'reloadList') {
setPage(1) tableRef.current?.reload()
fetchPartsData(1)
} else if (key === 'newProduct') { } else if (key === 'newProduct') {
setNewProductOpen(true) 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 getViewDropdownItems = () => {
const columnItems = columns const columnItems = columns
.filter((col) => col.key && col.title !== '') .filter((col) => col.key && col.title !== '')
@ -421,19 +298,12 @@ const Parts = () => {
<Button>View</Button> <Button>View</Button>
</Popover> </Popover>
</Space> </Space>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Flex> </Flex>
<Table <DashboardTable
dataSource={partsData} ref={tableRef}
columns={visibleColumns} columns={visibleColumns}
className={styles.customTable} url={`${config.backendUrl}/parts`}
pagination={false} authenticated={authenticated}
scroll={{ y: 'calc(100vh - 270px)' }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
/> />
</Flex> </Flex>
<Modal <Modal
@ -443,13 +313,13 @@ const Parts = () => {
onCancel={() => { onCancel={() => {
setNewProductOpen(false) setNewProductOpen(false)
}} }}
destroyOnClose destroyOnHidden={true}
> >
<NewProduct <NewProduct
onOk={() => { onOk={() => {
setNewProductOpen(false) setNewProductOpen(false)
setPage(1) messageApi.success('Product created successfully!')
fetchPartsData(1) tableRef.current?.reload()
}} }}
reset={newProductOpen} reset={newProductOpen}
/> />

View File

@ -208,7 +208,7 @@ const PartInfo = () => {
console.error('Failed to update part information:', err) console.error('Failed to update part information:', err)
messageApi.error('Failed to update part information') messageApi.error('Failed to update part information')
} finally { } finally {
fetchPartDetails() await fetchPartDetails()
setLoading(false) setLoading(false)
} }
} }
@ -238,282 +238,287 @@ const PartInfo = () => {
return ( return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}> <div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder} {contextHolder}
<Collapse <Flex vertical gap={'large'}>
ghost <Collapse
collapsible='icon' ghost
activeKey={collapseState.info ? ['1'] : []} collapsible='icon'
onChange={(keys) => updateCollapseState('info', keys.length > 0)} activeKey={collapseState.info ? ['1'] : []}
expandIcon={({ isActive }) => ( onChange={(keys) => updateCollapseState('info', keys.length > 0)}
<CaretRightOutlined expandIcon={({ isActive }) => (
rotate={isActive ? 90 : 0} <CaretRightOutlined
style={{ paddingTop: '9px' }} rotate={isActive ? 90 : 0}
/> style={{ paddingTop: '9px' }}
)} />
className='no-h-padding-collapse no-t-padding-collapse' )}
> 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'
> >
<Form <Collapse.Panel
form={partForm} header={
layout='vertical' <Flex
onValuesChange={(changedValues) => align='center'
setPartFormValues((prevValues) => ({ justify='space-between'
...prevValues, style={{ width: '100%' }}
...changedValues >
})) <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={{ key='1'
name: partData.name || '',
version: partData.version || '',
tags: partData.tags || []
}}
> >
<Descriptions <Form
bordered form={partForm}
column={{ layout='vertical'
xs: 1, onValuesChange={(changedValues) =>
sm: 1, setPartFormValues((prevValues) => ({
md: 1, ...prevValues,
lg: 2, ...changedValues
xl: 2, }))
xxl: 2 }
initialValues={{
name: partData.name || '',
version: partData.version || '',
tags: partData.tags || []
}} }}
> >
<Descriptions.Item label='ID' span={1}> <Descriptions
{partData.id ? ( bordered
<IdText id={partData.id} type='part'></IdText> column={{
) : ( xs: 1,
'n/a' sm: 1,
)} md: 1,
</Descriptions.Item> lg: 2,
<Descriptions.Item label='Created At'> xl: 2,
<TimeDisplay dateTime={partData.createdAt} showSince={true} /> xxl: 2
</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'
}} }}
> >
<Space direction='vertical' align='center'> <Descriptions.Item label='ID' span={1}>
<XMarkIcon style={{ fontSize: '24px', color: '#ff4d4f' }} /> {partData.id ? (
<Typography.Text type='danger'> <IdText id={partData.id} type='part'></IdText>
{stlLoadError} ) : (
</Typography.Text> 'n/a'
</Space> )}
</div> </Descriptions.Item>
) : ( <Descriptions.Item label='Created At'>
partFileObjectId && ( <TimeDisplay dateTime={partData.createdAt} showSince={true} />
<StlViewer </Descriptions.Item>
url={partFileObjectId}
orbitControls <Descriptions.Item label='Name' span={1}>
shadows {isEditing ? (
style={{ height: '40vw' }} <Form.Item
modelProps={{ name='name'
color: '#008675' 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> >
) <Space direction='vertical' align='center'>
)} <XMarkIcon style={{ fontSize: '24px', color: '#ff4d4f' }} />
</Card> <Typography.Text type='danger'>
</Collapse.Panel> {stlLoadError}
</Collapse> </Typography.Text>
</Space>
</div>
) : (
partFileObjectId && (
<StlViewer
url={partFileObjectId}
orbitControls
shadows
style={{ height: '40vw' }}
modelProps={{
color: '#008675'
}}
></StlViewer>
)
)}
</Card>
</Collapse.Panel>
</Collapse>
</Flex>
</div> </div>
) )
} }

View File

@ -1,29 +1,25 @@
// src/gcodefiles.js // 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 { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { import {
Table,
Button, Button,
Flex, Flex,
Space, Space,
Modal, Modal,
Dropdown, Dropdown,
message, message,
Spin,
Tag, Tag,
Checkbox, Checkbox,
Popover, Popover,
Input Input
} from 'antd' } from 'antd'
import { createStyles } from 'antd-style' import { DownloadOutlined } from '@ant-design/icons'
import { LoadingOutlined, DownloadOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText' import IdText from '../common/IdText'
import TimeDisplay from '../common/TimeDisplay' import TimeDisplay from '../common/TimeDisplay'
import DashboardTable from '../common/DashboardTable'
import NewProduct from './Products/NewProduct' import NewProduct from './Products/NewProduct'
import ProductIcon from '../../Icons/ProductIcon' import ProductIcon from '../../Icons/ProductIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../Icons/InfoCircleIcon'
@ -31,120 +27,17 @@ import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon' import ReloadIcon from '../../Icons/ReloadIcon'
import XMarkIcon from '../../Icons/XMarkIcon' import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon' import CheckIcon from '../../Icons/CheckIcon'
import useColumnVisibility from '../hooks/useColumnVisibility' import useColumnVisibility from '../hooks/useColumnVisibility'
import config from '../../../config' 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 Products = () => {
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate() 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 [newProductOpen, setNewProductOpen] = useState(false)
const tableRef = useRef()
const [loading, setLoading] = useState(true)
const { authenticated } = useContext(AuthContext) 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) => { const getProductActionItems = (id) => {
return { return {
items: [ items: [
@ -205,7 +98,7 @@ const Products = () => {
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
fixed: 'left', fixed: 'left',
width: 165, width: 180,
render: (text) => <IdText id={text} type={'product'} longId={false} />, render: (text) => <IdText id={text} type={'product'} longId={false} />,
filterDropdown: ({ filterDropdown: ({
setSelectedKeys, setSelectedKeys,
@ -360,7 +253,7 @@ const Products = () => {
], ],
onClick: ({ key }) => { onClick: ({ key }) => {
if (key === 'reloadList') { if (key === 'reloadList') {
fetchProductsData() tableRef.current?.reload()
} else if (key === 'newProduct') { } else if (key === 'newProduct') {
setNewProductOpen(true) 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( const visibleColumns = columns.filter(
(col) => !col.key || columnVisibility[col.key] (col) => !col.key || columnVisibility[col.key]
) )
@ -463,19 +341,12 @@ const Products = () => {
<Button>View</Button> <Button>View</Button>
</Popover> </Popover>
</Space> </Space>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Flex> </Flex>
<Table <DashboardTable
dataSource={productsData} ref={tableRef}
columns={visibleColumns} columns={visibleColumns}
className={styles.customTable} url={`${config.backendUrl}/products`}
pagination={false} authenticated={authenticated}
scroll={{ y: 'calc(100vh - 270px)' }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
/> />
</Flex> </Flex>
<Modal <Modal
@ -485,13 +356,13 @@ const Products = () => {
onCancel={() => { onCancel={() => {
setNewProductOpen(false) setNewProductOpen(false)
}} }}
destroyOnClose destroyOnHidden={true}
> >
<NewProduct <NewProduct
onOk={() => { onOk={() => {
setNewProductOpen(false) setNewProductOpen(false)
messageApi.success('Product created successfully!') messageApi.success('Product created successfully!')
fetchProductsData() tableRef.current?.reload()
}} }}
reset={newProductOpen} reset={newProductOpen}
/> />

View File

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

View File

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

View File

@ -50,67 +50,72 @@ const Settings = () => {
return ( return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}> <div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
<Collapse <Flex vertical gap={'large'}>
ghost <Collapse
collapsible='icon' ghost
activeKey={collapseState.appearance ? ['1'] : []} collapsible='icon'
onChange={(keys) => updateCollapseState('appearance', keys.length > 0)} activeKey={collapseState.appearance ? ['1'] : []}
expandIcon={({ isActive }) => ( onChange={(keys) =>
<CaretRightOutlined updateCollapseState('appearance', keys.length > 0)
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>
} }
key='1' expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse'
> >
<Descriptions <Collapse.Panel
bordered header={
column={{ <Flex
xs: 1, align='center'
sm: 1, justify='space-between'
md: 1, style={{ width: '100%' }}
lg: 2, >
xl: 2, <Title level={5} style={{ margin: 0 }}>
xxl: 2 Appearance Settings
}} </Title>
</Flex>
}
key='1'
> >
<Descriptions.Item label='Theme'> <Descriptions
<Select bordered
value={getCurrentThemeValue()} column={{
onChange={handleThemeChange} xs: 1,
style={{ width: '100%' }} sm: 1,
> md: 1,
<Option value='light'>Light</Option> lg: 2,
<Option value='dark'>Dark</Option> xl: 2,
<Option value='system'>System</Option> xxl: 2
</Select> }}
</Descriptions.Item> >
<Descriptions.Item label='UI Density'> <Descriptions.Item label='Theme'>
<Select <Select
value={isCompact ? 'compact' : 'comfortable'} value={getCurrentThemeValue()}
onChange={handleCompactChange} onChange={handleThemeChange}
style={{ width: '100%' }} style={{ width: '100%' }}
> >
<Option value='comfortable'>Comfortable</Option> <Option value='light'>Light</Option>
<Option value='compact'>Compact</Option> <Option value='dark'>Dark</Option>
</Select> <Option value='system'>System</Option>
</Descriptions.Item> </Select>
</Descriptions> </Descriptions.Item>
</Collapse.Panel> <Descriptions.Item label='UI Density'>
</Collapse> <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> </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 { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { import {
Table,
Button, Button,
Flex, Flex,
Space, Space,
@ -12,16 +10,15 @@ import {
Typography, Typography,
Checkbox, Checkbox,
Popover, Popover,
Input, Input
Spin
} from 'antd' } from 'antd'
import { createStyles } from 'antd-style' import { ExportOutlined } from '@ant-design/icons'
import { LoadingOutlined, ExportOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText' import IdText from '../common/IdText'
import NewVendor from './Vendors/NewVendor' import NewVendor from './Vendors/NewVendor'
import CountryDisplay from '../common/CountryDisplay' import CountryDisplay from '../common/CountryDisplay'
import TimeDisplay from '../common/TimeDisplay' import TimeDisplay from '../common/TimeDisplay'
import DashboardTable from '../common/DashboardTable'
import VendorIcon from '../../Icons/VendorIcon' import VendorIcon from '../../Icons/VendorIcon'
import PlusIcon from '../../Icons/PlusIcon' import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon' import ReloadIcon from '../../Icons/ReloadIcon'
@ -34,103 +31,13 @@ import config from '../../../config'
const { Link } = Typography 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 Vendors = () => {
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate() const navigate = useNavigate()
const { styles } = useStyle()
const [vendorsData, setVendorsData] = useState([])
const [newVendorOpen, setNewVendorOpen] = useState(false) const [newVendorOpen, setNewVendorOpen] = useState(false)
const [loading, setLoading] = useState(true) const tableRef = useRef()
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({})
const { authenticated } = useContext(AuthContext) 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 = ({ const getFilterDropdown = ({
setSelectedKeys, setSelectedKeys,
selectedKeys, 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) => { const getVendorActionItems = (id) => {
return { return {
items: [ items: [
@ -259,7 +151,7 @@ const Vendors = () => {
title: 'ID', title: 'ID',
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 165, width: 180,
render: (text) => <IdText id={text} type={'vendor'} longId={false} />, render: (text) => <IdText id={text} type={'vendor'} longId={false} />,
filterDropdown: ({ filterDropdown: ({
setSelectedKeys, setSelectedKeys,
@ -435,19 +327,13 @@ const Vendors = () => {
], ],
onClick: ({ key }) => { onClick: ({ key }) => {
if (key === 'reloadList') { if (key === 'reloadList') {
fetchVendorsData() tableRef.current?.reload()
} else if (key === 'newVendor') { } else if (key === 'newVendor') {
setNewVendorOpen(true) setNewVendorOpen(true)
} }
} }
} }
useEffect(() => {
if (authenticated) {
fetchVendorsData()
}
}, [authenticated, fetchVendorsData])
return ( return (
<> <>
<Flex vertical={'true'} gap='large'> <Flex vertical={'true'} gap='large'>
@ -465,33 +351,26 @@ const Vendors = () => {
<Button>View</Button> <Button>View</Button>
</Popover> </Popover>
</Space> </Space>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Flex> </Flex>
<Table <DashboardTable
dataSource={vendorsData} ref={tableRef}
columns={visibleColumns} columns={visibleColumns}
className={styles.customTable} url={`${config.backendUrl}/vendors`}
pagination={false} authenticated={authenticated}
scroll={{ y: 'calc(100vh - 270px)' }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
/> />
</Flex> </Flex>
<Modal <Modal
open={newVendorOpen} open={newVendorOpen}
onCancel={() => setNewVendorOpen(false)} onCancel={() => setNewVendorOpen(false)}
footer={null} footer={null}
destroyOnClose destroyOnHidden={true}
width={700} width={700}
> >
<NewVendor <NewVendor
onOk={() => { onOk={() => {
setNewVendorOpen(false) setNewVendorOpen(false)
messageApi.success('New vendor created successfully.') messageApi.success('New vendor created successfully.')
fetchVendorsData() tableRef.current?.reload()
}} }}
reset={!newVendorOpen} reset={!newVendorOpen}
/> />

View File

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

View File

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

View File

@ -1,10 +1,9 @@
// src/gcodefiles.js // 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 { useNavigate } from 'react-router-dom'
import axios from 'axios' import axios from 'axios'
import { import {
Table,
Badge, Badge,
Button, Button,
Flex, Flex,
@ -16,11 +15,9 @@ import {
Checkbox, Checkbox,
Divider, Divider,
Popover, Popover,
Input, Input
Spin
} from 'antd' } from 'antd'
import { createStyles } from 'antd-style' import { DownloadOutlined } from '@ant-design/icons'
import { LoadingOutlined, DownloadOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import NewGCodeFile from './GCodeFiles/NewGCodeFile' import NewGCodeFile from './GCodeFiles/NewGCodeFile'
@ -33,33 +30,18 @@ import ReloadIcon from '../../Icons/ReloadIcon'
import XMarkIcon from '../../Icons/XMarkIcon' import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon' import CheckIcon from '../../Icons/CheckIcon'
import TimeDisplay from '../common/TimeDisplay' import TimeDisplay from '../common/TimeDisplay'
import DashboardTable from '../common/DashboardTable'
import config from '../../../config' import config from '../../../config'
const { Text } = Typography 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 GCodeFiles = () => {
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate() const navigate = useNavigate()
const { styles } = useStyle() const [newGCodeFileOpen, setNewGCodeFileOpen] = useState(false)
const [showDeleted, setShowDeleted] = useState(false)
const tableRef = useRef()
const getFilterDropdown = ({ const getFilterDropdown = ({
setSelectedKeys, setSelectedKeys,
@ -96,6 +78,7 @@ const GCodeFiles = () => {
</div> </div>
) )
} }
// Column definitions // Column definitions
const columns = [ const columns = [
{ {
@ -133,8 +116,8 @@ const GCodeFiles = () => {
title: 'ID', title: 'ID',
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 165, width: 180,
render: (text) => <IdText id={text} type={'gcodeFile'} longId={false} /> render: (text) => <IdText id={text} type={'gcodefile'} longId={false} />
}, },
{ {
title: 'Filament', 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( const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
'GCodeFiles', 'GCodeFiles',
columns columns
@ -250,94 +225,6 @@ const GCodeFiles = () => {
const { authenticated } = useContext(AuthContext) 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) => { const getGCodeFileActionItems = (id) => {
return { return {
items: [ items: [
@ -358,7 +245,8 @@ const GCodeFiles = () => {
} else if (key === 'download') { } else if (key === 'download') {
handleDownloadGCode( handleDownloadGCode(
id, 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: { headers: {
Accept: 'application/json' Accept: 'application/json'
}, },
withCredentials: true // Important for including cookies withCredentials: true
} }
) )
setLoading(false)
const fileURL = window.URL.createObjectURL(new Blob([response.data])) 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') const fileLink = document.createElement('a')
fileLink.href = fileURL fileLink.href = fileURL
fileLink.setAttribute('download', fileName) fileLink.setAttribute('download', fileName)
document.body.appendChild(fileLink) document.body.appendChild(fileLink)
// Simulate click to download the file
fileLink.click() fileLink.click()
// Clean up and remove the anchor element
fileLink.parentNode.removeChild(fileLink) fileLink.parentNode.removeChild(fileLink)
} catch (error) { } catch (error) {
if (error.response) { if (error.response) {
@ -425,7 +305,7 @@ const GCodeFiles = () => {
], ],
onClick: ({ key }) => { onClick: ({ key }) => {
if (key === 'reloadList') { if (key === 'reloadList') {
fetchGCodeFilesData() tableRef.current?.reload()
} else if (key === 'newGCodeFile') { } else if (key === 'newGCodeFile') {
setNewGCodeFileOpen(true) setNewGCodeFileOpen(true)
} }
@ -484,19 +364,13 @@ const GCodeFiles = () => {
> >
<Button>View</Button> <Button>View</Button>
</Popover> </Popover>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Space> </Space>
<Table
dataSource={gcodeFilesData} <DashboardTable
ref={tableRef}
columns={visibleColumns} columns={visibleColumns}
className={styles.customTable} url={`${config.backendUrl}/gcodefiles`}
pagination={false} authenticated={authenticated}
scroll={{ y: 'calc(100vh - 270px)' }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
onChange={handleTableChange}
onScroll={handleScroll}
showSorterTooltip={false}
/> />
</Flex> </Flex>
<Modal <Modal
@ -506,13 +380,13 @@ const GCodeFiles = () => {
onCancel={() => { onCancel={() => {
setNewGCodeFileOpen(false) setNewGCodeFileOpen(false)
}} }}
destroyOnClose destroyOnHidden={true}
> >
<NewGCodeFile <NewGCodeFile
onOk={() => { onOk={() => {
setNewGCodeFileOpen(false) setNewGCodeFileOpen(false)
messageApi.success('Finished uploading GCode file!') messageApi.success('Finished uploading GCode file!')
fetchGCodeFilesData() tableRef.current?.reload()
}} }}
reset={newGCodeFileOpen} reset={newGCodeFileOpen}
/> />

View File

@ -155,255 +155,263 @@ const GCodeFileInfo = () => {
return ( return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}> <div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder} {contextHolder}
<Collapse <Flex vertical gap={'large'}>
ghost <Collapse
collapsible='icon' ghost
activeKey={collapseState.info ? ['1'] : []} collapsible='icon'
onChange={(keys) => updateCollapseState('info', keys.length > 0)} activeKey={collapseState.info ? ['1'] : []}
expandIcon={({ isActive }) => ( onChange={(keys) => updateCollapseState('info', keys.length > 0)}
<CaretRightOutlined expandIcon={({ isActive }) => (
rotate={isActive ? 90 : 0} <CaretRightOutlined
style={{ paddingTop: '9px' }} rotate={isActive ? 90 : 0}
/> style={{ paddingTop: '9px' }}
)} />
className='no-h-padding-collapse no-t-padding-collapse' )}
> 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'
> >
<Form form={form} layout='vertical'> <Collapse.Panel
<Descriptions header={
bordered <Flex
column={{ align='center'
xs: 1, justify='space-between'
sm: 1, style={{ width: '100%' }}
md: 1, >
lg: 2, <Title level={5} style={{ margin: 0 }}>
xl: 2, GCode File Information
xxl: 2 </Title>
}} <Space>
> {isEditing ? (
<Descriptions.Item label='ID' span={1}> <>
{gcodeFileData.id ? ( <Button
<IdText id={gcodeFileData.id} type='gcodeFile'></IdText> icon={<CheckIcon />}
) : ( type='primary'
'n/a' onClick={updateInfo}
)} loading={loading}
</Descriptions.Item> />
<Descriptions.Item label='Created At'> <Button
<TimeDisplay icon={<XMarkIcon />}
dateTime={gcodeFileData.createdAt} onClick={cancelEditing}
showSince={true} disabled={loading}
/> />
</Descriptions.Item> </>
) : (
<Descriptions.Item label='Name'> <Button icon={<EditIcon />} onClick={startEditing} />
{isEditing ? ( )}
<Form.Item </Space>
name='name' </Flex>
rules={[ }
{ required: true, message: 'Please enter a vendor name' }, key='1'
{ max: 100, message: 'Name cannot exceed 100 characters' } >
]} <Form form={form} layout='vertical'>
style={{ margin: 0 }} <Descriptions
> bordered
<Input /> column={{
</Form.Item> xs: 1,
) : ( sm: 1,
gcodeFileData.name md: 1,
)} lg: 2,
</Descriptions.Item> xl: 2,
xxl: 2
<Descriptions.Item label='Updated At'> }}
<TimeDisplay >
dateTime={gcodeFileData.updatedAt} <Descriptions.Item label='ID' span={1}>
showSince={true} {gcodeFileData.id ? (
/> <IdText id={gcodeFileData.id} type='gcodeFile'></IdText>
</Descriptions.Item> ) : (
<Descriptions.Item label='Filament Name'> 'n/a'
{isEditing ? ( )}
<Form.Item </Descriptions.Item>
name='filament' <Descriptions.Item label='Created At'>
rules={[ <TimeDisplay
{ required: true, message: 'Please enter a filament' } dateTime={gcodeFileData.createdAt}
]} showSince={true}
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}
/> />
) : ( </Descriptions.Item>
'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 <Descriptions.Item label='Name'>
ghost {isEditing ? (
collapsible='icon' <Form.Item
activeKey={collapseState.preview ? ['2'] : []} name='name'
onChange={(keys) => updateCollapseState('preview', keys.length > 0)} rules={[
expandIcon={({ isActive }) => ( {
<CaretRightOutlined required: true,
rotate={isActive ? 90 : 0} message: 'Please enter a vendor name'
style={{ paddingTop: '2px' }} },
/> {
)} max: 100,
className='no-h-padding-collapse' message: 'Name cannot exceed 100 characters'
> }
<Collapse.Panel ]}
header={ style={{ margin: 0 }}
<Title level={5} style={{ margin: 0 }}> >
GCode File Preview <Input />
</Title> </Form.Item>
} ) : (
key='2' 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' } }}> <Collapse.Panel
{gcodeFileData.gcodeFileInfo.thumbnail ? ( header={
<img <Title level={5} style={{ margin: 0 }}>
src={`data:image/png;base64,${gcodeFileData.gcodeFileInfo.thumbnail.data}`} GCode File Preview
alt='GCodeFile' </Title>
style={{ maxWidth: '100%' }} }
/> key='2'
) : ( >
'n/a' <Card styles={{ body: { padding: '10px' } }}>
)} {gcodeFileData.gcodeFileInfo.thumbnail ? (
</Card> <img
</Collapse.Panel> src={`data:image/png;base64,${gcodeFileData.gcodeFileInfo.thumbnail.data}`}
</Collapse> alt='GCodeFile'
style={{ maxWidth: '100%' }}
/>
) : (
'n/a'
)}
</Card>
</Collapse.Panel>
</Collapse>
</Flex>
</div> </div>
) )
} }

View File

@ -1,6 +1,7 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import React, { useState, useContext, useEffect } from 'react' import React, { useState, useContext, useEffect } from 'react'
import axios from 'axios' import axios from 'axios'
import { useMediaQuery } from 'react-responsive'
import { import {
capitalizeFirstLetter, capitalizeFirstLetter,
timeStringToMinutes timeStringToMinutes
@ -48,6 +49,7 @@ const initialNewGCodeFileForm = {
const NewGCodeFile = ({ onOk, reset }) => { const NewGCodeFile = ({ onOk, reset }) => {
const [messageApi] = message.useMessage() const [messageApi] = message.useMessage()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [newGCodeFileLoading, setNewGCodeFileLoading] = useState(false) const [newGCodeFileLoading, setNewGCodeFileLoading] = useState(false)
const [gcodeParsing, setGcodeParsing] = useState(false) const [gcodeParsing, setGcodeParsing] = useState(false)
@ -470,16 +472,18 @@ const NewGCodeFile = ({ onOk, reset }) => {
return ( return (
<Flex gap={'middle'}> <Flex gap={'middle'}>
<div style={{ minWidth: '160px' }}> {!isMobile && (
<Steps <div style={{ minWidth: '160px' }}>
current={currentStep} <Steps
items={steps} current={currentStep}
direction='vertical' items={steps}
style={{ width: 'fit-content' }} direction='vertical'
/> style={{ width: 'fit-content' }}
</div> />
</div>
)}
<Divider type={'vertical'} style={{ height: 'unset' }} /> {!isMobile && <Divider type={'vertical'} style={{ height: 'unset' }} />}
<Flex vertical={'true'} style={{ flexGrow: 1 }} gap='middle'> <Flex vertical={'true'} style={{ flexGrow: 1 }} gap='middle'>
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}> <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 { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { import {
Table,
Button, Button,
Flex, Flex,
Space, Space,
@ -15,71 +13,42 @@ import {
Input, Input,
Typography, Typography,
Checkbox, Checkbox,
Popover, Popover
Spin
} from 'antd' } from 'antd'
import { createStyles } from 'antd-style'
import { LoadingOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext.js' import { AuthContext } from '../context/AuthContext.js'
import { SocketContext } from '../context/SocketContext' import { SocketContext } from '../context/SocketContext.js'
import NewPrintJob from './PrintJobs/NewPrintJob' import NewJob from './Jobs/NewJob.jsx'
import JobState from '../common/JobState' import JobState from '../common/JobState.jsx'
import SubJobCounter from '../common/SubJobCounter' import SubJobCounter from '../common/SubJobCounter.jsx'
import TimeDisplay from '../common/TimeDisplay' import TimeDisplay from '../common/TimeDisplay.jsx'
import IdText from '../common/IdText' import IdText from '../common/IdText.jsx'
import useColumnVisibility from '../hooks/useColumnVisibility' import useColumnVisibility from '../hooks/useColumnVisibility.js'
import JobIcon from '../../Icons/JobIcon' import JobIcon from '../../Icons/JobIcon.jsx'
import InfoCircleIcon from '../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../Icons/InfoCircleIcon.jsx'
import PlusIcon from '../../Icons/PlusIcon' import PlusIcon from '../../Icons/PlusIcon.jsx'
import ReloadIcon from '../../Icons/ReloadIcon' import ReloadIcon from '../../Icons/ReloadIcon.jsx'
import EditIcon from '../../Icons/EditIcon.jsx' import EditIcon from '../../Icons/EditIcon.jsx'
import XMarkIcon from '../../Icons/XMarkIcon.jsx' import XMarkIcon from '../../Icons/XMarkIcon.jsx'
import CheckIcon from '../../Icons/CheckIcon.jsx' import CheckIcon from '../../Icons/CheckIcon.jsx'
import PlayCircleIcon from '../../Icons/PlayCircleIcon.jsx' import PlayCircleIcon from '../../Icons/PlayCircleIcon.jsx'
import config from '../../../config.js'
import CheckCircleIcon from '../../Icons/CheckCircleIcon.jsx' import CheckCircleIcon from '../../Icons/CheckCircleIcon.jsx'
import PauseCircleIcon from '../../Icons/PauseCircleIcon.jsx' import PauseCircleIcon from '../../Icons/PauseCircleIcon.jsx'
import XMarkCircleIcon from '../../Icons/XMarkCircleIcon.jsx' import XMarkCircleIcon from '../../Icons/XMarkCircleIcon.jsx'
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon.jsx' import QuestionCircleIcon from '../../Icons/QuestionCircleIcon.jsx'
import DashboardTable from '../common/DashboardTable'
import config from '../../../config.js'
const { Text } = Typography const { Text } = Typography
const useStyle = createStyles(({ css, token }) => { const Jobs = () => {
const { antCls } = token
return {
customTable: css`
${antCls}-table {
${antCls}-table-container {
${antCls}-table-body,
${antCls}-table-content {
scrollbar-width: thin;
scrollbar-color: #eaeaea transparent;
scrollbar-gutter: stable;
}
}
}
`
}
})
const PrintJobs = () => {
const { styles } = useStyle()
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const [notificationApi, notificationContextHolder] = const [notificationApi, notificationContextHolder] =
notification.useNotification() notification.useNotification()
const navigate = useNavigate() const navigate = useNavigate()
const [printJobsData, setPrintJobsData] = useState([]) const [newJobOpen, setNewJobOpen] = useState(false)
const [page, setPage] = useState(1) const tableRef = useRef()
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 getFilterDropdown = ({ const getFilterDropdown = ({
setSelectedKeys, setSelectedKeys,
@ -154,7 +123,7 @@ const PrintJobs = () => {
title: 'ID', title: 'ID',
dataIndex: 'id', dataIndex: 'id',
key: 'id', key: 'id',
width: 165, width: 180,
render: (text) => <IdText id={text} type={'job'} longId={false} />, render: (text) => <IdText id={text} type={'job'} longId={false} />,
filterDropdown: ({ filterDropdown: ({
setSelectedKeys, setSelectedKeys,
@ -266,19 +235,17 @@ const PrintJobs = () => {
{record.state.type === 'draft' ? ( {record.state.type === 'draft' ? (
<Button <Button
icon={<PlayCircleIcon />} icon={<PlayCircleIcon />}
onClick={() => handleDeployPrintJob(record.id)} onClick={() => handleDeployJob(record.id)}
/> />
) : ( ) : (
<Button <Button
icon={<InfoCircleIcon />} icon={<InfoCircleIcon />}
onClick={() => onClick={() =>
navigate( navigate(`/dashboard/production/jobs/info?jobId=${record.id}`)
`/dashboard/production/printjobs/info?printJobId=${record.id}`
)
} }
/> />
)} )}
<Dropdown menu={getPrintJobActionItems(record.id)}> <Dropdown menu={getJobActionItems(record.id)}>
<Button>Actions</Button> <Button>Actions</Button>
</Dropdown> </Dropdown>
</Space> </Space>
@ -291,14 +258,14 @@ const PrintJobs = () => {
const { socket } = useContext(SocketContext) const { socket } = useContext(SocketContext)
const [columnVisibility, updateColumnVisibility] = useColumnVisibility( const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
'PrintJobs', 'Jobs',
columns columns
) )
const handleDeployPrintJob = (printJobId) => { const handleDeployJob = (jobId) => {
if (socket) { if (socket) {
messageApi.info(`Print job ${printJobId} deployment initiated`) messageApi.info(`Print job ${jobId} deployment initiated`)
socket.emit('server.job_queue.deploy', { printJobId }, (response) => { socket.emit('server.job_queue.deploy', { jobId }, (response) => {
if (response == false) { if (response == false) {
notificationApi.error({ notificationApi.error({
message: 'Print job deployment failed', 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 { } else {
messageApi.error('Socket connection not available') messageApi.error('Socket connection not available')
} }
} }
const fetchPrintJobsData = useCallback( const getJobActionItems = (jobId) => {
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) => {
return { return {
items: [ items: [
{ {
@ -430,11 +300,9 @@ const PrintJobs = () => {
], ],
onClick: ({ key }) => { onClick: ({ key }) => {
if (key === 'edit') { if (key === 'edit') {
showNewPrintJobModal(printJobId) showNewJobModal(jobId)
} else if (key === 'info') { } else if (key === 'info') {
navigate( navigate(`/dashboard/production/jobs/info?jobId=${jobId}`)
`/dashboard/production/printjobs/info?printJobId=${printJobId}`
)
} }
} }
} }
@ -444,7 +312,7 @@ const PrintJobs = () => {
items: [ items: [
{ {
label: 'New Print Job', label: 'New Print Job',
key: 'newPrintJob', key: 'newJob',
icon: <PlusIcon /> icon: <PlusIcon />
}, },
{ type: 'divider' }, { type: 'divider' },
@ -455,16 +323,16 @@ const PrintJobs = () => {
} }
], ],
onClick: ({ key }) => { onClick: ({ key }) => {
if (key === 'newPrintJob') { if (key === 'newJob') {
showNewPrintJobModal() showNewJobModal()
} else if (key === 'reloadList') { } else if (key === 'reloadList') {
fetchPrintJobsData() tableRef.current?.reload()
} }
} }
} }
const showNewPrintJobModal = () => { const showNewJobModal = () => {
setNewPrintJobOpen(true) setNewJobOpen(true)
} }
const getViewDropdownItems = () => { const getViewDropdownItems = () => {
@ -511,41 +379,34 @@ const PrintJobs = () => {
> >
<Button>View</Button> <Button>View</Button>
</Popover> </Popover>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Space> </Space>
<Table <DashboardTable
className={styles.customTable} ref={tableRef}
dataSource={printJobsData}
columns={visibleColumns} columns={visibleColumns}
rowKey='id' url={`${config.backendUrl}/jobs`}
pagination={false} authenticated={authenticated}
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
scroll={{ y: 'calc(100vh - 270px)' }}
onChange={handleTableChange}
onScroll={handleScroll}
showSorterTooltip={false}
/> />
</Flex> </Flex>
<Modal <Modal
open={newPrintJobOpen} open={newJobOpen}
footer={null} footer={null}
width={700} width={700}
onCancel={() => { onCancel={() => {
setNewPrintJobOpen(false) setNewJobOpen(false)
}} }}
> >
<NewPrintJob <NewJob
onOk={() => { onOk={() => {
setNewPrintJobOpen(false) setNewJobOpen(false)
messageApi.success('New print job created successfully.') messageApi.success('New print job created successfully.')
fetchPrintJobsData() tableRef.current?.reload()
}} }}
reset={newPrintJobOpen} reset={newJobOpen}
/> />
</Modal> </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 React, { useState } from 'react'
import axios from 'axios' import axios from 'axios'
import { useMediaQuery } from 'react-responsive'
import { import {
Form, Form,
Button, Button,
@ -20,70 +21,66 @@ import config from '../../../../config'
const { Title } = Typography const { Title } = Typography
const initialNewPrintJobForm = { const initialNewJobForm = {
gcodeFile: null, gcodeFile: null,
quantity: 1 quantity: 1
} }
const NewPrintJob = ({ onOk, reset }) => { const NewJob = ({ onOk, reset }) => {
NewPrintJob.propTypes = { NewJob.propTypes = {
onOk: PropTypes.func.isRequired, onOk: PropTypes.func.isRequired,
reset: PropTypes.bool.isRequired reset: PropTypes.bool.isRequired
} }
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const [newPrintJobLoading, setNewPrintJobLoading] = useState(false) const [newJobLoading, setNewJobLoading] = useState(false)
const [currentStep, setCurrentStep] = useState(0) const [currentStep, setCurrentStep] = useState(0)
const [nextEnabled, setNextEnabled] = useState(false) const [nextEnabled, setNextEnabled] = useState(false)
const [newPrintJobForm] = Form.useForm() const [newJobForm] = Form.useForm()
const [newPrintJobFormValues, setNewPrintJobFormValues] = useState( const [newJobFormValues, setNewJobFormValues] = useState(initialNewJobForm)
initialNewPrintJobForm
)
const newPrintJobFormUpdateValues = Form.useWatch([], newPrintJobForm) const newJobFormUpdateValues = Form.useWatch([], newJobForm)
const isMobile = useMediaQuery({ maxWidth: 768 })
React.useEffect(() => { React.useEffect(() => {
newPrintJobForm newJobForm
.validateFields({ .validateFields({
validateOnly: true validateOnly: true
}) })
.then(() => setNextEnabled(true)) .then(() => setNextEnabled(true))
.catch(() => setNextEnabled(false)) .catch(() => setNextEnabled(false))
}, [newPrintJobForm, newPrintJobFormUpdateValues]) }, [newJobForm, newJobFormUpdateValues])
const summaryItems = [ const summaryItems = [
{ {
key: 'quantity', key: 'quantity',
label: 'Quantity', label: 'Quantity',
children: newPrintJobFormValues.quantity children: newJobFormValues.quantity
} }
] ]
React.useEffect(() => { React.useEffect(() => {
if (reset) { if (reset) {
newPrintJobForm.resetFields() newJobForm.resetFields()
} }
}, [reset, newPrintJobForm]) }, [reset, newJobForm])
const handleNewPrintJob = async () => { const handleNewJob = async () => {
setNewPrintJobLoading(true) setNewJobLoading(true)
try { try {
await axios.post( await axios.post(`${config.backendUrl}/jobs`, newJobFormValues, {
`${config.backendUrl}/printjobs`, headers: {
newPrintJobFormValues, Accept: 'application/json'
{ },
headers: { withCredentials: true // Important for including cookies
Accept: 'application/json' })
},
withCredentials: true // Important for including cookies
}
)
onOk() onOk()
} catch (error) { } catch (error) {
messageApi.error('Error creating new print job: ' + error.message) messageApi.error('Error creating new print job: ' + error.message)
} finally { } finally {
setNewPrintJobLoading(false) setNewJobLoading(false)
} }
} }
@ -108,7 +105,6 @@ const NewPrintJob = ({ onOk, reset }) => {
<Form.Item <Form.Item
label='Quantity' label='Quantity'
name='quantity' name='quantity'
defaultValue={1}
rules={[ rules={[
{ {
required: true, 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>
<Form.Item <Form.Item
label='Printers' label='Printers'
@ -152,33 +148,35 @@ const NewPrintJob = ({ onOk, reset }) => {
return ( return (
<Flex gap={'middle'}> <Flex gap={'middle'}>
{contextHolder} {contextHolder}
<div style={{ minWidth: '160px' }}> {!isMobile && (
<Steps <div style={{ minWidth: '160px' }}>
current={currentStep} <Steps
items={steps} current={currentStep}
direction='vertical' items={steps}
style={{ width: 'fit-content' }} direction='vertical'
/> style={{ width: 'fit-content' }}
</div> />
</div>
)}
<Divider type={'vertical'} style={{ height: 'unset' }} /> {!isMobile && <Divider type={'vertical'} style={{ height: 'unset' }} />}
<Flex vertical={'true'} style={{ flexGrow: 1 }} gap='middle'> <Flex vertical={'true'} style={{ flexGrow: 1 }} gap='middle'>
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}> <Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
New PrintJob New Job
</Title> </Title>
<Form <Form
name='basic' name='basic'
autoComplete='off' autoComplete='off'
form={newPrintJobForm} form={newJobForm}
onFinish={handleNewPrintJob} onFinish={handleNewJob}
onValuesChange={(changedValues) => onValuesChange={(changedValues) =>
setNewPrintJobFormValues((prevValues) => ({ setNewJobFormValues((prevValues) => ({
...prevValues, ...prevValues,
...changedValues ...changedValues
})) }))
} }
initialValues={initialNewPrintJobForm} initialValues={initialNewJobForm}
> >
<div style={{ minHeight: '260px' }}>{steps[currentStep].content}</div> <div style={{ minHeight: '260px' }}>{steps[currentStep].content}</div>
@ -204,11 +202,7 @@ const NewPrintJob = ({ onOk, reset }) => {
</Button> </Button>
)} )}
{currentStep === steps.length - 1 && ( {currentStep === steps.length - 1 && (
<Button <Button type='primary' htmlType='submit' loading={newJobLoading}>
type='primary'
htmlType='submit'
loading={newPrintJobLoading}
>
Done Done
</Button> </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 // 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 { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { import {
Table,
Button, Button,
message, message,
Dropdown, Dropdown,
@ -14,11 +12,8 @@ import {
Tag, Tag,
Modal, Modal,
Popover, Popover,
Checkbox, Checkbox
Spin
} from 'antd' } from 'antd'
import { createStyles } from 'antd-style'
import { LoadingOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import PrinterState from '../common/PrinterState' import PrinterState from '../common/PrinterState'
@ -31,36 +26,16 @@ import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon' import ReloadIcon from '../../Icons/ReloadIcon'
import XMarkIcon from '../../Icons/XMarkIcon' import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon' import CheckIcon from '../../Icons/CheckIcon'
import DashboardTable from '../common/DashboardTable'
import config from '../../../config' 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 Printers = () => {
const { styles } = useStyle() const [messageApi, contextHolder] = message.useMessage()
const [printerData, setPrinterData] = useState([]) const { authenticated } = useContext(AuthContext)
const [page, setPage] = useState(1) const [newPrinterOpen, setNewPrinterOpen] = useState(false)
const [hasMore, setHasMore] = useState(true) const navigate = useNavigate()
const [loading, setLoading] = useState(true) const tableRef = useRef()
const [lazyLoading, setLazyLoading] = useState(false)
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({})
// Column definitions // Column definitions
const columns = [ const columns = [
@ -98,7 +73,7 @@ const Printers = () => {
title: 'ID', title: 'ID',
dataIndex: 'id', dataIndex: 'id',
key: 'id', key: 'id',
width: 165, width: 180,
render: (text) => <IdText id={text} type='printer' longId={false} /> 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 = ({ const getFilterDropdown = ({
setSelectedKeys, setSelectedKeys,
selectedKeys, 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) => { const getPrinterActionItems = (printerId) => {
return { return {
items: [ items: [
@ -343,7 +228,7 @@ const Printers = () => {
const getViewDropdownItems = () => { const getViewDropdownItems = () => {
const columnItems = columns 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) => ( .map((col) => (
<Checkbox <Checkbox
checked={columnVisibility[col.key]} checked={columnVisibility[col.key]}
@ -388,22 +273,17 @@ const Printers = () => {
], ],
onClick: ({ key }) => { onClick: ({ key }) => {
if (key === 'reloadList') { if (key === 'reloadList') {
fetchPrintersData() tableRef.current?.reload()
} else if (key === 'newPrinter') { } else if (key === 'newPrinter') {
setNewPrinterOpen(true) setNewPrinterOpen(true)
} }
} }
} }
useEffect(() => {
if (authenticated) {
fetchPrintersData()
}
}, [fetchPrintersData, authenticated])
return ( return (
<> <>
<Flex vertical={'true'} gap='large'> <Flex vertical={'true'} gap='large'>
{contextHolder}
<Space> <Space>
<Dropdown menu={actionItems}> <Dropdown menu={actionItems}>
<Button>Actions</Button> <Button>Actions</Button>
@ -415,21 +295,15 @@ const Printers = () => {
> >
<Button>View</Button> <Button>View</Button>
</Popover> </Popover>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Space> </Space>
<Table <DashboardTable
className={styles.customTable} ref={tableRef}
dataSource={printerData}
columns={visibleColumns} columns={visibleColumns}
pagination={false} url={`${config.backendUrl}/printers`}
rowKey='id' authenticated={authenticated}
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
scroll={{ y: 'calc(100vh - 270px)' }}
onChange={handleTableChange}
onScroll={handleScroll}
showSorterTooltip={false}
/> />
<Modal <Modal
open={newPrinterOpen} open={newPrinterOpen}
footer={null} footer={null}
@ -442,7 +316,7 @@ const Printers = () => {
onOk={() => { onOk={() => {
setNewPrinterOpen(false) setNewPrinterOpen(false)
messageApi.success('New printer added successfully.') messageApi.success('New printer added successfully.')
fetchPrintersData() tableRef.current?.reload()
}} }}
reset={newPrinterOpen} reset={newPrinterOpen}
/> />

View File

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

View File

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

View File

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

View File

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

View File

@ -13,9 +13,6 @@ import {
} from 'antd' } from 'antd'
import { import {
LogoutOutlined, LogoutOutlined,
SettingOutlined,
ShoppingCartOutlined,
PoundOutlined,
MailOutlined, MailOutlined,
MenuOutlined, MenuOutlined,
LoadingOutlined LoadingOutlined
@ -25,14 +22,17 @@ import { SocketContext } from '../context/SocketContext'
import { SpotlightContext } from '../context/SpotlightContext' import { SpotlightContext } from '../context/SpotlightContext'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { Header } from 'antd/es/layout/layout' import { Header } from 'antd/es/layout/layout'
import { useMediaQuery } from 'react-responsive'
import FarmControlLogo from '../../Logos/FarmControlLogo' import FarmControlLogo from '../../Logos/FarmControlLogo'
import FarmControlLogoSmall from '../../Logos/FarmControlLogoSmall'
import ProductionIcon from '../../Icons/ProductionIcon' import ProductionIcon from '../../Icons/ProductionIcon'
import InventoryIcon from '../../Icons/InventoryIcon' import InventoryIcon from '../../Icons/InventoryIcon'
import PersonIcon from '../../Icons/PersonIcon' import PersonIcon from '../../Icons/PersonIcon'
import CloudIcon from '../../Icons/CloudIcon' import CloudIcon from '../../Icons/CloudIcon'
import BellIcon from '../../Icons/BellIcon' import BellIcon from '../../Icons/BellIcon'
import SearchIcon from '../../Icons/SearchIcon' import SearchIcon from '../../Icons/SearchIcon'
import SettingsIcon from '../../Icons/SettingsIcon'
const { Text } = Typography const { Text } = Typography
@ -44,6 +44,7 @@ const DashboardNavigation = () => {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const [selectedKey, setSelectedKey] = useState('production') const [selectedKey, setSelectedKey] = useState('production')
const isMobile = useMediaQuery({ maxWidth: 768 })
useEffect(() => { useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean) const pathParts = location.pathname.split('/').filter(Boolean)
@ -73,21 +74,10 @@ const DashboardNavigation = () => {
label: 'Inventory', label: 'Inventory',
icon: <InventoryIcon /> icon: <InventoryIcon />
}, },
{
key: 'sales',
label: 'Sales',
icon: <ShoppingCartOutlined />
},
{
key: 'finance',
label: 'Finance',
icon: <PoundOutlined />
},
{ {
key: 'management', key: 'management',
label: 'Management', label: 'Management',
icon: <SettingOutlined /> icon: <SettingsIcon />
} }
] ]
@ -151,7 +141,11 @@ const DashboardNavigation = () => {
borderBottom: '1px solid rgba(5, 5, 5, 0.00)' borderBottom: '1px solid rgba(5, 5, 5, 0.00)'
}} }}
> >
<FarmControlLogo style={{ fontSize: '200px' }} /> {!isMobile ? (
<FarmControlLogo style={{ fontSize: '200px' }} />
) : (
<FarmControlLogoSmall style={{ fontSize: '48px' }} />
)}
<Menu <Menu
mode='horizontal' mode='horizontal'
items={mainMenuItems} items={mainMenuItems}
@ -162,7 +156,7 @@ const DashboardNavigation = () => {
}} }}
onClick={handleMainMenuClick} onClick={handleMainMenuClick}
selectedKeys={[selectedKey]} selectedKeys={[selectedKey]}
overflowedIndicator={<MenuOutlined />} overflowedIndicator={<Button type='text' icon={<MenuOutlined />} />}
/> />
<Flex gap={'middle'} align='center'> <Flex gap={'middle'} align='center'>
<Space> <Space>
@ -223,7 +217,7 @@ const DashboardNavigation = () => {
<Space> <Space>
<Dropdown menu={userMenuItems} placement='bottomRight'> <Dropdown menu={userMenuItems} placement='bottomRight'>
<Tag style={{ marginRight: 0 }} icon={<PersonIcon />}> <Tag style={{ marginRight: 0 }} icon={<PersonIcon />}>
{userProfile?.name ? userProfile.name : userProfile.username} {!isMobile && (userProfile?.name || userProfile.username)}
</Tag> </Tag>
</Dropdown> </Dropdown>
</Space> </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 { Text } = Typography
const GCodeFileSelect = ({ onChange, filter, useFilter, style }) => { const GCodeFileSelect = ({ onChange, filter, useFilter = false, style }) => {
const [gcodeFilesTreeData, setGCodeFilesTreeData] = useState(null) const [gcodeFilesTreeData, setGCodeFilesTreeData] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [searchValue, setSearchValue] = useState('') const [searchValue, setSearchValue] = useState('')
@ -98,7 +98,7 @@ const GCodeFileSelect = ({ onChange, filter, useFilter, style }) => {
value: gcodeFile._id, value: gcodeFile._id,
key: gcodeFile._id, key: gcodeFile._id,
title: ( title: (
<Flex gap={'small'} align='center'> <Flex gap={'small'} align='center' style={{ width: '100%' }}>
<GCodeFileIcon /> <GCodeFileIcon />
<Badge color={gcodeFile.filament.color} /> <Badge color={gcodeFile.filament.color} />
<Text ellipsis> <Text ellipsis>
@ -211,9 +211,9 @@ const GCodeFileSelect = ({ onChange, filter, useFilter, style }) => {
} }
GCodeFileSelect.propTypes = { GCodeFileSelect.propTypes = {
onChange: PropTypes.func.isRequired, onChange: PropTypes.func,
filter: PropTypes.string.isRequired, filter: PropTypes.string,
useFilter: PropTypes.bool.isRequired, useFilter: PropTypes.bool,
style: PropTypes.object style: PropTypes.object
} }

View File

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

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import { Layout, Menu, Flex, Button } from 'antd' 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 FilamentStockIcon from '../../Icons/FilamentStockIcon'
import PartStockIcon from '../../Icons/PartStockIcon' import PartStockIcon from '../../Icons/PartStockIcon'
import ProductStockIcon from '../../Icons/ProductStockIcon' import ProductStockIcon from '../../Icons/ProductStockIcon'
@ -9,6 +9,7 @@ import StockAuditIcon from '../../Icons/StockAuditIcon'
import StockEventIcon from '../../Icons/StockEventIcon' import StockEventIcon from '../../Icons/StockEventIcon'
import CollapseSidebarIcon from '../../Icons/CollapseSidebarIcon' import CollapseSidebarIcon from '../../Icons/CollapseSidebarIcon'
import ExpandSidebarIcon from '../../Icons/ExpandSidebarIcon' import ExpandSidebarIcon from '../../Icons/ExpandSidebarIcon'
import { useMediaQuery } from 'react-responsive'
const { Sider } = Layout const { Sider } = Layout
@ -21,6 +22,7 @@ const InventorySidebar = () => {
const savedState = sessionStorage.getItem(SIDEBAR_COLLAPSED_KEY) const savedState = sessionStorage.getItem(SIDEBAR_COLLAPSED_KEY)
return savedState ? JSON.parse(savedState) : false return savedState ? JSON.parse(savedState) : false
}) })
const isMobile = useMediaQuery({ maxWidth: 768 })
useEffect(() => { useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean) 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 ( return (
<Sider width={250} theme='light' collapsed={collapsed}> <Sider width={250} theme='light' collapsed={collapsed}>
<Flex <Flex

View File

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

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import { Layout, Menu, Flex, Button } from 'antd' 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 FilamentIcon from '../../Icons/FilamentIcon'
import PartIcon from '../../Icons/PartIcon' import PartIcon from '../../Icons/PartIcon'
import ProductIcon from '../../Icons/ProductIcon' import ProductIcon from '../../Icons/ProductIcon'
@ -9,6 +9,10 @@ import VendorIcon from '../../Icons/VendorIcon'
import MaterialIcon from '../../Icons/MaterialIcon' import MaterialIcon from '../../Icons/MaterialIcon'
import CollapseSidebarIcon from '../../Icons/CollapseSidebarIcon' import CollapseSidebarIcon from '../../Icons/CollapseSidebarIcon'
import ExpandSidebarIcon from '../../Icons/ExpandSidebarIcon' 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 const { Sider } = Layout
@ -21,6 +25,7 @@ const ManagementSidebar = () => {
const savedState = sessionStorage.getItem(SIDEBAR_COLLAPSED_KEY) const savedState = sessionStorage.getItem(SIDEBAR_COLLAPSED_KEY)
return savedState ? JSON.parse(savedState) : false return savedState ? JSON.parse(savedState) : false
}) })
const isMobile = useMediaQuery({ maxWidth: 768 })
useEffect(() => { useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean) const pathParts = location.pathname.split('/').filter(Boolean)
@ -60,18 +65,38 @@ const ManagementSidebar = () => {
label: <Link to='/dashboard/management/materials'>Materials</Link>, label: <Link to='/dashboard/management/materials'>Materials</Link>,
icon: <MaterialIcon /> icon: <MaterialIcon />
}, },
{
key: 'notetypes',
label: <Link to='/dashboard/management/notetypes'>Note Types</Link>,
icon: <NoteTypeIcon />
},
{ type: 'divider' }, { type: 'divider' },
{ {
key: 'settings', key: 'settings',
label: <Link to='/dashboard/management/settings'>Settings</Link>, label: <Link to='/dashboard/management/settings'>Settings</Link>,
icon: <SettingOutlined /> icon: <SettingsIcon />
}, },
{ {
key: 'audit', key: 'auditlogs',
label: <Link to='/dashboard/management/audit'>Audit Log</Link>, label: <Link to='/dashboard/management/auditlogs'>Audit Log</Link>,
icon: <AuditOutlined /> 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 ( return (
<Sider width={250} theme='light' collapsed={collapsed}> <Sider width={250} theme='light' collapsed={collapsed}>
<Flex <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', title: 'ID',
dataIndex: '_id', dataIndex: '_id',
key: 'id', key: 'id',
width: 165, width: 180,
render: (text) => <IdText id={text} type={'part'} showHyperlink={true} /> render: (text) => <IdText id={text} type={'part'} showHyperlink={true} />
} }
] ]

View File

@ -13,9 +13,12 @@ import ReloadIcon from '../../Icons/ReloadIcon'
import config from '../../../config' import config from '../../../config'
const PrinterJobsTree = ({ subJobs: initialSubJobs }) => { const PrinterJobsTree = ({
subJobs: initialSubJobs,
loading: initialLoading
}) => {
const [subJobs, setSubJobs] = useState(initialSubJobs || []) const [subJobs, setSubJobs] = useState(initialSubJobs || [])
const [loading, setLoading] = useState(false) const [treeLoading, setTreeLoading] = useState(initialLoading)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const { socket } = useContext(SocketContext) const { socket } = useContext(SocketContext)
const [messageApi] = message.useMessage() const [messageApi] = message.useMessage()
@ -25,9 +28,9 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
const handleNodeClick = (selectedKeys) => { const handleNodeClick = (selectedKeys) => {
const key = selectedKeys[0] const key = selectedKeys[0]
if (key.startsWith('printjob-')) { if (key.startsWith('job-')) {
const jobId = key.replace('printjob-', '') const jobId = key.replace('job-', '')
navigate(`/dashboard/production/printjobs/info?printJobId=${jobId}`) navigate(`/dashboard/production/jobs/info?jobId=${jobId}`)
} }
} }
@ -38,52 +41,50 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
return return
} }
// Group subjobs by printJob // Group subjobs by job
const printJobGroups = subJobsData.reduce((acc, subJob) => { const jobGroups = subJobsData.reduce((acc, subJob) => {
const printJobId = subJob.printJob._id const jobId = subJob.job._id
if (!acc[printJobId]) { if (!acc[jobId]) {
acc[printJobId] = { acc[jobId] = {
printJob: subJob.printJob, job: subJob.job,
subJobs: [] subJobs: []
} }
} }
acc[printJobId].subJobs.push(subJob) acc[jobId].subJobs.push(subJob)
return acc return acc
}, {}) }, {})
// Create tree nodes for each printJob // Create tree nodes for each job
const printJobNodes = Object.values(printJobGroups).map( const jobNodes = Object.values(jobGroups).map(({ job, subJobs }) => {
({ printJob, subJobs }) => { setExpandedKeys((prev) => [...prev, `job-${job._id}`])
setExpandedKeys((prev) => [...prev, `printjob-${printJob._id}`]) return {
return { title: (
<Space size={5}>
<JobIcon />
{'Job'}
<JobState job={job} />
</Space>
),
key: `job-${job._id}`,
children: subJobs.map((subJob) => ({
title: ( title: (
<Space size={5}> <Space>
<JobIcon /> <SubJobIcon />
{'Job'} {'Sub Job'}
<JobState job={printJob} /> <SubJobState
subJob={subJob}
showProgress={false}
showControls={false}
/>
</Space> </Space>
), ),
key: `printjob-${printJob._id}`, key: `subjob-${subJob._id}`,
children: subJobs.map((subJob) => ({ isLeaf: true
title: ( }))
<Space>
<SubJobIcon />
{'Sub Job'}
<SubJobState
subJob={subJob}
showProgress={false}
showControls={false}
/>
</Space>
),
key: `subjob-${subJob._id}`,
isLeaf: true
}))
}
} }
) })
setTreeData(printJobNodes) setTreeData(jobNodes)
} }
useEffect(() => { useEffect(() => {
@ -94,8 +95,8 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
const initializeData = async () => { const initializeData = async () => {
if (!initialSubJobs) { if (!initialSubJobs) {
try { try {
setLoading(true) setTreeLoading(true)
const response = await axios.get(`${config.backendUrl}/printjobs`, { const response = await axios.get(`${config.backendUrl}/jobs`, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
withCredentials: true withCredentials: true
}) })
@ -106,7 +107,7 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
setError('Failed to fetch sub jobs') setError('Failed to fetch sub jobs')
messageApi.error('Failed to fetch sub jobs') messageApi.error('Failed to fetch sub jobs')
} finally { } finally {
setLoading(false) setTreeLoading(false)
} }
} else { } else {
setSubJobs(initialSubJobs) setSubJobs(initialSubJobs)
@ -142,14 +143,6 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
} }
}, [initialSubJobs, socket]) }, [initialSubJobs, socket])
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
</div>
)
}
if (error) { if (error) {
return ( return (
<Space <Space
@ -165,15 +158,17 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
} }
return ( return (
<Card> <Spin indicator={<LoadingOutlined />} spinning={treeLoading}>
<Tree <Card>
treeData={treeData} <Tree
expandedKeys={expandedKeys} treeData={treeData}
onExpand={setExpandedKeys} expandedKeys={expandedKeys}
onSelect={handleNodeClick} onExpand={setExpandedKeys}
showLine={true} onSelect={handleNodeClick}
/> showLine={true}
</Card> />
</Card>
</Spin>
) )
} }
@ -183,7 +178,7 @@ PrinterJobsTree.propTypes = {
state: PropTypes.object.isRequired, state: PropTypes.object.isRequired,
_id: PropTypes.string.isRequired, _id: PropTypes.string.isRequired,
printer: PropTypes.string.isRequired, printer: PropTypes.string.isRequired,
printJob: PropTypes.shape({ job: PropTypes.shape({
state: PropTypes.object.isRequired, state: PropTypes.object.isRequired,
_id: PropTypes.string.isRequired, _id: PropTypes.string.isRequired,
printers: PropTypes.arrayOf(PropTypes.string).isRequired, printers: PropTypes.arrayOf(PropTypes.string).isRequired,
@ -199,7 +194,8 @@ PrinterJobsTree.propTypes = {
createdAt: PropTypes.string.isRequired, createdAt: PropTypes.string.isRequired,
updatedAt: PropTypes.string.isRequired updatedAt: PropTypes.string.isRequired
}) })
) ),
loading: PropTypes.bool
} }
export default PrinterJobsTree 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, Space,
Collapse, Collapse,
InputNumber, InputNumber,
Switch, Descriptions,
Descriptions Button,
Tag
} from 'antd' } from 'antd'
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons' import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
import { SocketContext } from '../context/SocketContext' import { SocketContext } from '../context/SocketContext'
@ -46,6 +47,10 @@ const PrinterPositionPanel = ({
const [initialized, setInitialized] = useState(false) const [initialized, setInitialized] = useState(false)
const { socket } = useContext(SocketContext) const { socket } = useContext(SocketContext)
const [speedFactor, setSpeedFactor] = useState(positionData.speed_factor)
const [extrudeFactor, setExtrudeFactor] = useState(
positionData.extrude_factor
)
useEffect(() => { useEffect(() => {
const params = { const params = {
@ -57,7 +62,6 @@ const PrinterPositionPanel = ({
} }
const notifyPositionStatusUpdate = (statusUpdate) => { const notifyPositionStatusUpdate = (statusUpdate) => {
console.log(statusUpdate)
if (statusUpdate?.toolhead) { if (statusUpdate?.toolhead) {
setPositionData((prevData) => ({ setPositionData((prevData) => ({
...prevData, ...prevData,
@ -72,44 +76,44 @@ const PrinterPositionPanel = ({
} }
} }
if (!initialized && socket) { if (!initialized && socket.connected) {
setInitialized(true) setInitialized(true)
socket.on('connect', () => { socket.on('connect', () => {
console.log('Connected to socket!')
socket.emit('printer.objects.subscribe', params) socket.emit('printer.objects.subscribe', params)
socket.emit('printer.objects.query', params) socket.emit('printer.objects.query', params)
}) })
console.log('Subscribing to position data')
socket.emit('printer.objects.subscribe', params) socket.emit('printer.objects.subscribe', params)
socket.emit('printer.objects.query', params) socket.emit('printer.objects.query', params)
socket.on('notify_status_update', notifyPositionStatusUpdate) socket.on('notify_status_update', notifyPositionStatusUpdate)
} }
setSpeedFactor(positionData.speed_factor)
setExtrudeFactor(positionData.extrude_factor)
return () => { return () => {
if (socket && initialized && shouldUnsubscribe) { if (socket.connected && initialized && shouldUnsubscribe) {
console.log('Unsubscribing...')
socket.off('notify_status_update', notifyPositionStatusUpdate) socket.off('notify_status_update', notifyPositionStatusUpdate)
socket.emit('printer.objects.unsubscribe', params) socket.emit('printer.objects.unsubscribe', params)
} }
} }
}, [socket, initialized, printerId, shouldUnsubscribe]) }, [socket, initialized, printerId, shouldUnsubscribe])
const handleSetSpeedFactor = (value) => { const handleSetSpeedFactor = () => {
if (socket) { if (socket) {
socket.emit('printer.gcode.script', { socket.emit('printer.gcode.script', {
printerId, printerId,
script: `M220 S${value * 100}` script: `M220 S${speedFactor * 100}`
}) })
} }
} }
const handleSetExtrudeFactor = (value) => { const handleSetExtrudeFactor = () => {
if (socket) { if (socket) {
socket.emit('printer.gcode.script', { socket.emit('printer.gcode.script', {
printerId, printerId,
script: `M221 S${value * 100}` script: `M221 S${extrudeFactor * 100}`
}) })
} }
} }
@ -181,49 +185,69 @@ const PrinterPositionPanel = ({
{positionData.position[3].toFixed(2)}mm {positionData.position[3].toFixed(2)}mm
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
<Descriptions column={1} size='small' bordered>
<Descriptions.Item label='Current Speed'>
{positionData.speed}mm/s
</Descriptions.Item>
</Descriptions>
{showControls && ( {showControls && (
<> <>
<Space direction='vertical' style={{ width: '100%' }}> <Space direction='vertical' style={{ width: '100%' }}>
<Space direction='horizontal'> <Space direction='horizontal'>
<Text>Speed Factor:</Text> <Text>Speed Factor:</Text>
<InputNumber <Space.Compact block size='small'>
value={positionData.speed_factor} <InputNumber
min={0.1} value={speedFactor.toFixed(2)}
max={2} min={0.1}
step={0.1} max={2}
style={{ width: '100px' }} step={0.1}
onChange={(value) => handleSetSpeedFactor(value)} 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>
<Space direction='horizontal'> <Space direction='horizontal'>
<Text>Extrude Factor:</Text> <Text>Extrude Factor:</Text>
<InputNumber <Space.Compact block size='small'>
value={positionData.extrude_factor} <InputNumber
min={0.1} value={extrudeFactor.toFixed(2)}
max={2} min={0.1}
step={0.1} max={2}
style={{ width: '100px' }} step={0.1}
onChange={(value) => handleSetExtrudeFactor(value)} style={{ width: '100px' }}
/> onChange={(value) => setExtrudeFactor(value)}
</Space> onPressEnter={handleSetExtrudeFactor}
size='small'
<Space direction='horizontal'> />
<Text>Absolute Coordinates:</Text> <Button
<Switch type='default'
checked={positionData.absolute_coordinates} style={{ width: 40 }}
disabled={true} onClick={handleSetExtrudeFactor}
/> >
Set
</Button>
</Space.Compact>
</Space> </Space>
</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> </Flex>
{showMoreInfo && ( {showMoreInfo && (

View File

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

View File

@ -1,12 +1,13 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import { Layout, Menu, Flex, Button } from 'antd' 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 GCodeFileIcon from '../../Icons/GCodeFileIcon'
import JobIcon from '../../Icons/JobIcon' import JobIcon from '../../Icons/JobIcon'
import PrinterIcon from '../../Icons/PrinterIcon' import PrinterIcon from '../../Icons/PrinterIcon'
import CollapseSidebarIcon from '../../Icons/CollapseSidebarIcon' import CollapseSidebarIcon from '../../Icons/CollapseSidebarIcon'
import ExpandSidebarIcon from '../../Icons/ExpandSidebarIcon' import ExpandSidebarIcon from '../../Icons/ExpandSidebarIcon'
import { useMediaQuery } from 'react-responsive'
const { Sider } = Layout const { Sider } = Layout
@ -19,6 +20,7 @@ const ProductionSidebar = () => {
const savedState = sessionStorage.getItem(SIDEBAR_COLLAPSED_KEY) const savedState = sessionStorage.getItem(SIDEBAR_COLLAPSED_KEY)
return savedState ? JSON.parse(savedState) : false return savedState ? JSON.parse(savedState) : false
}) })
const isMobile = useMediaQuery({ maxWidth: 768 })
useEffect(() => { useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean) const pathParts = location.pathname.split('/').filter(Boolean)
@ -45,8 +47,8 @@ const ProductionSidebar = () => {
icon: <PrinterIcon /> icon: <PrinterIcon />
}, },
{ {
key: 'printjobs', key: 'jobs',
label: <Link to='/dashboard/production/printjobs'>Print Jobs</Link>, label: <Link to='/dashboard/production/jobs'>Print Jobs</Link>,
icon: <JobIcon /> icon: <JobIcon />
}, },
{ {
@ -55,6 +57,20 @@ const ProductionSidebar = () => {
icon: <GCodeFileIcon /> icon: <GCodeFileIcon />
} }
] ]
if (isMobile) {
return (
<Menu
mode='horizontal'
selectedKeys={[selectedKey]}
defaultSelectedKeys={['overview']}
items={items}
_internalDisableMenuItemTitleTooltip
overflowedIndicator={<Button type='text' icon={<CaretDownFilled />} />}
/>
)
}
return ( return (
<Sider width={250} theme='light' collapsed={collapsed}> <Sider width={250} theme='light' collapsed={collapsed}>
<Flex <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' import config from '../../../config'
const SubJobsTree = ({ printJobData }) => { const SubJobsTree = ({ jobData, loading }) => {
const [treeData, setTreeData] = useState([]) const [treeData, setTreeData] = useState([])
const [loading, setLoading] = useState(false) const [treeLoading, setTreeLoading] = useState(loading)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const { socket } = useContext(SocketContext) const { socket } = useContext(SocketContext)
const [messageApi] = message.useMessage() const [messageApi] = message.useMessage()
const [expandedKeys, setExpandedKeys] = useState([]) const [expandedKeys, setExpandedKeys] = useState([])
const [currentPrintJobData, setCurrentPrintJobData] = useState(null) const [currentJobData, setCurrentJobData] = useState(null)
const navigate = useNavigate() const navigate = useNavigate()
const buildTreeData = useCallback( const buildTreeData = useCallback(
@ -83,29 +83,29 @@ const SubJobsTree = ({ printJobData }) => {
} }
useEffect(() => { useEffect(() => {
buildTreeData(currentPrintJobData) buildTreeData(currentJobData)
}, [currentPrintJobData]) }, [currentJobData])
useEffect(() => { useEffect(() => {
const initializeData = async () => { const initializeData = async () => {
if (!printJobData) { if (!jobData) {
try { try {
setLoading(true) setTreeLoading(true)
const response = await axios.get(`${config.backendUrl}/printjobs`, { const response = await axios.get(`${config.backendUrl}/jobs`, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
withCredentials: true withCredentials: true
}) })
if (response.data) { if (response.data) {
setCurrentPrintJobData(response.data) setCurrentJobData(response.data)
} }
} catch (err) { } catch (err) {
setError('Failed to fetch print job details') setError('Failed to fetch print job details')
messageApi.error('Failed to fetch print job details') messageApi.error('Failed to fetch print job details')
} finally { } finally {
setLoading(false) setTreeLoading(false)
} }
} else { } else {
setCurrentPrintJobData(printJobData) setCurrentJobData(jobData)
} }
} }
@ -115,7 +115,7 @@ const SubJobsTree = ({ printJobData }) => {
if (socket) { if (socket) {
socket.on('notify_deployment_update', (updateData) => { socket.on('notify_deployment_update', (updateData) => {
console.log('Received deployment update:', updateData) console.log('Received deployment update:', updateData)
setCurrentPrintJobData((prevData) => { setCurrentJobData((prevData) => {
if (!prevData) return prevData if (!prevData) return prevData
// Handle printer updates // Handle printer updates
@ -152,7 +152,7 @@ const SubJobsTree = ({ printJobData }) => {
// Handle sub-job updates // Handle sub-job updates
if (updateData.subJobId) { if (updateData.subJobId) {
console.log('Received subjob update:', updateData) console.log('Received subjob update:', updateData)
setCurrentPrintJobData((prevData) => { setCurrentJobData((prevData) => {
if (!prevData) return prevData if (!prevData) return prevData
return { return {
...prevData, ...prevData,
@ -178,15 +178,7 @@ const SubJobsTree = ({ printJobData }) => {
socket.off('notify_deployment_update') socket.off('notify_deployment_update')
} }
} }
}, [printJobData, socket]) }, [jobData, socket])
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
</div>
)
}
if (error) { if (error) {
return ( return (
@ -195,10 +187,7 @@ const SubJobsTree = ({ printJobData }) => {
style={{ width: '100%', textAlign: 'center' }} style={{ width: '100%', textAlign: 'center' }}
> >
<p>{error}</p> <p>{error}</p>
<Button <Button icon={<ReloadIcon />} onClick={() => buildTreeData(jobData)}>
icon={<ReloadIcon />}
onClick={() => buildTreeData(printJobData)}
>
Retry Retry
</Button> </Button>
</Space> </Space>
@ -206,20 +195,23 @@ const SubJobsTree = ({ printJobData }) => {
} }
return ( return (
<Card> <Spin indicator={<LoadingOutlined />} spinning={treeLoading}>
<Tree <Card style={{ minHeight: 160 }}>
treeData={treeData} <Tree
expandedKeys={expandedKeys} treeData={treeData}
onExpand={setExpandedKeys} expandedKeys={expandedKeys}
onSelect={handleNodeClick} onExpand={setExpandedKeys}
showLine={true} onSelect={handleNodeClick}
/> showLine={true}
</Card> />
</Card>
</Spin>
) )
} }
SubJobsTree.propTypes = { SubJobsTree.propTypes = {
printJobData: PropTypes.object.isRequired jobData: PropTypes.object.isRequired,
loading: PropTypes.bool
} }
export default SubJobsTree export default SubJobsTree

View File

@ -215,7 +215,7 @@ const AuthProvider = ({ children }) => {
open={showSessionExpiredModal} open={showSessionExpiredModal}
onOk={handleSessionExpiredModalOk} onOk={handleSessionExpiredModalOk}
okText='Log In' okText='Log In'
style={{ maxWidth: 430 }} style={{ maxWidth: 430, top: '50%', transform: 'translateY(-50%)' }}
closable={false} closable={false}
centered centered
maskClosable={false} maskClosable={false}
@ -244,9 +244,8 @@ const AuthProvider = ({ children }) => {
loginWithSSO() loginWithSSO()
}} }}
okText='Log In' okText='Log In'
style={{ maxWidth: 430 }} style={{ maxWidth: 430, top: '50%', transform: 'translateY(-50%)' }}
closable={false} closable={false}
centered
maskClosable={false} maskClosable={false}
footer={[ footer={[
<Button <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 // PrivateRoute.js
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import React, { useContext } from 'react' import React, { useContext, useState, useEffect } from 'react'
import { AuthContext } from './Dashboard/context/AuthContext' import { AuthContext } from './Dashboard/context/AuthContext'
import AuthLoading from './App/AppLoading' import AuthLoading from './App/AppLoading'
import { useThemeContext } from './Dashboard/context/ThemeContext'
const PrivateRoute = ({ component: Component }) => { const PrivateRoute = ({ component: Component }) => {
const { isDarkMode } = useThemeContext()
const { authenticated, loading, showSessionExpiredModal } = const { authenticated, loading, showSessionExpiredModal } =
useContext(AuthContext) 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 // Show loading state while auth state is being determined
if (loading) { if (loading) {
@ -14,10 +25,21 @@ const PrivateRoute = ({ component: Component }) => {
} }
// Redirect to login if not authenticated // Redirect to login if not authenticated
return authenticated || showSessionExpiredModal ? ( return (
<Component /> <div style={{ background: isDarkMode ? '#000000' : '#ffffff' }}>
) : ( <div
<Component /> 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 = { const config = {
development: { development: {
backendUrl: 'http://localhost:8080', backendUrl: 'http://192.168.68.53:8080',
wsUrl: 'ws://localhost:8081' wsUrl: 'ws://192.168.68.53:8081'
}, },
production: { production: {
backendUrl: 'http://localhost:8080', // Replace with your production backend URL backendUrl: 'http://localhost:8080', // Replace with your production backend URL

View File

@ -1,15 +1,5 @@
body { body {
margin: 0; 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 { .ant-modal-mask {