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.
3169
package-lock.json
generated
@ -3,6 +3,8 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@ant-design/charts": "^2.3.0",
|
||||
"@ant-design/pro-components": "^2.8.7",
|
||||
"@babel/plugin-transform-private-property-in-object": "^7.27.1",
|
||||
"@simplewebauthn/browser": "^13.1.0",
|
||||
"@tsparticles/react": "^3.0.0",
|
||||
@ -27,9 +29,12 @@
|
||||
"react": "^18.3.1",
|
||||
"react-country-flag": "^3.1.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-responsive": "^10.0.1",
|
||||
"react-router-dom": "*",
|
||||
"react-scripts": "*",
|
||||
"react-stl-viewer": "^2.5.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"socket.io-client": "*",
|
||||
"standard": "^17.1.2",
|
||||
"styled-components": "*",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@ -24,7 +24,7 @@
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
<title>Farm Control</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
18
src/App.css
@ -1,6 +1,18 @@
|
||||
body,
|
||||
.ant-typography {
|
||||
font-family: 'SF Pro';
|
||||
:root {
|
||||
--unit-100vh: 100vh;
|
||||
}
|
||||
@supports (height: 100dvh) {
|
||||
:root {
|
||||
--unit-100vh: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-overflow-item-rest::after {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.ant-menu-overflow-item > div:hover {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.App {
|
||||
|
||||
31
src/App.jsx
@ -13,8 +13,8 @@ import Printers from './components/Dashboard/Production/Printers'
|
||||
import ControlPrinter from './components/Dashboard/Production/Printers/ControlPrinter.jsx'
|
||||
import PrinterInfo from './components/Dashboard/Production/Printers/PrinterInfo.jsx'
|
||||
|
||||
import PrintJobs from './components/Dashboard/Production/PrintJobs.jsx'
|
||||
import PrintJobInfo from './components/Dashboard/Production/PrintJobs/PrintJobInfo.jsx'
|
||||
import Jobs from './components/Dashboard/Production/Jobs.jsx'
|
||||
import JobInfo from './components/Dashboard/Production/Jobs/JobInfo.jsx'
|
||||
|
||||
import Filaments from './components/Dashboard/Management/Filaments'
|
||||
import FilamentInfo from './components/Dashboard/Management/Filaments/FilamentInfo.jsx'
|
||||
@ -48,12 +48,17 @@ import { SocketProvider } from './components/Dashboard/context/SocketContext.js'
|
||||
import { AuthProvider } from './components/Dashboard/context/AuthContext.js'
|
||||
import { SpotlightProvider } from './components/Dashboard/context/SpotlightContext.js'
|
||||
import StockEvents from './components/Dashboard/Inventory/StockEvents.jsx'
|
||||
|
||||
import Settings from './components/Dashboard/Management/Settings'
|
||||
import AuditLogs from './components/Dashboard/Management/AuditLogs.jsx'
|
||||
|
||||
import {
|
||||
ThemeProvider,
|
||||
useThemeContext
|
||||
} from './components/Dashboard/context/ThemeContext'
|
||||
import AppError from './components/App/AppError'
|
||||
import NoteTypes from './components/Dashboard/Management/NoteTypes.jsx'
|
||||
import NoteTypeInfo from './components/Dashboard/Management/NoteTypes/NoteTypeInfo.jsx'
|
||||
|
||||
const AppContent = () => {
|
||||
const { themeConfig } = useThemeContext()
|
||||
@ -97,14 +102,8 @@ const AppContent = () => {
|
||||
path='production/printers/info'
|
||||
element={<PrinterInfo />}
|
||||
/>
|
||||
<Route
|
||||
path='production/printjobs'
|
||||
element={<PrintJobs />}
|
||||
/>
|
||||
<Route
|
||||
path='production/printjobs/info'
|
||||
element={<PrintJobInfo />}
|
||||
/>
|
||||
<Route path='production/jobs' element={<Jobs />} />
|
||||
<Route path='production/jobs/info' element={<JobInfo />} />
|
||||
<Route
|
||||
path='production/gcodefiles'
|
||||
element={<GCodeFiles />}
|
||||
@ -168,7 +167,19 @@ const AppContent = () => {
|
||||
path='management/materials'
|
||||
element={<Materials />}
|
||||
/>
|
||||
<Route
|
||||
path='management/notetypes'
|
||||
element={<NoteTypes />}
|
||||
/>
|
||||
<Route
|
||||
path='management/notetypes/info'
|
||||
element={<NoteTypeInfo />}
|
||||
/>
|
||||
<Route path='management/settings' element={<Settings />} />
|
||||
<Route
|
||||
path='management/auditlogs'
|
||||
element={<AuditLogs />}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path='*'
|
||||
|
||||
BIN
src/assets/icons/auditlogicon.afdesign
Normal file
1
src/assets/icons/auditlogicon.min.svg
Normal 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 |
9
src/assets/icons/auditlogicon.svg
Normal 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 |
@ -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 |
@ -1,12 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
|
||||
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" version="1.1" viewBox="0 0 65 67" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(.09375 1.5681)">
|
||||
<g transform="matrix(.93038 0 0 .93038 2.2234 .097899)">
|
||||
<path d="m23.324 37.057-16.574 9.474c-0.125 0.063-0.219 0.157-0.219 0.313s0.094 0.219 0.219 0.312l23.781 13.594c0.532 0.281 0.969 0.438 1.407 0.438 0.437 0 0.875-0.157 1.375-0.438l23.781-13.594c0.156-0.093 0.25-0.156 0.25-0.312s-0.094-0.25-0.25-0.313l-16.562-9.467 5.225-3.033 14.587 8.469c2.531 1.5 3.531 2.625 3.531 4.344s-1 2.844-3.531 4.312l-24.438 14.219c-1.5 0.875-2.718 1.281-3.968 1.281-1.282 0-2.469-0.406-3.969-1.281l-24.469-14.219c-2.531-1.468-3.5-2.593-3.5-4.312s0.969-2.844 3.5-4.344l14.606-8.469 5.218 3.026z" fill-rule="nonzero"/>
|
||||
</g>
|
||||
<g transform="matrix(.93038 0 0 .93038 2.2234 .097899)">
|
||||
<path d="m31.938 41.031c1.25 0 2.468-0.406 3.968-1.281l24.438-14.187c2.531-1.5 3.531-2.625 3.531-4.344s-1-2.844-3.531-4.313l-24.438-14.218c-1.5-0.875-2.718-1.282-3.968-1.282-1.282 0-2.469 0.407-3.969 1.282l-24.469 14.218c-2.531 1.469-3.5 2.594-3.5 4.313s0.969 2.844 3.5 4.344l24.469 14.187c1.5 0.875 2.687 1.281 3.969 1.281zm0-5.468c-0.438 0-0.875-0.125-1.407-0.438l-23.781-13.594c-0.125-0.062-0.219-0.156-0.219-0.312s0.094-0.219 0.219-0.313l23.781-13.593c0.532-0.282 0.969-0.438 1.407-0.438 0.437 0 0.875 0.156 1.375 0.438l23.781 13.593c0.156 0.094 0.25 0.157 0.25 0.313s-0.094 0.25-0.25 0.312l-23.781 13.594c-0.5 0.313-0.938 0.438-1.375 0.438z" fill-rule="nonzero"/>
|
||||
</g>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 65 67" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(0.89681,0,0,0.89681,3.03125,0.598183)">
|
||||
<path d="M60.856,46.701C63.511,48.237 64.674,49.551 64.674,51.514C64.674,53.498 63.511,54.811 60.856,56.353L36.576,70.434C34.946,71.382 33.66,71.85 32.337,71.85C30.988,71.85 29.733,71.382 28.098,70.434L3.792,56.353C1.137,54.811 0,53.498 0,51.514C0,49.551 1.137,48.237 3.792,46.701L8.051,44.233L14.131,47.761L8.044,51.233C7.919,51.296 7.836,51.389 7.836,51.514C7.836,51.659 7.919,51.753 8.044,51.816L31.043,64.94C31.541,65.199 31.933,65.344 32.337,65.344C32.741,65.344 33.133,65.199 33.605,64.94L56.604,51.816C56.755,51.753 56.838,51.659 56.838,51.514C56.838,51.389 56.755,51.296 56.604,51.233L50.523,47.764L56.601,44.233L60.856,46.701Z" style="fill-rule:nonzero;"/>
|
||||
<path d="M60.856,32.109C63.511,33.671 64.674,34.965 64.674,36.948C64.674,38.931 63.511,40.219 60.856,41.761L36.576,55.868C34.946,56.816 33.66,57.284 32.337,57.284C30.988,57.284 29.733,56.816 28.098,55.868L3.792,41.761C1.137,40.219 0,38.931 0,36.948C0,34.965 1.137,33.671 3.792,32.109L8.814,29.199L14.911,32.732L8.044,36.641C7.919,36.709 7.836,36.798 7.836,36.948C7.836,37.093 7.919,37.161 8.044,37.249L31.043,50.348C31.541,50.607 31.933,50.752 32.337,50.752C32.741,50.752 33.133,50.607 33.605,50.348L56.604,37.249C56.755,37.161 56.838,37.093 56.838,36.948C56.838,36.798 56.755,36.709 56.604,36.641L49.743,32.735L55.84,29.199L60.856,32.109Z" style="fill-rule:nonzero;"/>
|
||||
<path d="M32.337,41.788C33.66,41.788 34.946,41.325 36.576,40.371L60.856,26.291C63.511,24.754 64.674,23.435 64.674,21.452C64.674,19.494 63.511,18.181 60.856,16.639L36.576,2.558C34.946,1.61 33.66,1.142 32.337,1.142C30.988,1.142 29.733,1.61 28.098,2.558L3.792,16.639C1.137,18.181 0,19.494 0,21.452C0,23.435 1.137,24.754 3.792,26.291L28.098,40.371C29.733,41.325 30.988,41.788 32.337,41.788ZM32.337,35.287C31.933,35.287 31.541,35.142 31.043,34.878L8.044,21.759C7.919,21.691 7.836,21.603 7.836,21.452C7.836,21.327 7.919,21.239 8.044,21.171L31.043,8.052C31.541,7.787 31.933,7.648 32.337,7.648C32.741,7.648 33.133,7.787 33.605,8.052L56.604,21.171C56.755,21.239 56.838,21.327 56.838,21.452C56.838,21.603 56.755,21.691 56.604,21.759L33.605,34.878C33.133,35.142 32.741,35.287 32.337,35.287Z" style="fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.6 KiB |
BIN
src/assets/icons/notetypeicon.afdesign
Normal file
1
src/assets/icons/notetypeicon.min.svg
Normal 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 |
18
src/assets/icons/notetypeicon.svg
Normal 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 |
BIN
src/assets/icons/settingsicon.afdesign
Normal file
1
src/assets/icons/settingsicon.min.svg
Normal 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 |
7
src/assets/icons/settingsicon.svg
Normal 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 |
BIN
src/assets/logos/farmcontrollogosmall.afdesign
Normal file
18
src/assets/logos/farmcontrollogosmall.svg
Normal 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 |
@ -32,7 +32,7 @@ const AppError = ({
|
||||
>
|
||||
<Card>
|
||||
<Flex vertical align='center'>
|
||||
<FarmControlLogo style={{ fontSize: '350px', height: '31px' }} />
|
||||
<FarmControlLogo style={{ fontSize: '500px', height: '40px' }} />
|
||||
</Flex>
|
||||
</Card>
|
||||
<Alert
|
||||
|
||||
@ -1,12 +1,34 @@
|
||||
import React from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Flex, Card, Alert } from 'antd'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import AuthParticles from './AppParticles'
|
||||
import FarmControlLogo from '../Logos/FarmControlLogo'
|
||||
|
||||
const AppLoading = () => {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(true)
|
||||
}, 1000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'black'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'black',
|
||||
minHeight: '100vh',
|
||||
transition: 'opacity 0.5s ease-in-out',
|
||||
opacity: isVisible ? 1 : 0
|
||||
}}
|
||||
>
|
||||
<AuthParticles />
|
||||
<Flex
|
||||
align='center'
|
||||
@ -17,7 +39,7 @@ const AppLoading = () => {
|
||||
>
|
||||
<Card>
|
||||
<Flex vertical align='center'>
|
||||
<FarmControlLogo style={{ fontSize: '350px', height: '31px' }} />
|
||||
<FarmControlLogo style={{ fontSize: '500px', height: '40px' }} />
|
||||
</Flex>
|
||||
</Card>
|
||||
<Alert
|
||||
@ -26,7 +48,8 @@ const AppLoading = () => {
|
||||
showIcon
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,24 +1,19 @@
|
||||
// src/filamentStocks.js
|
||||
|
||||
import React, { useEffect, useState, useContext, useCallback } from 'react'
|
||||
import React, { useState, useContext, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Flex,
|
||||
Space,
|
||||
Modal,
|
||||
message,
|
||||
Dropdown,
|
||||
Typography,
|
||||
Popover,
|
||||
message,
|
||||
Checkbox,
|
||||
Input,
|
||||
Spin
|
||||
Popover,
|
||||
Input
|
||||
} from 'antd'
|
||||
import { createStyles } from 'antd-style'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import { SocketContext } from '../context/SocketContext'
|
||||
@ -34,49 +29,20 @@ import TimeDisplay from '../common/TimeDisplay'
|
||||
import XMarkIcon from '../../Icons/XMarkIcon'
|
||||
import CheckIcon from '../../Icons/CheckIcon'
|
||||
import useColumnVisibility from '../hooks/useColumnVisibility'
|
||||
import DashboardTable from '../common/DashboardTable'
|
||||
|
||||
import config from '../../../config'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const useStyle = createStyles(({ css, token }) => {
|
||||
const { antCls } = token
|
||||
return {
|
||||
customTable: css`
|
||||
${antCls}-table {
|
||||
${antCls}-table-container {
|
||||
${antCls}-table-body,
|
||||
${antCls}-table-content {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #eaeaea transparent;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
})
|
||||
|
||||
const FilamentStocks = () => {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const navigate = useNavigate()
|
||||
const { styles } = useStyle()
|
||||
const { socket } = useContext(SocketContext)
|
||||
|
||||
const [filamentStocksData, setFilamentStocksData] = useState([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [lazyLoading, setLazyLoading] = useState(false)
|
||||
|
||||
const [filters, setFilters] = useState({})
|
||||
const [sorter, setSorter] = useState({
|
||||
field: 'createdAt',
|
||||
order: 'descend'
|
||||
})
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const tableRef = useRef()
|
||||
|
||||
const [newFilamentStockOpen, setNewFilamentStockOpen] = useState(false)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
|
||||
const { authenticated } = useContext(AuthContext)
|
||||
|
||||
@ -116,135 +82,6 @@ const FilamentStocks = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const fetchFilamentStocksData = useCallback(
|
||||
async (pageNum = 1, append = false) => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${config.backendUrl}/filamentstocks`,
|
||||
{
|
||||
params: {
|
||||
page: pageNum,
|
||||
limit: 25,
|
||||
...filters,
|
||||
sort: sorter.field,
|
||||
order: sorter.order
|
||||
},
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
const newData = response.data
|
||||
setHasMore(newData.length === 25)
|
||||
|
||||
if (append) {
|
||||
setFilamentStocksData((prev) => [...prev, ...newData])
|
||||
} else {
|
||||
setFilamentStocksData(newData)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
setLazyLoading(false)
|
||||
} catch (err) {
|
||||
messageApi.error('Error fetching filament stocks:', err)
|
||||
setLoading(false)
|
||||
setLazyLoading(false)
|
||||
}
|
||||
},
|
||||
[messageApi, filters, sorter]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (authenticated) {
|
||||
fetchFilamentStocksData()
|
||||
}
|
||||
}, [authenticated, fetchFilamentStocksData])
|
||||
|
||||
useEffect(() => {
|
||||
if (socket && !initialized) {
|
||||
setInitialized(true)
|
||||
socket.on('notify_filamentstock_update', (updateData) => {
|
||||
console.log('Received filament stock update:', updateData)
|
||||
setFilamentStocksData((prevData) => {
|
||||
return prevData.map((stock) => {
|
||||
if (stock._id === updateData._id) {
|
||||
return {
|
||||
...stock,
|
||||
...updateData
|
||||
}
|
||||
}
|
||||
return stock
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (socket && initialized) {
|
||||
console.log('Deregistering filament stock update listener')
|
||||
socket.off('notify_filamentstock_update')
|
||||
}
|
||||
}
|
||||
}, [socket, initialized])
|
||||
|
||||
const getFilamentStockActionItems = (id) => {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
label: 'Info',
|
||||
key: 'info',
|
||||
icon: <InfoCircleIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'info') {
|
||||
navigate(
|
||||
`/dashboard/inventory/filamentstocks/info?filamentStockId=${id}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(e) => {
|
||||
const { target } = e
|
||||
const scrollHeight = target.scrollHeight
|
||||
const scrollTop = target.scrollTop
|
||||
const clientHeight = target.clientHeight
|
||||
|
||||
if (
|
||||
scrollHeight - scrollTop - clientHeight < 100 &&
|
||||
!lazyLoading &&
|
||||
hasMore
|
||||
) {
|
||||
setLazyLoading(true)
|
||||
const nextPage = page + 1
|
||||
setPage(nextPage)
|
||||
fetchFilamentStocksData(nextPage, true)
|
||||
}
|
||||
},
|
||||
[page, lazyLoading, hasMore, fetchFilamentStocksData]
|
||||
)
|
||||
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
const newFilters = {}
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value && value.length > 0) {
|
||||
newFilters[key] = value[0]
|
||||
}
|
||||
})
|
||||
setPage(1)
|
||||
setFilters(newFilters)
|
||||
setSorter({
|
||||
field: sorter.field,
|
||||
order: sorter.order
|
||||
})
|
||||
fetchFilamentStocksData(1)
|
||||
}
|
||||
|
||||
// Column definitions
|
||||
const columns = [
|
||||
{
|
||||
@ -281,7 +118,7 @@ const FilamentStocks = () => {
|
||||
title: 'ID',
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 165,
|
||||
width: 180,
|
||||
render: (text) => (
|
||||
<IdText id={text} type={'filamentstock'} longId={false} />
|
||||
)
|
||||
@ -366,6 +203,72 @@ const FilamentStocks = () => {
|
||||
}
|
||||
]
|
||||
|
||||
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
|
||||
'FilamentStocks',
|
||||
columns
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (socket && !initialized) {
|
||||
setInitialized(true)
|
||||
socket.on('notify_filamentstock_update', (updateData) => {
|
||||
console.log('Received filament stock update:', updateData)
|
||||
if (tableRef.current) {
|
||||
tableRef.current.updateData(updateData._id, updateData)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (socket && initialized) {
|
||||
console.log('Deregistering filament stock update listener')
|
||||
socket.off('notify_filamentstock_update')
|
||||
}
|
||||
}
|
||||
}, [socket, initialized])
|
||||
|
||||
const getFilamentStockActionItems = (id) => {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
label: 'Info',
|
||||
key: 'info',
|
||||
icon: <InfoCircleIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'info') {
|
||||
navigate(
|
||||
`/dashboard/inventory/filamentstocks/info?filamentStockId=${id}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const actionItems = {
|
||||
items: [
|
||||
{
|
||||
label: 'New Filament Stock',
|
||||
key: 'newFilamentStock',
|
||||
icon: <PlusIcon />
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
label: 'Reload List',
|
||||
key: 'reloadList',
|
||||
icon: <ReloadIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reloadList') {
|
||||
tableRef.current?.reload()
|
||||
} else if (key === 'newFilamentStock') {
|
||||
setNewFilamentStockOpen(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getViewDropdownItems = () => {
|
||||
const columnItems = columns
|
||||
.filter((col) => col.key && col.title !== '')
|
||||
@ -390,39 +293,10 @@ const FilamentStocks = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
|
||||
'FilamentStocks',
|
||||
columns
|
||||
)
|
||||
|
||||
const visibleColumns = columns.filter(
|
||||
(col) => !col.key || columnVisibility[col.key]
|
||||
)
|
||||
|
||||
const actionItems = {
|
||||
items: [
|
||||
{
|
||||
label: 'New Filament Stock',
|
||||
key: 'newFilamentStock',
|
||||
icon: <PlusIcon />
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
label: 'Reload List',
|
||||
key: 'reloadList',
|
||||
icon: <ReloadIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reloadList') {
|
||||
setPage(1)
|
||||
fetchFilamentStocksData(1)
|
||||
} else if (key === 'newFilamentStock') {
|
||||
setNewFilamentStockOpen(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex vertical={'true'} gap='large'>
|
||||
@ -440,19 +314,13 @@ const FilamentStocks = () => {
|
||||
<Button>View</Button>
|
||||
</Popover>
|
||||
</Space>
|
||||
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
|
||||
</Flex>
|
||||
<Table
|
||||
dataSource={filamentStocksData}
|
||||
className={styles.customTable}
|
||||
|
||||
<DashboardTable
|
||||
ref={tableRef}
|
||||
columns={visibleColumns}
|
||||
pagination={false}
|
||||
rowKey='_id'
|
||||
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
|
||||
scroll={{ y: 'calc(100vh - 270px)' }}
|
||||
onScroll={handleScroll}
|
||||
onChange={handleTableChange}
|
||||
showSorterTooltip={false}
|
||||
url={`${config.backendUrl}/filamentstocks`}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
@ -463,13 +331,13 @@ const FilamentStocks = () => {
|
||||
onCancel={() => {
|
||||
setNewFilamentStockOpen(false)
|
||||
}}
|
||||
destroyOnClose
|
||||
destroyOnHidden={true}
|
||||
>
|
||||
<NewFilamentStock
|
||||
onOk={() => {
|
||||
setNewFilamentStockOpen(false)
|
||||
messageApi.success('New filament stock created successfully.')
|
||||
fetchFilamentStocksData()
|
||||
tableRef.current?.reload()
|
||||
}}
|
||||
reset={newFilamentStockOpen}
|
||||
/>
|
||||
|
||||
@ -10,7 +10,8 @@ import {
|
||||
Typography,
|
||||
Form,
|
||||
Badge,
|
||||
Collapse
|
||||
Collapse,
|
||||
Flex
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
||||
import IdText from '../../common/IdText'
|
||||
@ -132,6 +133,7 @@ const FilamentStockInfo = () => {
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
{contextHolder}
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
@ -244,7 +246,8 @@ const FilamentStockInfo = () => {
|
||||
{filamentStockData.startingNetWeight.toFixed(2) + 'g'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Gross'>
|
||||
{filamentStockData.startingGrossWeight.toFixed(2) + 'g'}
|
||||
{filamentStockData.startingGrossWeight.toFixed(2) +
|
||||
'g'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Space>
|
||||
@ -281,6 +284,7 @@ const FilamentStockInfo = () => {
|
||||
<StockEventTable stockEvents={filamentStockData.stockEvents} />
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Flex>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
Descriptions,
|
||||
Alert
|
||||
} from 'antd'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import PropTypes from 'prop-types'
|
||||
import { SocketContext } from '../../context/SocketContext'
|
||||
|
||||
@ -28,6 +29,8 @@ const LoadFilamentStock = ({
|
||||
printer = null,
|
||||
filamentStockLoaded = false
|
||||
}) => {
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
LoadFilamentStock.propTypes = {
|
||||
onOk: PropTypes.func.isRequired,
|
||||
reset: PropTypes.bool.isRequired,
|
||||
@ -266,6 +269,7 @@ const LoadFilamentStock = ({
|
||||
|
||||
return (
|
||||
<Flex gap={'middle'}>
|
||||
{!isMobile && (
|
||||
<div style={{ minWidth: '160px' }}>
|
||||
<Steps
|
||||
current={currentStep}
|
||||
@ -274,8 +278,9 @@ const LoadFilamentStock = ({
|
||||
style={{ width: 'fit-content' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider type={'vertical'} style={{ height: 'unset' }} />
|
||||
{!isMobile && <Divider type={'vertical'} style={{ height: 'unset' }} />}
|
||||
|
||||
<Flex vertical={'true'} style={{ flexGrow: 1 }} gap='middle'>
|
||||
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React, { useState, useContext, useEffect } from 'react'
|
||||
import { Form, Button, Typography, Flex, Steps, Divider, Alert } from 'antd'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import PropTypes from 'prop-types'
|
||||
import { SocketContext } from '../../context/SocketContext'
|
||||
|
||||
@ -18,6 +19,7 @@ const UnloadFilamentStock = ({ onOk, reset, printer = null }) => {
|
||||
}
|
||||
|
||||
const { socket } = useContext(SocketContext)
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
const initialUnloadFilamentStockForm = {
|
||||
printer: printer
|
||||
@ -194,6 +196,7 @@ const UnloadFilamentStock = ({ onOk, reset, printer = null }) => {
|
||||
|
||||
return (
|
||||
<Flex gap={'middle'}>
|
||||
{!isMobile && (
|
||||
<div style={{ minWidth: '160px' }}>
|
||||
<Steps
|
||||
current={currentStep}
|
||||
@ -202,8 +205,9 @@ const UnloadFilamentStock = ({ onOk, reset, printer = null }) => {
|
||||
style={{ width: 'fit-content' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider type={'vertical'} style={{ height: 'unset' }} />
|
||||
{!isMobile && <Divider type={'vertical'} style={{ height: 'unset' }} />}
|
||||
|
||||
<Flex vertical={'true'} style={{ flexGrow: 1 }} gap='middle'>
|
||||
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
|
||||
|
||||
@ -1,20 +1,8 @@
|
||||
// src/partStocks.js
|
||||
|
||||
import React, { useEffect, useState, useContext, useCallback } from 'react'
|
||||
import React, { useState, useContext, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Flex,
|
||||
Space,
|
||||
Modal,
|
||||
message,
|
||||
Dropdown,
|
||||
Typography
|
||||
} from 'antd'
|
||||
import { createStyles } from 'antd-style'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import { Button, Flex, Space, Modal, message, Dropdown, Typography } from 'antd'
|
||||
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
|
||||
@ -26,64 +14,21 @@ import PlusIcon from '../../Icons/PlusIcon'
|
||||
import ReloadIcon from '../../Icons/ReloadIcon'
|
||||
import PartStockState from '../common/PartStockState'
|
||||
import TimeDisplay from '../common/TimeDisplay'
|
||||
import DashboardTable from '../common/DashboardTable'
|
||||
|
||||
import config from '../../../config'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const useStyle = createStyles(({ css, token }) => {
|
||||
const { antCls } = token
|
||||
return {
|
||||
customTable: css`
|
||||
${antCls}-table {
|
||||
${antCls}-table-container {
|
||||
${antCls}-table-body,
|
||||
${antCls}-table-content {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #eaeaea transparent;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
})
|
||||
|
||||
const PartStocks = () => {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const navigate = useNavigate()
|
||||
const { styles } = useStyle()
|
||||
|
||||
const [partStocksData, setPartStocksData] = useState([])
|
||||
const tableRef = useRef()
|
||||
|
||||
const [newPartStockOpen, setNewPartStockOpen] = useState(false)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const { authenticated } = useContext(AuthContext)
|
||||
|
||||
const fetchPartStocksData = useCallback(async () => {
|
||||
try {
|
||||
const response = await axios.get(`${config.backendUrl}/partstocks`, {
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true // Important for including cookies
|
||||
})
|
||||
setPartStocksData(response.data)
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
messageApi.info(err)
|
||||
}
|
||||
}, [messageApi])
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch initial data
|
||||
if (authenticated) {
|
||||
fetchPartStocksData()
|
||||
}
|
||||
}, [authenticated, fetchPartStocksData])
|
||||
|
||||
const getPartStockActionItems = (id) => {
|
||||
return {
|
||||
items: [
|
||||
@ -123,7 +68,7 @@ const PartStocks = () => {
|
||||
title: 'ID',
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 165,
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'partstock'} longId={false} />
|
||||
},
|
||||
{
|
||||
@ -213,7 +158,7 @@ const PartStocks = () => {
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reloadList') {
|
||||
fetchPartStocksData()
|
||||
tableRef.current?.reload()
|
||||
} else if (key === 'newPartStock') {
|
||||
setNewPartStockOpen(true)
|
||||
}
|
||||
@ -229,14 +174,11 @@ const PartStocks = () => {
|
||||
<Button>Actions</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
<Table
|
||||
dataSource={partStocksData}
|
||||
className={styles.customTable}
|
||||
<DashboardTable
|
||||
ref={tableRef}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
rowKey='_id'
|
||||
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
|
||||
scroll={{ y: 'calc(100vh - 270px)' }}
|
||||
url={`${config.backendUrl}/partstocks`}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
@ -247,13 +189,13 @@ const PartStocks = () => {
|
||||
onCancel={() => {
|
||||
setNewPartStockOpen(false)
|
||||
}}
|
||||
destroyOnClose
|
||||
destroyOnHidden={true}
|
||||
>
|
||||
<NewPartStock
|
||||
onOk={() => {
|
||||
setNewPartStockOpen(false)
|
||||
messageApi.success('New part stock created successfully.')
|
||||
fetchPartStocksData()
|
||||
tableRef.current?.reload()
|
||||
}}
|
||||
reset={newPartStockOpen}
|
||||
/>
|
||||
|
||||
@ -1,10 +1,6 @@
|
||||
import React, { useEffect, useState, useContext, useCallback } from 'react'
|
||||
import React, { useState, useContext, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
|
||||
import { Table, Button, Flex, Space, message, Dropdown, Typography } from 'antd'
|
||||
import { createStyles } from 'antd-style'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import { Button, Flex, Space, message, Dropdown, Typography } from 'antd'
|
||||
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import { SocketContext } from '../context/SocketContext'
|
||||
@ -15,78 +11,29 @@ import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||
import PlusIcon from '../../Icons/PlusIcon'
|
||||
import ReloadIcon from '../../Icons/ReloadIcon'
|
||||
import TimeDisplay from '../common/TimeDisplay'
|
||||
import DashboardTable from '../common/DashboardTable'
|
||||
|
||||
import config from '../../../config'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const useStyle = createStyles(({ css, token }) => {
|
||||
const { antCls } = token
|
||||
return {
|
||||
customTable: css`
|
||||
${antCls}-table {
|
||||
${antCls}-table-container {
|
||||
${antCls}-table-body,
|
||||
${antCls}-table-content {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #eaeaea transparent;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
})
|
||||
|
||||
const StockAudits = () => {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const navigate = useNavigate()
|
||||
const { styles } = useStyle()
|
||||
const { socket } = useContext(SocketContext)
|
||||
|
||||
const [stockAuditsData, setStockAuditsData] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const tableRef = useRef()
|
||||
|
||||
const { authenticated } = useContext(AuthContext)
|
||||
|
||||
const fetchStockAuditsData = useCallback(async () => {
|
||||
try {
|
||||
const response = await axios.get(`${config.backendUrl}/stockaudits`, {
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
})
|
||||
setStockAuditsData(response.data)
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
messageApi.info(err)
|
||||
}
|
||||
}, [messageApi])
|
||||
|
||||
useEffect(() => {
|
||||
if (authenticated) {
|
||||
fetchStockAuditsData()
|
||||
}
|
||||
}, [authenticated, fetchStockAuditsData])
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (socket && !initialized) {
|
||||
setInitialized(true)
|
||||
socket.on('notify_stockaudit_update', (updateData) => {
|
||||
console.log('Received stock audit update:', updateData)
|
||||
setStockAuditsData((prevData) => {
|
||||
return prevData.map((audit) => {
|
||||
if (audit._id === updateData._id) {
|
||||
return {
|
||||
...audit,
|
||||
...updateData
|
||||
if (tableRef.current) {
|
||||
tableRef.current.updateData(updateData._id, updateData)
|
||||
}
|
||||
}
|
||||
return audit
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@ -128,7 +75,7 @@ const StockAudits = () => {
|
||||
title: 'ID',
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 165,
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'stockaudit'} longId={false} />
|
||||
},
|
||||
{
|
||||
@ -203,7 +150,7 @@ const StockAudits = () => {
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reloadList') {
|
||||
fetchStockAuditsData()
|
||||
tableRef.current?.reload()
|
||||
} else if (key === 'newStockAudit') {
|
||||
// TODO: Implement new stock audit creation
|
||||
messageApi.info('New stock audit creation not implemented yet')
|
||||
@ -220,14 +167,11 @@ const StockAudits = () => {
|
||||
<Button>Actions</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
<Table
|
||||
dataSource={stockAuditsData}
|
||||
className={styles.customTable}
|
||||
<DashboardTable
|
||||
ref={tableRef}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
rowKey='_id'
|
||||
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
|
||||
scroll={{ y: 'calc(100vh - 270px)' }}
|
||||
url={`${config.backendUrl}/stockaudits`}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
|
||||
@ -103,7 +103,7 @@ const StockAuditInfo = () => {
|
||||
title: 'Item ID',
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 165,
|
||||
width: 180,
|
||||
render: (text) => (
|
||||
<IdText id={text} type={'stockaudititem'} longId={false} />
|
||||
)
|
||||
|
||||
@ -1,21 +1,15 @@
|
||||
import React, { useEffect, useState, useContext, useCallback } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
import React, { useState, useContext, useRef } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Space,
|
||||
message,
|
||||
Spin,
|
||||
Popover,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
Table,
|
||||
Typography,
|
||||
Input
|
||||
} from 'antd'
|
||||
import { createStyles } from 'antd-style'
|
||||
import { LoadingOutlined, AuditOutlined } from '@ant-design/icons'
|
||||
import { AuditOutlined } from '@ant-design/icons'
|
||||
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import { SocketContext } from '../context/SocketContext'
|
||||
@ -28,63 +22,24 @@ import PlayCircleIcon from '../../Icons/PlayCircleIcon'
|
||||
import XMarkIcon from '../../Icons/XMarkIcon'
|
||||
import CheckIcon from '../../Icons/CheckIcon'
|
||||
import useColumnVisibility from '../hooks/useColumnVisibility'
|
||||
import DashboardTable from '../common/DashboardTable'
|
||||
|
||||
import config from '../../../config'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const useStyle = createStyles(({ css, token }) => {
|
||||
const { antCls } = token
|
||||
return {
|
||||
customTable: css`
|
||||
${antCls}-table {
|
||||
${antCls}-table-container {
|
||||
${antCls}-table-body,
|
||||
${antCls}-table-content {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #eaeaea transparent;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
})
|
||||
|
||||
const StockEvents = () => {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const { styles } = useStyle()
|
||||
const { socket } = useContext(SocketContext)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
|
||||
// Helper function to convert text to camelCase
|
||||
const toCamelCase = (text) => {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => {
|
||||
return index === 0 ? word.toLowerCase() : word.toUpperCase()
|
||||
})
|
||||
.replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
const [stockEventsData, setStockEventsData] = useState([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [lazyLoading, setLazyLoading] = useState(false)
|
||||
|
||||
const [filters, setFilters] = useState({})
|
||||
const [sorter, setSorter] = useState({
|
||||
field: 'createdAt',
|
||||
order: 'descend'
|
||||
})
|
||||
const tableRef = useRef()
|
||||
|
||||
// Column definitions for visibility
|
||||
const columns = [
|
||||
{
|
||||
title: '',
|
||||
key: 'icon',
|
||||
width: 50,
|
||||
width: 40,
|
||||
fixed: 'left',
|
||||
render: (record) => {
|
||||
switch (record.type.toLowerCase()) {
|
||||
case 'subjob':
|
||||
@ -103,6 +58,7 @@ const StockEvents = () => {
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 200,
|
||||
fixed: 'left',
|
||||
sorter: true,
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
@ -118,6 +74,15 @@ const StockEvents = () => {
|
||||
propertyName: 'type'
|
||||
})
|
||||
},
|
||||
{
|
||||
title: 'ID',
|
||||
key: 'id',
|
||||
dataIndex: '_id',
|
||||
width: 170,
|
||||
render: (id) => {
|
||||
return <IdText id={id} longId={false} type={'stockevent'} />
|
||||
}
|
||||
},
|
||||
{
|
||||
title: <PlusMinusIcon />,
|
||||
dataIndex: 'value',
|
||||
@ -134,26 +99,17 @@ const StockEvents = () => {
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Linked ID',
|
||||
key: 'linkedId',
|
||||
width: 100,
|
||||
title: 'Stock ID',
|
||||
key: 'stockId',
|
||||
width: 170,
|
||||
render: (record) => {
|
||||
if (record.subJob?.number) {
|
||||
if (record.filamentStock?._id) {
|
||||
return (
|
||||
<IdText
|
||||
id={record.subJob.number.toString().padStart(6, '0')}
|
||||
id={record.filamentStock._id}
|
||||
longId={false}
|
||||
type={'subjob'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (record.stockAudit) {
|
||||
return (
|
||||
<IdText
|
||||
id={record.stockAudit._id}
|
||||
longId={false}
|
||||
type={'stockaudit'}
|
||||
showHyperlink={true}
|
||||
type={'filamentstock'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -161,22 +117,42 @@ const StockEvents = () => {
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Job ID',
|
||||
key: 'jobId',
|
||||
width: 100,
|
||||
title: 'Linked IDs',
|
||||
key: 'linkedIds',
|
||||
width: 170 * 2,
|
||||
render: (record) => {
|
||||
if (record.subJob) {
|
||||
return (
|
||||
const ids = (
|
||||
<Space size={'middle'}>
|
||||
{record.job ? (
|
||||
<IdText
|
||||
id={record.job}
|
||||
longId={false}
|
||||
showHyperlink={true}
|
||||
type={'job'}
|
||||
/>
|
||||
) : null}
|
||||
{record.subJob?.number ? (
|
||||
<IdText
|
||||
id={record.subJob.number.toString().padStart(6, '0')}
|
||||
longId={false}
|
||||
type={'subjob'}
|
||||
/>
|
||||
) : null}
|
||||
{record.stockAudit ? (
|
||||
<IdText
|
||||
id={record.stockAudit._id}
|
||||
longId={false}
|
||||
type={'stockaudit'}
|
||||
showHyperlink={true}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
if (!record.stockAudit && !record.job && !record.subJob) {
|
||||
return 'n/a'
|
||||
}
|
||||
return ids
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Created At',
|
||||
@ -252,79 +228,15 @@ const StockEvents = () => {
|
||||
|
||||
const { authenticated } = useContext(AuthContext)
|
||||
|
||||
const fetchStockEventsData = useCallback(
|
||||
async (pageNum = 1, append = false) => {
|
||||
try {
|
||||
const response = await axios.get(`${config.backendUrl}/stockevents`, {
|
||||
params: {
|
||||
page: pageNum,
|
||||
limit: 25,
|
||||
type: filters.type,
|
||||
...filters,
|
||||
sort: sorter.field,
|
||||
order: sorter.order
|
||||
},
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
const newData = response.data
|
||||
setHasMore(newData.length === 25)
|
||||
|
||||
if (append) {
|
||||
setStockEventsData((prev) => [...prev, ...newData])
|
||||
} else {
|
||||
setStockEventsData(newData)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
setLazyLoading(false)
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
messageApi.error(
|
||||
'Error fetching stock events:',
|
||||
error.response.status
|
||||
)
|
||||
} else {
|
||||
messageApi.error(
|
||||
'An unexpected error occurred. Please try again later.'
|
||||
)
|
||||
}
|
||||
setLoading(false)
|
||||
setLazyLoading(false)
|
||||
}
|
||||
},
|
||||
[messageApi, filters, sorter]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (authenticated) {
|
||||
fetchStockEventsData()
|
||||
}
|
||||
}, [authenticated, fetchStockEventsData])
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
// Add WebSocket event listener for real-time updates
|
||||
if (socket && !initialized) {
|
||||
setInitialized(true)
|
||||
socket.on('notify_stockevent_update', (updateData) => {
|
||||
console.log('Received stock event update:', updateData)
|
||||
setStockEventsData((prevData) => {
|
||||
return prevData.map((stockEvent) => {
|
||||
if (stockEvent?._id) {
|
||||
if (stockEvent._id === updateData._id) {
|
||||
return {
|
||||
...stockEvent,
|
||||
...updateData
|
||||
if (tableRef.current) {
|
||||
tableRef.current.updateData(updateData._id, updateData)
|
||||
}
|
||||
} else {
|
||||
return stockEvent
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@ -336,27 +248,6 @@ const StockEvents = () => {
|
||||
}
|
||||
}, [socket, initialized])
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(e) => {
|
||||
const { target } = e
|
||||
const scrollHeight = target.scrollHeight
|
||||
const scrollTop = target.scrollTop
|
||||
const clientHeight = target.clientHeight
|
||||
|
||||
if (
|
||||
scrollHeight - scrollTop - clientHeight < 100 &&
|
||||
!lazyLoading &&
|
||||
hasMore
|
||||
) {
|
||||
setLazyLoading(true)
|
||||
const nextPage = page + 1
|
||||
setPage(nextPage)
|
||||
fetchStockEventsData(nextPage, true)
|
||||
}
|
||||
},
|
||||
[page, lazyLoading, hasMore, fetchStockEventsData]
|
||||
)
|
||||
|
||||
const actionItems = {
|
||||
items: [
|
||||
{
|
||||
@ -367,34 +258,11 @@ const StockEvents = () => {
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reloadList') {
|
||||
setPage(1)
|
||||
fetchStockEventsData(1)
|
||||
tableRef.current?.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
const newFilters = {}
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value && value.length > 0) {
|
||||
// Convert type filter to camelCase
|
||||
if (key === 'type') {
|
||||
newFilters[key] = toCamelCase(value[0])
|
||||
} else {
|
||||
newFilters[key] = value[0]
|
||||
}
|
||||
}
|
||||
})
|
||||
setPage(1)
|
||||
setFilters(newFilters)
|
||||
setSorter({
|
||||
field: sorter.field,
|
||||
order: sorter.order
|
||||
})
|
||||
// Trigger a new fetch with the updated filters
|
||||
fetchStockEventsData(1)
|
||||
}
|
||||
|
||||
const getViewDropdownItems = () => {
|
||||
const columnItems = columns
|
||||
.filter((col) => col.key && col.title !== '')
|
||||
@ -426,7 +294,6 @@ const StockEvents = () => {
|
||||
return (
|
||||
<>
|
||||
<Flex vertical={'true'} gap='large'>
|
||||
{contextHolder}
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Dropdown menu={actionItems}>
|
||||
@ -440,19 +307,13 @@ const StockEvents = () => {
|
||||
<Button>View</Button>
|
||||
</Popover>
|
||||
</Space>
|
||||
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
|
||||
</Flex>
|
||||
<Table
|
||||
dataSource={stockEventsData}
|
||||
|
||||
<DashboardTable
|
||||
ref={tableRef}
|
||||
columns={visibleColumns}
|
||||
className={styles.customTable}
|
||||
pagination={false}
|
||||
scroll={{ y: 'calc(100vh - 270px)' }}
|
||||
rowKey='_id'
|
||||
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
|
||||
onScroll={handleScroll}
|
||||
onChange={handleTableChange}
|
||||
showSorterTooltip={false}
|
||||
url={`${config.backendUrl}/stockevents`}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
|
||||
333
src/components/Dashboard/Management/AuditLogs.jsx
Normal 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
|
||||
@ -282,7 +282,7 @@ const Filaments = () => {
|
||||
title: 'ID',
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 165,
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'filament'} longId={false} />,
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
@ -493,7 +493,7 @@ const Filaments = () => {
|
||||
onCancel={() => {
|
||||
setNewFilamentOpen(false)
|
||||
}}
|
||||
destroyOnClose
|
||||
destroyOnHidden={true}
|
||||
>
|
||||
<NewFilament
|
||||
onOk={() => {
|
||||
|
||||
@ -34,7 +34,7 @@ import useCollapseState from '../../hooks/useCollapseState'
|
||||
|
||||
import config from '../../../../config.js'
|
||||
|
||||
const { Title, Link } = Typography
|
||||
const { Title, Link, Text } = Typography
|
||||
|
||||
const FilamentInfo = () => {
|
||||
const [filamentData, setFilamentData] = useState(null)
|
||||
@ -211,6 +211,8 @@ const FilamentInfo = () => {
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
{contextHolder}
|
||||
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
@ -304,6 +306,7 @@ const FilamentInfo = () => {
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Name'>
|
||||
<Flex align={'center'} gap={'small'}>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='name'
|
||||
@ -312,7 +315,10 @@ const FilamentInfo = () => {
|
||||
required: true,
|
||||
message: 'Please enter a filament name'
|
||||
},
|
||||
{ max: 100, message: 'Name cannot exceed 100 characters' }
|
||||
{
|
||||
max: 100,
|
||||
message: 'Name cannot exceed 100 characters'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
@ -321,6 +327,7 @@ const FilamentInfo = () => {
|
||||
) : (
|
||||
filamentData.name || 'n/a'
|
||||
)}
|
||||
</Flex>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Updated At'>
|
||||
@ -331,6 +338,7 @@ const FilamentInfo = () => {
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Vendor'>
|
||||
<Flex align={'center'} gap={'small'}>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='vendor'
|
||||
@ -341,9 +349,12 @@ const FilamentInfo = () => {
|
||||
>
|
||||
<VendorSelect />
|
||||
</Form.Item>
|
||||
) : filamentData.vendor.name ? (
|
||||
<Text>{filamentData.vendor.name}</Text>
|
||||
) : (
|
||||
filamentData.vendor.name || 'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Vendor ID'>
|
||||
@ -355,12 +366,16 @@ const FilamentInfo = () => {
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Material'>
|
||||
<Flex align={'center'} gap={'small'}>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='type'
|
||||
style={{ margin: 0 }}
|
||||
rules={[
|
||||
{ required: true, message: 'Please select a material' }
|
||||
{
|
||||
required: true,
|
||||
message: 'Please select a material'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
@ -375,14 +390,18 @@ const FilamentInfo = () => {
|
||||
) : (
|
||||
filamentData.type || 'n/a'
|
||||
)}
|
||||
</Flex>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Cost'>
|
||||
<Flex align={'center'} gap={'small'}>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='cost'
|
||||
style={{ margin: 0 }}
|
||||
rules={[{ required: true, message: 'Please enter a cost' }]}
|
||||
rules={[
|
||||
{ required: true, message: 'Please enter a cost' }
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
prefix='£'
|
||||
@ -395,9 +414,11 @@ const FilamentInfo = () => {
|
||||
) : (
|
||||
'n/a'
|
||||
)}
|
||||
</Flex>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Color'>
|
||||
<Flex align={'center'} gap={'small'}>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='color'
|
||||
@ -405,15 +426,23 @@ const FilamentInfo = () => {
|
||||
rules={[
|
||||
{ required: true, message: 'Please select a color' }
|
||||
]}
|
||||
getValueFromEvent={(color) => {
|
||||
return '#' + color.toHex()
|
||||
}}
|
||||
>
|
||||
<ColorPicker />
|
||||
<ColorPicker showText disabledAlpha />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Badge color={filamentData.color} text={filamentData.color} />
|
||||
<Badge
|
||||
color={filamentData.color}
|
||||
text={filamentData.color}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Diameter'>
|
||||
<Flex align={'center'} gap={'small'}>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='diameter'
|
||||
@ -429,9 +458,11 @@ const FilamentInfo = () => {
|
||||
) : (
|
||||
'n/a'
|
||||
)}
|
||||
</Flex>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Density'>
|
||||
<Flex align={'center'} gap={'small'}>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='density'
|
||||
@ -447,9 +478,11 @@ const FilamentInfo = () => {
|
||||
) : (
|
||||
'n/a'
|
||||
)}
|
||||
</Flex>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='URL'>
|
||||
<Flex align={'center'} gap={'small'}>
|
||||
{isEditing ? (
|
||||
<Form.Item name='url' style={{ margin: 0 }}>
|
||||
<Input placeholder='Enter URL' />
|
||||
@ -461,9 +494,11 @@ const FilamentInfo = () => {
|
||||
) : (
|
||||
'n/a'
|
||||
)}
|
||||
</Flex>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Barcode'>
|
||||
<Flex align={'center'} gap={'small'}>
|
||||
{isEditing ? (
|
||||
<Form.Item name='barcode' style={{ margin: 0 }}>
|
||||
<Input placeholder='Enter barcode' />
|
||||
@ -471,6 +506,7 @@ const FilamentInfo = () => {
|
||||
) : (
|
||||
filamentData.barcode || 'n/a'
|
||||
)}
|
||||
</Flex>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Form>
|
||||
@ -501,6 +537,7 @@ const FilamentInfo = () => {
|
||||
{/* Add any additional details sections here */}
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Flex>
|
||||
|
||||
<Modal
|
||||
title='Delete Filament'
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import React, { useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
@ -38,6 +39,7 @@ const initialNewFilamentForm = {
|
||||
|
||||
const NewFilament = ({ onOk, reset }) => {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
const [newFilamentLoading, setNewFilamentLoading] = useState(false)
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
@ -349,6 +351,7 @@ const NewFilament = ({ onOk, reset }) => {
|
||||
<Flex gap='middle'>
|
||||
{contextHolder}
|
||||
|
||||
{!isMobile && (
|
||||
<div style={{ minWidth: '160px' }}>
|
||||
<Steps
|
||||
current={currentStep}
|
||||
@ -357,8 +360,9 @@ const NewFilament = ({ onOk, reset }) => {
|
||||
style={{ width: 'fit-content' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider type='vertical' style={{ height: 'unset' }} />
|
||||
{!isMobile && <Divider type='vertical' style={{ height: 'unset' }} />}
|
||||
|
||||
<Flex vertical style={{ flexGrow: 1 }} gap='middle'>
|
||||
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
|
||||
|
||||
@ -168,7 +168,7 @@ const Materials = () => {
|
||||
title: 'ID',
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 165,
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'material'} longId={false} />
|
||||
},
|
||||
{
|
||||
|
||||
321
src/components/Dashboard/Management/NoteTypes.jsx
Normal 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
|
||||
259
src/components/Dashboard/Management/NoteTypes/NewNoteType.jsx
Normal 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
|
||||
402
src/components/Dashboard/Management/NoteTypes/NoteTypeInfo.jsx
Normal 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
|
||||
@ -1,28 +1,24 @@
|
||||
// src/gcodefiles.js
|
||||
|
||||
import React, { useEffect, useState, useContext, useCallback } from 'react'
|
||||
import React, { useState, useContext, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Flex,
|
||||
Space,
|
||||
Modal,
|
||||
Dropdown,
|
||||
message,
|
||||
Typography,
|
||||
Spin,
|
||||
Checkbox,
|
||||
Popover,
|
||||
Input
|
||||
Input,
|
||||
message
|
||||
} from 'antd'
|
||||
import { createStyles } from 'antd-style'
|
||||
import { LoadingOutlined, DownloadOutlined } from '@ant-design/icons'
|
||||
import { DownloadOutlined } from '@ant-design/icons'
|
||||
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import IdText from '../common/IdText'
|
||||
import DashboardTable from '../common/DashboardTable'
|
||||
import NewProduct from './Products/NewProduct'
|
||||
import PartIcon from '../../Icons/PartIcon'
|
||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||
@ -37,35 +33,12 @@ import config from '../../../config'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const useStyle = createStyles(({ css, token }) => {
|
||||
const { antCls } = token
|
||||
return {
|
||||
customTable: css`
|
||||
${antCls}-table {
|
||||
${antCls}-table-container {
|
||||
${antCls}-table-body,
|
||||
${antCls}-table-content {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #eaeaea transparent;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
})
|
||||
|
||||
const Parts = () => {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const navigate = useNavigate()
|
||||
const { styles } = useStyle()
|
||||
|
||||
const [partsData, setPartsData] = useState([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [lazyLoading, setLazyLoading] = useState(false)
|
||||
const [newProductOpen, setNewProductOpen] = useState(false)
|
||||
const tableRef = useRef()
|
||||
const { authenticated } = useContext(AuthContext)
|
||||
|
||||
// Column definitions
|
||||
const columns = [
|
||||
@ -104,7 +77,7 @@ const Parts = () => {
|
||||
title: 'ID',
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 165,
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'part'} longId={false} />
|
||||
},
|
||||
{
|
||||
@ -131,7 +104,7 @@ const Parts = () => {
|
||||
{
|
||||
title: 'Product ID',
|
||||
key: 'productId',
|
||||
width: 165,
|
||||
width: 180,
|
||||
render: (record) => (
|
||||
<IdText
|
||||
id={record.product._id}
|
||||
@ -153,8 +126,7 @@ const Parts = () => {
|
||||
return 'n/a'
|
||||
}
|
||||
},
|
||||
sorter: true,
|
||||
defaultSortOrder: 'descend'
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
title: 'Updated At',
|
||||
@ -168,8 +140,7 @@ const Parts = () => {
|
||||
return 'n/a'
|
||||
}
|
||||
},
|
||||
sorter: true,
|
||||
defaultSortOrder: 'descend'
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
@ -196,89 +167,11 @@ const Parts = () => {
|
||||
}
|
||||
]
|
||||
|
||||
const [filters, setFilters] = useState({})
|
||||
const [sorter, setSorter] = useState({})
|
||||
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
|
||||
'Parts',
|
||||
columns
|
||||
)
|
||||
|
||||
const { authenticated } = useContext(AuthContext)
|
||||
|
||||
const fetchPartsData = useCallback(
|
||||
async (pageNum = 1, append = false) => {
|
||||
try {
|
||||
const response = await axios.get(`${config.backendUrl}/parts`, {
|
||||
params: {
|
||||
page: pageNum,
|
||||
limit: 25,
|
||||
...filters,
|
||||
sort: sorter.field,
|
||||
order: sorter.order
|
||||
},
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
const newData = response.data
|
||||
setHasMore(newData.length === 25)
|
||||
|
||||
if (append) {
|
||||
setPartsData((prev) => [...prev, ...newData])
|
||||
} else {
|
||||
setPartsData(newData)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
setLazyLoading(false)
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
messageApi.error(
|
||||
'Error updating printer details:',
|
||||
error.response.status
|
||||
)
|
||||
} else {
|
||||
messageApi.error(
|
||||
'An unexpected error occurred. Please try again later.'
|
||||
)
|
||||
}
|
||||
setLoading(false)
|
||||
setLazyLoading(false)
|
||||
}
|
||||
},
|
||||
[messageApi, filters, sorter]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (authenticated) {
|
||||
fetchPartsData()
|
||||
}
|
||||
}, [authenticated, fetchPartsData])
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(e) => {
|
||||
const { target } = e
|
||||
const scrollHeight = target.scrollHeight
|
||||
const scrollTop = target.scrollTop
|
||||
const clientHeight = target.clientHeight
|
||||
|
||||
// If we're near the bottom (within 100px) and not currently loading
|
||||
if (
|
||||
scrollHeight - scrollTop - clientHeight < 100 &&
|
||||
!lazyLoading &&
|
||||
hasMore
|
||||
) {
|
||||
setLazyLoading(true)
|
||||
const nextPage = page + 1
|
||||
setPage(nextPage)
|
||||
fetchPartsData(nextPage, true)
|
||||
}
|
||||
},
|
||||
[page, lazyLoading, hasMore, fetchPartsData]
|
||||
)
|
||||
|
||||
const getPartActionItems = (id) => {
|
||||
return {
|
||||
items: [
|
||||
@ -353,29 +246,13 @@ const Parts = () => {
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reloadList') {
|
||||
setPage(1)
|
||||
fetchPartsData(1)
|
||||
tableRef.current?.reload()
|
||||
} else if (key === 'newProduct') {
|
||||
setNewProductOpen(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
const newFilters = {}
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value && value.length > 0) {
|
||||
newFilters[key] = value[0]
|
||||
}
|
||||
})
|
||||
setPage(1)
|
||||
setFilters(newFilters)
|
||||
setSorter({
|
||||
field: sorter.field,
|
||||
order: sorter.order
|
||||
})
|
||||
}
|
||||
|
||||
const getViewDropdownItems = () => {
|
||||
const columnItems = columns
|
||||
.filter((col) => col.key && col.title !== '')
|
||||
@ -421,19 +298,12 @@ const Parts = () => {
|
||||
<Button>View</Button>
|
||||
</Popover>
|
||||
</Space>
|
||||
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
|
||||
</Flex>
|
||||
<Table
|
||||
dataSource={partsData}
|
||||
<DashboardTable
|
||||
ref={tableRef}
|
||||
columns={visibleColumns}
|
||||
className={styles.customTable}
|
||||
pagination={false}
|
||||
scroll={{ y: 'calc(100vh - 270px)' }}
|
||||
rowKey='_id'
|
||||
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
|
||||
onScroll={handleScroll}
|
||||
onChange={handleTableChange}
|
||||
showSorterTooltip={false}
|
||||
url={`${config.backendUrl}/parts`}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
@ -443,13 +313,13 @@ const Parts = () => {
|
||||
onCancel={() => {
|
||||
setNewProductOpen(false)
|
||||
}}
|
||||
destroyOnClose
|
||||
destroyOnHidden={true}
|
||||
>
|
||||
<NewProduct
|
||||
onOk={() => {
|
||||
setNewProductOpen(false)
|
||||
setPage(1)
|
||||
fetchPartsData(1)
|
||||
messageApi.success('Product created successfully!')
|
||||
tableRef.current?.reload()
|
||||
}}
|
||||
reset={newProductOpen}
|
||||
/>
|
||||
|
||||
@ -208,7 +208,7 @@ const PartInfo = () => {
|
||||
console.error('Failed to update part information:', err)
|
||||
messageApi.error('Failed to update part information')
|
||||
} finally {
|
||||
fetchPartDetails()
|
||||
await fetchPartDetails()
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
@ -238,6 +238,7 @@ const PartInfo = () => {
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
{contextHolder}
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
@ -330,7 +331,10 @@ const PartInfo = () => {
|
||||
required: true,
|
||||
message: 'Please enter a product name'
|
||||
},
|
||||
{ max: 100, message: 'Name cannot exceed 100 characters' }
|
||||
{
|
||||
max: 100,
|
||||
message: 'Name cannot exceed 100 characters'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
@ -514,6 +518,7 @@ const PartInfo = () => {
|
||||
</Card>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Flex>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,29 +1,25 @@
|
||||
// src/gcodefiles.js
|
||||
|
||||
import React, { useEffect, useState, useContext, useCallback } from 'react'
|
||||
import React, { useState, useContext, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Flex,
|
||||
Space,
|
||||
Modal,
|
||||
Dropdown,
|
||||
message,
|
||||
Spin,
|
||||
Tag,
|
||||
Checkbox,
|
||||
Popover,
|
||||
Input
|
||||
} from 'antd'
|
||||
import { createStyles } from 'antd-style'
|
||||
import { LoadingOutlined, DownloadOutlined } from '@ant-design/icons'
|
||||
import { DownloadOutlined } from '@ant-design/icons'
|
||||
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
|
||||
import IdText from '../common/IdText'
|
||||
import TimeDisplay from '../common/TimeDisplay'
|
||||
import DashboardTable from '../common/DashboardTable'
|
||||
import NewProduct from './Products/NewProduct'
|
||||
import ProductIcon from '../../Icons/ProductIcon'
|
||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||
@ -31,120 +27,17 @@ import PlusIcon from '../../Icons/PlusIcon'
|
||||
import ReloadIcon from '../../Icons/ReloadIcon'
|
||||
import XMarkIcon from '../../Icons/XMarkIcon'
|
||||
import CheckIcon from '../../Icons/CheckIcon'
|
||||
|
||||
import useColumnVisibility from '../hooks/useColumnVisibility'
|
||||
|
||||
import config from '../../../config'
|
||||
|
||||
const useStyle = createStyles(({ css, token }) => {
|
||||
const { antCls } = token
|
||||
return {
|
||||
customTable: css`
|
||||
${antCls}-table {
|
||||
${antCls}-table-container {
|
||||
${antCls}-table-body,
|
||||
${antCls}-table-content {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #eaeaea transparent;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
})
|
||||
|
||||
const Products = () => {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const navigate = useNavigate()
|
||||
const { styles } = useStyle()
|
||||
|
||||
const [productsData, setProductsData] = useState([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [lazyLoading, setLazyLoading] = useState(false)
|
||||
const [filters, setFilters] = useState({})
|
||||
const [sorter, setSorter] = useState({})
|
||||
|
||||
const [newProductOpen, setNewProductOpen] = useState(false)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const tableRef = useRef()
|
||||
const { authenticated } = useContext(AuthContext)
|
||||
|
||||
const fetchProductsData = useCallback(
|
||||
async (pageNum = 1, append = false) => {
|
||||
try {
|
||||
const response = await axios.get(`${config.backendUrl}/products`, {
|
||||
params: {
|
||||
page: pageNum,
|
||||
limit: 25,
|
||||
...filters,
|
||||
sort: sorter.field,
|
||||
order: sorter.order
|
||||
},
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
const newData = response.data
|
||||
setHasMore(newData.length === 25)
|
||||
|
||||
if (append) {
|
||||
setProductsData((prev) => [...prev, ...newData])
|
||||
} else {
|
||||
setProductsData(newData)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
setLazyLoading(false)
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
messageApi.error(
|
||||
'Error updating printer details:',
|
||||
error.response.status
|
||||
)
|
||||
} else {
|
||||
messageApi.error(
|
||||
'An unexpected error occurred. Please try again later.'
|
||||
)
|
||||
}
|
||||
setLoading(false)
|
||||
setLazyLoading(false)
|
||||
}
|
||||
},
|
||||
[messageApi, filters, sorter]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (authenticated) {
|
||||
fetchProductsData()
|
||||
}
|
||||
}, [authenticated, fetchProductsData])
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(e) => {
|
||||
const { target } = e
|
||||
const scrollHeight = target.scrollHeight
|
||||
const scrollTop = target.scrollTop
|
||||
const clientHeight = target.clientHeight
|
||||
|
||||
if (
|
||||
scrollHeight - scrollTop - clientHeight < 100 &&
|
||||
!lazyLoading &&
|
||||
hasMore
|
||||
) {
|
||||
setLazyLoading(true)
|
||||
const nextPage = page + 1
|
||||
setPage(nextPage)
|
||||
fetchProductsData(nextPage, true)
|
||||
}
|
||||
},
|
||||
[page, lazyLoading, hasMore, fetchProductsData]
|
||||
)
|
||||
|
||||
const getProductActionItems = (id) => {
|
||||
return {
|
||||
items: [
|
||||
@ -205,7 +98,7 @@ const Products = () => {
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
fixed: 'left',
|
||||
width: 165,
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'product'} longId={false} />,
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
@ -360,7 +253,7 @@ const Products = () => {
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reloadList') {
|
||||
fetchProductsData()
|
||||
tableRef.current?.reload()
|
||||
} else if (key === 'newProduct') {
|
||||
setNewProductOpen(true)
|
||||
}
|
||||
@ -427,21 +320,6 @@ const Products = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
const newFilters = {}
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value && value.length > 0) {
|
||||
newFilters[key] = value[0]
|
||||
}
|
||||
})
|
||||
setPage(1)
|
||||
setFilters(newFilters)
|
||||
setSorter({
|
||||
field: sorter.field,
|
||||
order: sorter.order
|
||||
})
|
||||
}
|
||||
|
||||
const visibleColumns = columns.filter(
|
||||
(col) => !col.key || columnVisibility[col.key]
|
||||
)
|
||||
@ -463,19 +341,12 @@ const Products = () => {
|
||||
<Button>View</Button>
|
||||
</Popover>
|
||||
</Space>
|
||||
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
|
||||
</Flex>
|
||||
<Table
|
||||
dataSource={productsData}
|
||||
<DashboardTable
|
||||
ref={tableRef}
|
||||
columns={visibleColumns}
|
||||
className={styles.customTable}
|
||||
pagination={false}
|
||||
scroll={{ y: 'calc(100vh - 270px)' }}
|
||||
rowKey='_id'
|
||||
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
|
||||
onScroll={handleScroll}
|
||||
onChange={handleTableChange}
|
||||
showSorterTooltip={false}
|
||||
url={`${config.backendUrl}/products`}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
@ -485,13 +356,13 @@ const Products = () => {
|
||||
onCancel={() => {
|
||||
setNewProductOpen(false)
|
||||
}}
|
||||
destroyOnClose
|
||||
destroyOnHidden={true}
|
||||
>
|
||||
<NewProduct
|
||||
onOk={() => {
|
||||
setNewProductOpen(false)
|
||||
messageApi.success('Product created successfully!')
|
||||
fetchProductsData()
|
||||
tableRef.current?.reload()
|
||||
}}
|
||||
reset={newProductOpen}
|
||||
/>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import React, { useState, useContext, useEffect, useRef } from 'react'
|
||||
import axios from 'axios'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import {
|
||||
Input,
|
||||
Button,
|
||||
@ -65,6 +66,8 @@ const NewProduct = ({ onOk, reset }) => {
|
||||
|
||||
const { token, authenticated } = useContext(AuthContext)
|
||||
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
useEffect(() => {
|
||||
newProductForm
|
||||
.validateFields({
|
||||
@ -393,6 +396,7 @@ const NewProduct = ({ onOk, reset }) => {
|
||||
<Flex gap='middle'>
|
||||
{contextHolder}
|
||||
|
||||
{!isMobile && (
|
||||
<div style={{ minWidth: '160px' }}>
|
||||
<Steps
|
||||
current={currentStep}
|
||||
@ -401,8 +405,9 @@ const NewProduct = ({ onOk, reset }) => {
|
||||
style={{ width: 'fit-content' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider type='vertical' style={{ height: 'unset' }} />
|
||||
{!isMobile && <Divider type='vertical' style={{ height: 'unset' }} />}
|
||||
|
||||
<Flex vertical gap='middle' style={{ flexGrow: 1 }}>
|
||||
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
|
||||
|
||||
@ -72,6 +72,7 @@ const ProductInfo = () => {
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
console.log('hello')
|
||||
await fetchProductDetails()
|
||||
}
|
||||
if (productId) {
|
||||
@ -114,7 +115,6 @@ const ProductInfo = () => {
|
||||
console.log(err)
|
||||
messageApi.error('Failed to fetch product details')
|
||||
} finally {
|
||||
fetchProductDetails()
|
||||
setFetchLoading(false)
|
||||
}
|
||||
}
|
||||
@ -164,6 +164,7 @@ const ProductInfo = () => {
|
||||
console.error('Failed to update product information:', err)
|
||||
messageApi.error('Failed to update product information')
|
||||
} finally {
|
||||
await fetchProductDetails()
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
@ -193,6 +194,7 @@ const ProductInfo = () => {
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
{contextHolder}
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
@ -290,7 +292,10 @@ const ProductInfo = () => {
|
||||
required: true,
|
||||
message: 'Please enter a product name'
|
||||
},
|
||||
{ max: 100, message: 'Name cannot exceed 100 characters' }
|
||||
{
|
||||
max: 100,
|
||||
message: 'Name cannot exceed 100 characters'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
@ -472,6 +477,7 @@ const ProductInfo = () => {
|
||||
<PartsTable data={productData.parts} />
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Flex>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -50,17 +50,21 @@ const Settings = () => {
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
activeKey={collapseState.appearance ? ['1'] : []}
|
||||
onChange={(keys) => updateCollapseState('appearance', keys.length > 0)}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('appearance', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '9px' }}
|
||||
/>
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
@ -111,6 +115,7 @@ const Settings = () => {
|
||||
</Descriptions>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Flex>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import React, { useState, useContext, useCallback, useEffect } from 'react'
|
||||
import React, { useState, useContext, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Flex,
|
||||
Space,
|
||||
@ -12,16 +10,15 @@ import {
|
||||
Typography,
|
||||
Checkbox,
|
||||
Popover,
|
||||
Input,
|
||||
Spin
|
||||
Input
|
||||
} from 'antd'
|
||||
import { createStyles } from 'antd-style'
|
||||
import { LoadingOutlined, ExportOutlined } from '@ant-design/icons'
|
||||
import { ExportOutlined } from '@ant-design/icons'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import IdText from '../common/IdText'
|
||||
import NewVendor from './Vendors/NewVendor'
|
||||
import CountryDisplay from '../common/CountryDisplay'
|
||||
import TimeDisplay from '../common/TimeDisplay'
|
||||
import DashboardTable from '../common/DashboardTable'
|
||||
import VendorIcon from '../../Icons/VendorIcon'
|
||||
import PlusIcon from '../../Icons/PlusIcon'
|
||||
import ReloadIcon from '../../Icons/ReloadIcon'
|
||||
@ -34,103 +31,13 @@ import config from '../../../config'
|
||||
|
||||
const { Link } = Typography
|
||||
|
||||
const useStyle = createStyles(({ css, token }) => {
|
||||
const { antCls } = token
|
||||
return {
|
||||
customTable: css`
|
||||
${antCls}-table {
|
||||
${antCls}-table-container {
|
||||
${antCls}-table-body,
|
||||
${antCls}-table-content {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #eaeaea transparent;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
})
|
||||
|
||||
const Vendors = () => {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const navigate = useNavigate()
|
||||
const { styles } = useStyle()
|
||||
const [vendorsData, setVendorsData] = useState([])
|
||||
const [newVendorOpen, setNewVendorOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [lazyLoading, setLazyLoading] = useState(false)
|
||||
const [filters, setFilters] = useState({})
|
||||
const [sorter, setSorter] = useState({})
|
||||
|
||||
const tableRef = useRef()
|
||||
const { authenticated } = useContext(AuthContext)
|
||||
|
||||
const fetchVendorsData = useCallback(
|
||||
async (pageNum = 1, append = false) => {
|
||||
try {
|
||||
const response = await axios.get(`${config.backendUrl}/vendors`, {
|
||||
params: {
|
||||
page: pageNum,
|
||||
limit: 25,
|
||||
...filters,
|
||||
sort: sorter.field,
|
||||
order: sorter.order
|
||||
},
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
const newData = response.data
|
||||
setHasMore(newData.length === 25)
|
||||
|
||||
if (append) {
|
||||
setVendorsData((prev) => [...prev, ...newData])
|
||||
} else {
|
||||
setVendorsData(newData)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
setLazyLoading(false)
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
messageApi.error('Error fetching vendor data:', error.response.status)
|
||||
} else {
|
||||
messageApi.error(
|
||||
'An unexpected error occurred. Please try again later.'
|
||||
)
|
||||
}
|
||||
setLoading(false)
|
||||
setLazyLoading(false)
|
||||
}
|
||||
},
|
||||
[messageApi, filters, sorter]
|
||||
)
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(e) => {
|
||||
const { target } = e
|
||||
const scrollHeight = target.scrollHeight
|
||||
const scrollTop = target.scrollTop
|
||||
const clientHeight = target.clientHeight
|
||||
|
||||
if (
|
||||
scrollHeight - scrollTop - clientHeight < 100 &&
|
||||
!lazyLoading &&
|
||||
hasMore
|
||||
) {
|
||||
setLazyLoading(true)
|
||||
const nextPage = page + 1
|
||||
setPage(nextPage)
|
||||
fetchVendorsData(nextPage, true)
|
||||
}
|
||||
},
|
||||
[page, lazyLoading, hasMore, fetchVendorsData]
|
||||
)
|
||||
|
||||
const getFilterDropdown = ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
@ -191,21 +98,6 @@ const Vendors = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
const newFilters = {}
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value && value.length > 0) {
|
||||
newFilters[key] = value[0]
|
||||
}
|
||||
})
|
||||
setPage(1)
|
||||
setFilters(newFilters)
|
||||
setSorter({
|
||||
field: sorter.field,
|
||||
order: sorter.order
|
||||
})
|
||||
}
|
||||
|
||||
const getVendorActionItems = (id) => {
|
||||
return {
|
||||
items: [
|
||||
@ -259,7 +151,7 @@ const Vendors = () => {
|
||||
title: 'ID',
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 165,
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'vendor'} longId={false} />,
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
@ -435,19 +327,13 @@ const Vendors = () => {
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reloadList') {
|
||||
fetchVendorsData()
|
||||
tableRef.current?.reload()
|
||||
} else if (key === 'newVendor') {
|
||||
setNewVendorOpen(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (authenticated) {
|
||||
fetchVendorsData()
|
||||
}
|
||||
}, [authenticated, fetchVendorsData])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex vertical={'true'} gap='large'>
|
||||
@ -465,33 +351,26 @@ const Vendors = () => {
|
||||
<Button>View</Button>
|
||||
</Popover>
|
||||
</Space>
|
||||
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
|
||||
</Flex>
|
||||
<Table
|
||||
dataSource={vendorsData}
|
||||
<DashboardTable
|
||||
ref={tableRef}
|
||||
columns={visibleColumns}
|
||||
className={styles.customTable}
|
||||
pagination={false}
|
||||
scroll={{ y: 'calc(100vh - 270px)' }}
|
||||
rowKey='_id'
|
||||
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
|
||||
onScroll={handleScroll}
|
||||
onChange={handleTableChange}
|
||||
showSorterTooltip={false}
|
||||
url={`${config.backendUrl}/vendors`}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
open={newVendorOpen}
|
||||
onCancel={() => setNewVendorOpen(false)}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
destroyOnHidden={true}
|
||||
width={700}
|
||||
>
|
||||
<NewVendor
|
||||
onOk={() => {
|
||||
setNewVendorOpen(false)
|
||||
messageApi.success('New vendor created successfully.')
|
||||
fetchVendorsData()
|
||||
tableRef.current?.reload()
|
||||
}}
|
||||
reset={!newVendorOpen}
|
||||
/>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import React, { useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
@ -37,6 +38,7 @@ const NewVendor = ({ onOk, reset }) => {
|
||||
const [newVendorForm] = Form.useForm()
|
||||
const [newVendorFormValues, setNewVendorFormValues] =
|
||||
useState(initialNewVendorForm)
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
const newVendorFormUpdateValues = Form.useWatch([], newVendorForm)
|
||||
|
||||
@ -181,6 +183,7 @@ const NewVendor = ({ onOk, reset }) => {
|
||||
<Flex gap='middle'>
|
||||
{contextHolder}
|
||||
|
||||
{!isMobile && (
|
||||
<div style={{ minWidth: '160px' }}>
|
||||
<Steps
|
||||
current={currentStep}
|
||||
@ -189,8 +192,9 @@ const NewVendor = ({ onOk, reset }) => {
|
||||
style={{ width: 'fit-content' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider type='vertical' style={{ height: 'unset' }} />
|
||||
{!isMobile && <Divider type='vertical' style={{ height: 'unset' }} />}
|
||||
|
||||
<Flex vertical gap='middle' style={{ flexGrow: 1 }}>
|
||||
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
|
||||
|
||||
@ -156,6 +156,7 @@ const VendorInfo = () => {
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
{contextHolder}
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
@ -218,7 +219,10 @@ const VendorInfo = () => {
|
||||
<IdText id={vendorData._id} type='vendor' />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Created At'>
|
||||
<TimeDisplay dateTime={vendorData.createdAt} showSince={true} />
|
||||
<TimeDisplay
|
||||
dateTime={vendorData.createdAt}
|
||||
showSince={true}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Name'>
|
||||
@ -226,8 +230,14 @@ const VendorInfo = () => {
|
||||
<Form.Item
|
||||
name='name'
|
||||
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'
|
||||
},
|
||||
{
|
||||
max: 100,
|
||||
message: 'Name cannot exceed 100 characters'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
@ -239,7 +249,10 @@ const VendorInfo = () => {
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Updated At'>
|
||||
<TimeDisplay dateTime={vendorData.updatedAt} showSince={true} />
|
||||
<TimeDisplay
|
||||
dateTime={vendorData.updatedAt}
|
||||
showSince={true}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Website'>
|
||||
@ -352,6 +365,7 @@ const VendorInfo = () => {
|
||||
</Form>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Flex>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
// src/gcodefiles.js
|
||||
|
||||
import React, { useEffect, useState, useContext, useCallback } from 'react'
|
||||
import React, { useState, useContext, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Table,
|
||||
Badge,
|
||||
Button,
|
||||
Flex,
|
||||
@ -16,11 +15,9 @@ import {
|
||||
Checkbox,
|
||||
Divider,
|
||||
Popover,
|
||||
Input,
|
||||
Spin
|
||||
Input
|
||||
} from 'antd'
|
||||
import { createStyles } from 'antd-style'
|
||||
import { LoadingOutlined, DownloadOutlined } from '@ant-design/icons'
|
||||
import { DownloadOutlined } from '@ant-design/icons'
|
||||
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import NewGCodeFile from './GCodeFiles/NewGCodeFile'
|
||||
@ -33,33 +30,18 @@ import ReloadIcon from '../../Icons/ReloadIcon'
|
||||
import XMarkIcon from '../../Icons/XMarkIcon'
|
||||
import CheckIcon from '../../Icons/CheckIcon'
|
||||
import TimeDisplay from '../common/TimeDisplay'
|
||||
import DashboardTable from '../common/DashboardTable'
|
||||
|
||||
import config from '../../../config'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const useStyle = createStyles(({ css, token }) => {
|
||||
const { antCls } = token
|
||||
return {
|
||||
customTable: css`
|
||||
${antCls}-table {
|
||||
${antCls}-table-container {
|
||||
${antCls}-table-body,
|
||||
${antCls}-table-content {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #eaeaea transparent;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
})
|
||||
|
||||
const GCodeFiles = () => {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const navigate = useNavigate()
|
||||
const { styles } = useStyle()
|
||||
const [newGCodeFileOpen, setNewGCodeFileOpen] = useState(false)
|
||||
const [showDeleted, setShowDeleted] = useState(false)
|
||||
const tableRef = useRef()
|
||||
|
||||
const getFilterDropdown = ({
|
||||
setSelectedKeys,
|
||||
@ -96,6 +78,7 @@ const GCodeFiles = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Column definitions
|
||||
const columns = [
|
||||
{
|
||||
@ -133,8 +116,8 @@ const GCodeFiles = () => {
|
||||
title: 'ID',
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 165,
|
||||
render: (text) => <IdText id={text} type={'gcodeFile'} longId={false} />
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'gcodefile'} longId={false} />
|
||||
},
|
||||
{
|
||||
title: 'Filament',
|
||||
@ -234,15 +217,7 @@ const GCodeFiles = () => {
|
||||
}
|
||||
}
|
||||
]
|
||||
const [gcodeFilesData, setGCodeFilesData] = useState([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [lazyLoading, setLazyLoading] = useState(false)
|
||||
const [newGCodeFileOpen, setNewGCodeFileOpen] = useState(false)
|
||||
const [showDeleted, setShowDeleted] = useState(false)
|
||||
const [filters, setFilters] = useState({})
|
||||
const [sorter, setSorter] = useState({})
|
||||
|
||||
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
|
||||
'GCodeFiles',
|
||||
columns
|
||||
@ -250,94 +225,6 @@ const GCodeFiles = () => {
|
||||
|
||||
const { authenticated } = useContext(AuthContext)
|
||||
|
||||
const fetchGCodeFilesData = useCallback(
|
||||
async (pageNum = 1, append = false) => {
|
||||
try {
|
||||
const params = {
|
||||
page: pageNum,
|
||||
limit: 25,
|
||||
...filters,
|
||||
sort: sorter.field,
|
||||
order: sorter.order
|
||||
}
|
||||
|
||||
const response = await axios.get(`${config.backendUrl}/gcodefiles`, {
|
||||
params,
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
const newData = response.data
|
||||
setHasMore(newData.length === 25) // If we get less than 25 items, we've reached the end
|
||||
|
||||
if (append) {
|
||||
setGCodeFilesData((prev) => [...prev, ...newData])
|
||||
} else {
|
||||
setGCodeFilesData(newData)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
setLazyLoading(false)
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
messageApi.error('Error fetching gcode files:', error.response.status)
|
||||
} else {
|
||||
messageApi.error(
|
||||
'An unexpected error occurred. Please try again later.'
|
||||
)
|
||||
}
|
||||
setLoading(false)
|
||||
setLazyLoading(false)
|
||||
}
|
||||
},
|
||||
[messageApi, filters, sorter]
|
||||
)
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(e) => {
|
||||
const { target } = e
|
||||
const scrollHeight = target.scrollHeight
|
||||
const scrollTop = target.scrollTop
|
||||
const clientHeight = target.clientHeight
|
||||
|
||||
// If we're near the bottom (within 100px) and not currently loading
|
||||
if (
|
||||
scrollHeight - scrollTop - clientHeight < 100 &&
|
||||
!lazyLoading &&
|
||||
hasMore
|
||||
) {
|
||||
setLazyLoading(true)
|
||||
const nextPage = page + 1
|
||||
setPage(nextPage)
|
||||
fetchGCodeFilesData(nextPage, true)
|
||||
}
|
||||
},
|
||||
[page, lazyLoading, hasMore, fetchGCodeFilesData]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (authenticated) {
|
||||
fetchGCodeFilesData()
|
||||
}
|
||||
}, [authenticated, fetchGCodeFilesData])
|
||||
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
const newFilters = {}
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value && value.length > 0) {
|
||||
newFilters[key] = value[0] // Take the first filter value
|
||||
}
|
||||
})
|
||||
|
||||
setFilters(newFilters)
|
||||
setSorter({
|
||||
field: sorter.field,
|
||||
order: sorter.order
|
||||
})
|
||||
}
|
||||
|
||||
const getGCodeFileActionItems = (id) => {
|
||||
return {
|
||||
items: [
|
||||
@ -358,7 +245,8 @@ const GCodeFiles = () => {
|
||||
} else if (key === 'download') {
|
||||
handleDownloadGCode(
|
||||
id,
|
||||
gcodeFilesData.find((file) => file._id === id)?.name + '.gcode'
|
||||
tableRef.current?.getData().find((file) => file._id === id)?.name +
|
||||
'.gcode'
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -376,24 +264,16 @@ const GCodeFiles = () => {
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true // Important for including cookies
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
setLoading(false)
|
||||
|
||||
const fileURL = window.URL.createObjectURL(new Blob([response.data]))
|
||||
// Create an anchor element and simulate a click to download the file
|
||||
const fileLink = document.createElement('a')
|
||||
fileLink.href = fileURL
|
||||
|
||||
fileLink.setAttribute('download', fileName)
|
||||
document.body.appendChild(fileLink)
|
||||
|
||||
// Simulate click to download the file
|
||||
fileLink.click()
|
||||
|
||||
// Clean up and remove the anchor element
|
||||
fileLink.parentNode.removeChild(fileLink)
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
@ -425,7 +305,7 @@ const GCodeFiles = () => {
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reloadList') {
|
||||
fetchGCodeFilesData()
|
||||
tableRef.current?.reload()
|
||||
} else if (key === 'newGCodeFile') {
|
||||
setNewGCodeFileOpen(true)
|
||||
}
|
||||
@ -484,19 +364,13 @@ const GCodeFiles = () => {
|
||||
>
|
||||
<Button>View</Button>
|
||||
</Popover>
|
||||
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
|
||||
</Space>
|
||||
<Table
|
||||
dataSource={gcodeFilesData}
|
||||
|
||||
<DashboardTable
|
||||
ref={tableRef}
|
||||
columns={visibleColumns}
|
||||
className={styles.customTable}
|
||||
pagination={false}
|
||||
scroll={{ y: 'calc(100vh - 270px)' }}
|
||||
rowKey='_id'
|
||||
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
|
||||
onChange={handleTableChange}
|
||||
onScroll={handleScroll}
|
||||
showSorterTooltip={false}
|
||||
url={`${config.backendUrl}/gcodefiles`}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
@ -506,13 +380,13 @@ const GCodeFiles = () => {
|
||||
onCancel={() => {
|
||||
setNewGCodeFileOpen(false)
|
||||
}}
|
||||
destroyOnClose
|
||||
destroyOnHidden={true}
|
||||
>
|
||||
<NewGCodeFile
|
||||
onOk={() => {
|
||||
setNewGCodeFileOpen(false)
|
||||
messageApi.success('Finished uploading GCode file!')
|
||||
fetchGCodeFilesData()
|
||||
tableRef.current?.reload()
|
||||
}}
|
||||
reset={newGCodeFileOpen}
|
||||
/>
|
||||
|
||||
@ -155,6 +155,7 @@ const GCodeFileInfo = () => {
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
{contextHolder}
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
@ -232,8 +233,14 @@ const GCodeFileInfo = () => {
|
||||
<Form.Item
|
||||
name='name'
|
||||
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'
|
||||
},
|
||||
{
|
||||
max: 100,
|
||||
message: 'Name cannot exceed 100 characters'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
@ -285,8 +292,8 @@ const GCodeFileInfo = () => {
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Est Print Time'>
|
||||
{gcodeFileData.gcodeFileInfo.estimatedPrintingTimeNormalMode ||
|
||||
'n/a'}
|
||||
{gcodeFileData.gcodeFileInfo
|
||||
.estimatedPrintingTimeNormalMode || 'n/a'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Cost'>
|
||||
{'£' + gcodeFileData.cost.toFixed(2) || 'n/a'}
|
||||
@ -404,6 +411,7 @@ const GCodeFileInfo = () => {
|
||||
</Card>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Flex>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import React, { useState, useContext, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import {
|
||||
capitalizeFirstLetter,
|
||||
timeStringToMinutes
|
||||
@ -48,6 +49,7 @@ const initialNewGCodeFileForm = {
|
||||
|
||||
const NewGCodeFile = ({ onOk, reset }) => {
|
||||
const [messageApi] = message.useMessage()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
const [newGCodeFileLoading, setNewGCodeFileLoading] = useState(false)
|
||||
const [gcodeParsing, setGcodeParsing] = useState(false)
|
||||
@ -470,6 +472,7 @@ const NewGCodeFile = ({ onOk, reset }) => {
|
||||
|
||||
return (
|
||||
<Flex gap={'middle'}>
|
||||
{!isMobile && (
|
||||
<div style={{ minWidth: '160px' }}>
|
||||
<Steps
|
||||
current={currentStep}
|
||||
@ -478,8 +481,9 @@ const NewGCodeFile = ({ onOk, reset }) => {
|
||||
style={{ width: 'fit-content' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider type={'vertical'} style={{ height: 'unset' }} />
|
||||
{!isMobile && <Divider type={'vertical'} style={{ height: 'unset' }} />}
|
||||
|
||||
<Flex vertical={'true'} style={{ flexGrow: 1 }} gap='middle'>
|
||||
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
// src/PrintJobs.js
|
||||
// src/Jobs.js
|
||||
|
||||
import React, { useEffect, useState, useCallback, useContext } from 'react'
|
||||
import React, { useState, useContext, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Flex,
|
||||
Space,
|
||||
@ -15,71 +13,42 @@ import {
|
||||
Input,
|
||||
Typography,
|
||||
Checkbox,
|
||||
Popover,
|
||||
Spin
|
||||
Popover
|
||||
} from 'antd'
|
||||
import { createStyles } from 'antd-style'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
|
||||
import { AuthContext } from '../context/AuthContext.js'
|
||||
import { SocketContext } from '../context/SocketContext'
|
||||
import NewPrintJob from './PrintJobs/NewPrintJob'
|
||||
import JobState from '../common/JobState'
|
||||
import SubJobCounter from '../common/SubJobCounter'
|
||||
import TimeDisplay from '../common/TimeDisplay'
|
||||
import IdText from '../common/IdText'
|
||||
import useColumnVisibility from '../hooks/useColumnVisibility'
|
||||
import JobIcon from '../../Icons/JobIcon'
|
||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||
import PlusIcon from '../../Icons/PlusIcon'
|
||||
import ReloadIcon from '../../Icons/ReloadIcon'
|
||||
import { SocketContext } from '../context/SocketContext.js'
|
||||
import NewJob from './Jobs/NewJob.jsx'
|
||||
import JobState from '../common/JobState.jsx'
|
||||
import SubJobCounter from '../common/SubJobCounter.jsx'
|
||||
import TimeDisplay from '../common/TimeDisplay.jsx'
|
||||
import IdText from '../common/IdText.jsx'
|
||||
import useColumnVisibility from '../hooks/useColumnVisibility.js'
|
||||
import JobIcon from '../../Icons/JobIcon.jsx'
|
||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon.jsx'
|
||||
import PlusIcon from '../../Icons/PlusIcon.jsx'
|
||||
import ReloadIcon from '../../Icons/ReloadIcon.jsx'
|
||||
import EditIcon from '../../Icons/EditIcon.jsx'
|
||||
import XMarkIcon from '../../Icons/XMarkIcon.jsx'
|
||||
import CheckIcon from '../../Icons/CheckIcon.jsx'
|
||||
import PlayCircleIcon from '../../Icons/PlayCircleIcon.jsx'
|
||||
|
||||
import config from '../../../config.js'
|
||||
import CheckCircleIcon from '../../Icons/CheckCircleIcon.jsx'
|
||||
import PauseCircleIcon from '../../Icons/PauseCircleIcon.jsx'
|
||||
import XMarkCircleIcon from '../../Icons/XMarkCircleIcon.jsx'
|
||||
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon.jsx'
|
||||
import DashboardTable from '../common/DashboardTable'
|
||||
|
||||
import config from '../../../config.js'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const useStyle = createStyles(({ css, token }) => {
|
||||
const { antCls } = token
|
||||
return {
|
||||
customTable: css`
|
||||
${antCls}-table {
|
||||
${antCls}-table-container {
|
||||
${antCls}-table-body,
|
||||
${antCls}-table-content {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #eaeaea transparent;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
})
|
||||
|
||||
const PrintJobs = () => {
|
||||
const { styles } = useStyle()
|
||||
const Jobs = () => {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const [notificationApi, notificationContextHolder] =
|
||||
notification.useNotification()
|
||||
const navigate = useNavigate()
|
||||
const [printJobsData, setPrintJobsData] = useState([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [lazyLoading, setLazyLoading] = useState(false)
|
||||
|
||||
const [filters, setFilters] = useState({})
|
||||
const [sorter, setSorter] = useState({})
|
||||
|
||||
const [newPrintJobOpen, setNewPrintJobOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [newJobOpen, setNewJobOpen] = useState(false)
|
||||
const tableRef = useRef()
|
||||
|
||||
const getFilterDropdown = ({
|
||||
setSelectedKeys,
|
||||
@ -154,7 +123,7 @@ const PrintJobs = () => {
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 165,
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'job'} longId={false} />,
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
@ -266,19 +235,17 @@ const PrintJobs = () => {
|
||||
{record.state.type === 'draft' ? (
|
||||
<Button
|
||||
icon={<PlayCircleIcon />}
|
||||
onClick={() => handleDeployPrintJob(record.id)}
|
||||
onClick={() => handleDeployJob(record.id)}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
icon={<InfoCircleIcon />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/production/printjobs/info?printJobId=${record.id}`
|
||||
)
|
||||
navigate(`/dashboard/production/jobs/info?jobId=${record.id}`)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Dropdown menu={getPrintJobActionItems(record.id)}>
|
||||
<Dropdown menu={getJobActionItems(record.id)}>
|
||||
<Button>Actions</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
@ -291,14 +258,14 @@ const PrintJobs = () => {
|
||||
const { socket } = useContext(SocketContext)
|
||||
|
||||
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
|
||||
'PrintJobs',
|
||||
'Jobs',
|
||||
columns
|
||||
)
|
||||
|
||||
const handleDeployPrintJob = (printJobId) => {
|
||||
const handleDeployJob = (jobId) => {
|
||||
if (socket) {
|
||||
messageApi.info(`Print job ${printJobId} deployment initiated`)
|
||||
socket.emit('server.job_queue.deploy', { printJobId }, (response) => {
|
||||
messageApi.info(`Print job ${jobId} deployment initiated`)
|
||||
socket.emit('server.job_queue.deploy', { jobId }, (response) => {
|
||||
if (response == false) {
|
||||
notificationApi.error({
|
||||
message: 'Print job deployment failed',
|
||||
@ -311,110 +278,13 @@ const PrintJobs = () => {
|
||||
})
|
||||
}
|
||||
})
|
||||
navigate(`/dashboard/production/printjobs/info?printJobId=${printJobId}`)
|
||||
navigate(`/dashboard/production/jobs/info?jobId=${jobId}`)
|
||||
} else {
|
||||
messageApi.error('Socket connection not available')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPrintJobsData = useCallback(
|
||||
async (pageNum = 1, append = false) => {
|
||||
if (!authenticated) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const params = {
|
||||
page: pageNum,
|
||||
limit: 25,
|
||||
...filters,
|
||||
sort: sorter.field,
|
||||
order: sorter.order
|
||||
}
|
||||
|
||||
const response = await axios.get(`${config.backendUrl}/printjobs`, {
|
||||
params,
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
const newData = response.data
|
||||
setHasMore(newData.length === 25)
|
||||
|
||||
if (append) {
|
||||
setPrintJobsData((prev) => [...prev, ...newData])
|
||||
} else {
|
||||
setPrintJobsData(newData)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
setLazyLoading(false)
|
||||
} catch (error) {
|
||||
setLoading(false)
|
||||
setLazyLoading(false)
|
||||
if (error.response) {
|
||||
messageApi.error(
|
||||
'Error fetching print jobs data:',
|
||||
error.response.status
|
||||
)
|
||||
} else {
|
||||
messageApi.error(
|
||||
'An unexpected error occurred. Please try again later.'
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
[authenticated, messageApi, filters, sorter]
|
||||
)
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(e) => {
|
||||
const { target } = e
|
||||
const scrollHeight = target.scrollHeight
|
||||
const scrollTop = target.scrollTop
|
||||
const clientHeight = target.clientHeight
|
||||
|
||||
// If we're near the bottom (within 100px) and not currently loading
|
||||
if (
|
||||
scrollHeight - scrollTop - clientHeight < 100 &&
|
||||
!lazyLoading &&
|
||||
hasMore
|
||||
) {
|
||||
setLazyLoading(true)
|
||||
const nextPage = page + 1
|
||||
setPage(nextPage)
|
||||
fetchPrintJobsData(nextPage, true)
|
||||
}
|
||||
},
|
||||
[page, lazyLoading, hasMore, fetchPrintJobsData]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch initial data
|
||||
if (authenticated) {
|
||||
fetchPrintJobsData()
|
||||
}
|
||||
}, [authenticated, fetchPrintJobsData])
|
||||
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
const newFilters = {}
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value && value.length > 0) {
|
||||
newFilters[key] = value[0]
|
||||
}
|
||||
})
|
||||
|
||||
setFilters(newFilters)
|
||||
setSorter({
|
||||
field: sorter.field,
|
||||
order: sorter.order
|
||||
})
|
||||
setPage(1)
|
||||
fetchPrintJobsData(1)
|
||||
}
|
||||
|
||||
const getPrintJobActionItems = (printJobId) => {
|
||||
const getJobActionItems = (jobId) => {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
@ -430,11 +300,9 @@ const PrintJobs = () => {
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'edit') {
|
||||
showNewPrintJobModal(printJobId)
|
||||
showNewJobModal(jobId)
|
||||
} else if (key === 'info') {
|
||||
navigate(
|
||||
`/dashboard/production/printjobs/info?printJobId=${printJobId}`
|
||||
)
|
||||
navigate(`/dashboard/production/jobs/info?jobId=${jobId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -444,7 +312,7 @@ const PrintJobs = () => {
|
||||
items: [
|
||||
{
|
||||
label: 'New Print Job',
|
||||
key: 'newPrintJob',
|
||||
key: 'newJob',
|
||||
icon: <PlusIcon />
|
||||
},
|
||||
{ type: 'divider' },
|
||||
@ -455,16 +323,16 @@ const PrintJobs = () => {
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'newPrintJob') {
|
||||
showNewPrintJobModal()
|
||||
if (key === 'newJob') {
|
||||
showNewJobModal()
|
||||
} else if (key === 'reloadList') {
|
||||
fetchPrintJobsData()
|
||||
tableRef.current?.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const showNewPrintJobModal = () => {
|
||||
setNewPrintJobOpen(true)
|
||||
const showNewJobModal = () => {
|
||||
setNewJobOpen(true)
|
||||
}
|
||||
|
||||
const getViewDropdownItems = () => {
|
||||
@ -511,41 +379,34 @@ const PrintJobs = () => {
|
||||
>
|
||||
<Button>View</Button>
|
||||
</Popover>
|
||||
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
className={styles.customTable}
|
||||
dataSource={printJobsData}
|
||||
<DashboardTable
|
||||
ref={tableRef}
|
||||
columns={visibleColumns}
|
||||
rowKey='id'
|
||||
pagination={false}
|
||||
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
|
||||
scroll={{ y: 'calc(100vh - 270px)' }}
|
||||
onChange={handleTableChange}
|
||||
onScroll={handleScroll}
|
||||
showSorterTooltip={false}
|
||||
url={`${config.backendUrl}/jobs`}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
open={newPrintJobOpen}
|
||||
open={newJobOpen}
|
||||
footer={null}
|
||||
width={700}
|
||||
onCancel={() => {
|
||||
setNewPrintJobOpen(false)
|
||||
setNewJobOpen(false)
|
||||
}}
|
||||
>
|
||||
<NewPrintJob
|
||||
<NewJob
|
||||
onOk={() => {
|
||||
setNewPrintJobOpen(false)
|
||||
setNewJobOpen(false)
|
||||
messageApi.success('New print job created successfully.')
|
||||
fetchPrintJobsData()
|
||||
tableRef.current?.reload()
|
||||
}}
|
||||
reset={newPrintJobOpen}
|
||||
reset={newJobOpen}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PrintJobs
|
||||
export default Jobs
|
||||
398
src/components/Dashboard/Production/Jobs/JobInfo.jsx
Normal 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
|
||||
@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import {
|
||||
Form,
|
||||
Button,
|
||||
@ -20,70 +21,66 @@ import config from '../../../../config'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
const initialNewPrintJobForm = {
|
||||
const initialNewJobForm = {
|
||||
gcodeFile: null,
|
||||
quantity: 1
|
||||
}
|
||||
|
||||
const NewPrintJob = ({ onOk, reset }) => {
|
||||
NewPrintJob.propTypes = {
|
||||
const NewJob = ({ onOk, reset }) => {
|
||||
NewJob.propTypes = {
|
||||
onOk: PropTypes.func.isRequired,
|
||||
reset: PropTypes.bool.isRequired
|
||||
}
|
||||
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const [newPrintJobLoading, setNewPrintJobLoading] = useState(false)
|
||||
const [newJobLoading, setNewJobLoading] = useState(false)
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [nextEnabled, setNextEnabled] = useState(false)
|
||||
const [newPrintJobForm] = Form.useForm()
|
||||
const [newPrintJobFormValues, setNewPrintJobFormValues] = useState(
|
||||
initialNewPrintJobForm
|
||||
)
|
||||
const [newJobForm] = Form.useForm()
|
||||
const [newJobFormValues, setNewJobFormValues] = useState(initialNewJobForm)
|
||||
|
||||
const newPrintJobFormUpdateValues = Form.useWatch([], newPrintJobForm)
|
||||
const newJobFormUpdateValues = Form.useWatch([], newJobForm)
|
||||
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
React.useEffect(() => {
|
||||
newPrintJobForm
|
||||
newJobForm
|
||||
.validateFields({
|
||||
validateOnly: true
|
||||
})
|
||||
.then(() => setNextEnabled(true))
|
||||
.catch(() => setNextEnabled(false))
|
||||
}, [newPrintJobForm, newPrintJobFormUpdateValues])
|
||||
}, [newJobForm, newJobFormUpdateValues])
|
||||
|
||||
const summaryItems = [
|
||||
{
|
||||
key: 'quantity',
|
||||
label: 'Quantity',
|
||||
children: newPrintJobFormValues.quantity
|
||||
children: newJobFormValues.quantity
|
||||
}
|
||||
]
|
||||
|
||||
React.useEffect(() => {
|
||||
if (reset) {
|
||||
newPrintJobForm.resetFields()
|
||||
newJobForm.resetFields()
|
||||
}
|
||||
}, [reset, newPrintJobForm])
|
||||
}, [reset, newJobForm])
|
||||
|
||||
const handleNewPrintJob = async () => {
|
||||
setNewPrintJobLoading(true)
|
||||
const handleNewJob = async () => {
|
||||
setNewJobLoading(true)
|
||||
try {
|
||||
await axios.post(
|
||||
`${config.backendUrl}/printjobs`,
|
||||
newPrintJobFormValues,
|
||||
{
|
||||
await axios.post(`${config.backendUrl}/jobs`, newJobFormValues, {
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true // Important for including cookies
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
onOk()
|
||||
} catch (error) {
|
||||
messageApi.error('Error creating new print job: ' + error.message)
|
||||
} finally {
|
||||
setNewPrintJobLoading(false)
|
||||
setNewJobLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,7 +105,6 @@ const NewPrintJob = ({ onOk, reset }) => {
|
||||
<Form.Item
|
||||
label='Quantity'
|
||||
name='quantity'
|
||||
defaultValue={1}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
@ -121,7 +117,7 @@ const NewPrintJob = ({ onOk, reset }) => {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber min={1} defaultValue={1} style={{ width: '100%' }} />
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label='Printers'
|
||||
@ -152,6 +148,7 @@ const NewPrintJob = ({ onOk, reset }) => {
|
||||
return (
|
||||
<Flex gap={'middle'}>
|
||||
{contextHolder}
|
||||
{!isMobile && (
|
||||
<div style={{ minWidth: '160px' }}>
|
||||
<Steps
|
||||
current={currentStep}
|
||||
@ -160,25 +157,26 @@ const NewPrintJob = ({ onOk, reset }) => {
|
||||
style={{ width: 'fit-content' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider type={'vertical'} style={{ height: 'unset' }} />
|
||||
{!isMobile && <Divider type={'vertical'} style={{ height: 'unset' }} />}
|
||||
|
||||
<Flex vertical={'true'} style={{ flexGrow: 1 }} gap='middle'>
|
||||
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
|
||||
New PrintJob
|
||||
New Job
|
||||
</Title>
|
||||
<Form
|
||||
name='basic'
|
||||
autoComplete='off'
|
||||
form={newPrintJobForm}
|
||||
onFinish={handleNewPrintJob}
|
||||
form={newJobForm}
|
||||
onFinish={handleNewJob}
|
||||
onValuesChange={(changedValues) =>
|
||||
setNewPrintJobFormValues((prevValues) => ({
|
||||
setNewJobFormValues((prevValues) => ({
|
||||
...prevValues,
|
||||
...changedValues
|
||||
}))
|
||||
}
|
||||
initialValues={initialNewPrintJobForm}
|
||||
initialValues={initialNewJobForm}
|
||||
>
|
||||
<div style={{ minHeight: '260px' }}>{steps[currentStep].content}</div>
|
||||
|
||||
@ -204,11 +202,7 @@ const NewPrintJob = ({ onOk, reset }) => {
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === steps.length - 1 && (
|
||||
<Button
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
loading={newPrintJobLoading}
|
||||
>
|
||||
<Button type='primary' htmlType='submit' loading={newJobLoading}>
|
||||
Done
|
||||
</Button>
|
||||
)}
|
||||
@ -219,4 +213,4 @@ const NewPrintJob = ({ onOk, reset }) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default NewPrintJob
|
||||
export default NewJob
|
||||
@ -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
|
||||
@ -1,10 +1,8 @@
|
||||
// src/Printers.js
|
||||
|
||||
import React, { useEffect, useState, useContext, useCallback } from 'react'
|
||||
import React, { useState, useContext, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
message,
|
||||
Dropdown,
|
||||
@ -14,11 +12,8 @@ import {
|
||||
Tag,
|
||||
Modal,
|
||||
Popover,
|
||||
Checkbox,
|
||||
Spin
|
||||
Checkbox
|
||||
} from 'antd'
|
||||
import { createStyles } from 'antd-style'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import PrinterState from '../common/PrinterState'
|
||||
@ -31,36 +26,16 @@ import PlusIcon from '../../Icons/PlusIcon'
|
||||
import ReloadIcon from '../../Icons/ReloadIcon'
|
||||
import XMarkIcon from '../../Icons/XMarkIcon'
|
||||
import CheckIcon from '../../Icons/CheckIcon'
|
||||
import DashboardTable from '../common/DashboardTable'
|
||||
|
||||
import config from '../../../config'
|
||||
|
||||
const useStyle = createStyles(({ css, token }) => {
|
||||
const { antCls } = token
|
||||
return {
|
||||
customTable: css`
|
||||
${antCls}-table {
|
||||
${antCls}-table-container {
|
||||
${antCls}-table-body,
|
||||
${antCls}-table-content {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #eaeaea transparent;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
})
|
||||
|
||||
const Printers = () => {
|
||||
const { styles } = useStyle()
|
||||
const [printerData, setPrinterData] = useState([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [lazyLoading, setLazyLoading] = useState(false)
|
||||
const [filters, setFilters] = useState({})
|
||||
const [sorter, setSorter] = useState({})
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const { authenticated } = useContext(AuthContext)
|
||||
const [newPrinterOpen, setNewPrinterOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const tableRef = useRef()
|
||||
|
||||
// Column definitions
|
||||
const columns = [
|
||||
@ -98,7 +73,7 @@ const Printers = () => {
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 165,
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type='printer' longId={false} />
|
||||
},
|
||||
{
|
||||
@ -186,11 +161,6 @@ const Printers = () => {
|
||||
}, {})
|
||||
)
|
||||
|
||||
const [messageApi] = message.useMessage()
|
||||
const { authenticated } = useContext(AuthContext)
|
||||
const [newPrinterOpen, setNewPrinterOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const getFilterDropdown = ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
@ -227,91 +197,6 @@ const Printers = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const fetchPrintersData = useCallback(
|
||||
async (pageNum = 1, append = false) => {
|
||||
try {
|
||||
const params = {
|
||||
page: pageNum,
|
||||
limit: 25,
|
||||
...filters,
|
||||
sort: sorter.field,
|
||||
order: sorter.order
|
||||
}
|
||||
|
||||
const response = await axios.get(`${config.backendUrl}/printers`, {
|
||||
params,
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
const newData = response.data
|
||||
setHasMore(newData.length === 25) // If we get less than 25 items, we've reached the end
|
||||
|
||||
if (append) {
|
||||
setPrinterData((prev) => [...prev, ...newData])
|
||||
} else {
|
||||
setPrinterData(newData)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
setLazyLoading(false)
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
messageApi.error(
|
||||
'Error fetching printer data:',
|
||||
error.response.status
|
||||
)
|
||||
} else {
|
||||
messageApi.error(
|
||||
'An unexpected error occurred. Please try again later.'
|
||||
)
|
||||
}
|
||||
setLoading(false)
|
||||
setLazyLoading(false)
|
||||
}
|
||||
},
|
||||
[messageApi, filters, sorter]
|
||||
)
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(e) => {
|
||||
const { target } = e
|
||||
const scrollHeight = target.scrollHeight
|
||||
const scrollTop = target.scrollTop
|
||||
const clientHeight = target.clientHeight
|
||||
|
||||
// If we're near the bottom (within 100px) and not currently loading
|
||||
if (
|
||||
scrollHeight - scrollTop - clientHeight < 100 &&
|
||||
!lazyLoading &&
|
||||
hasMore
|
||||
) {
|
||||
setLazyLoading(true)
|
||||
const nextPage = page + 1
|
||||
setPage(nextPage)
|
||||
fetchPrintersData(nextPage, true)
|
||||
}
|
||||
},
|
||||
[page, lazyLoading, hasMore, fetchPrintersData]
|
||||
)
|
||||
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
const newFilters = {}
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value && value.length > 0) {
|
||||
newFilters[key] = value[0] // Take the first filter value
|
||||
}
|
||||
})
|
||||
|
||||
setFilters(newFilters)
|
||||
setSorter({
|
||||
field: sorter.field,
|
||||
order: sorter.order
|
||||
})
|
||||
}
|
||||
|
||||
const getPrinterActionItems = (printerId) => {
|
||||
return {
|
||||
items: [
|
||||
@ -343,7 +228,7 @@ const Printers = () => {
|
||||
|
||||
const getViewDropdownItems = () => {
|
||||
const columnItems = columns
|
||||
.filter((col) => col.key && col.title !== '') // Filter out empty title columns and ensure key exists
|
||||
.filter((col) => col.key && col.title !== '')
|
||||
.map((col) => (
|
||||
<Checkbox
|
||||
checked={columnVisibility[col.key]}
|
||||
@ -388,22 +273,17 @@ const Printers = () => {
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reloadList') {
|
||||
fetchPrintersData()
|
||||
tableRef.current?.reload()
|
||||
} else if (key === 'newPrinter') {
|
||||
setNewPrinterOpen(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (authenticated) {
|
||||
fetchPrintersData()
|
||||
}
|
||||
}, [fetchPrintersData, authenticated])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex vertical={'true'} gap='large'>
|
||||
{contextHolder}
|
||||
<Space>
|
||||
<Dropdown menu={actionItems}>
|
||||
<Button>Actions</Button>
|
||||
@ -415,21 +295,15 @@ const Printers = () => {
|
||||
>
|
||||
<Button>View</Button>
|
||||
</Popover>
|
||||
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
className={styles.customTable}
|
||||
dataSource={printerData}
|
||||
<DashboardTable
|
||||
ref={tableRef}
|
||||
columns={visibleColumns}
|
||||
pagination={false}
|
||||
rowKey='id'
|
||||
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
|
||||
scroll={{ y: 'calc(100vh - 270px)' }}
|
||||
onChange={handleTableChange}
|
||||
onScroll={handleScroll}
|
||||
showSorterTooltip={false}
|
||||
url={`${config.backendUrl}/printers`}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={newPrinterOpen}
|
||||
footer={null}
|
||||
@ -442,7 +316,7 @@ const Printers = () => {
|
||||
onOk={() => {
|
||||
setNewPrinterOpen(false)
|
||||
messageApi.success('New printer added successfully.')
|
||||
fetchPrintersData()
|
||||
tableRef.current?.reload()
|
||||
}}
|
||||
reset={newPrinterOpen}
|
||||
/>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React, { useState, useContext, useCallback, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import {
|
||||
Button,
|
||||
message,
|
||||
@ -26,6 +27,7 @@ import { SocketContext } from '../../context/SocketContext'
|
||||
import PrinterTemperaturePanel from '../../common/PrinterTemperaturePanel'
|
||||
import PrinterPositionPanel from '../../common/PrinterPositionPanel'
|
||||
import PrinterMovementPanel from '../../common/PrinterMovementPanel'
|
||||
import PrinterMiscPanel from '../../common/PrinterMiscPanel'
|
||||
import PrinterState from '../../common/PrinterState'
|
||||
import { AuthContext } from '../../context/AuthContext'
|
||||
import PrinterSubJobsTree from '../../common/PrinterJobsTree'
|
||||
@ -47,6 +49,7 @@ import PlayCircleIcon from '../../../Icons/PlayCircleIcon'
|
||||
import XMarkCircleIcon from '../../../Icons/XMarkCircleIcon'
|
||||
import PauseCircleIcon from '../../../Icons/PauseCircleIcon'
|
||||
import ExclamationOctagonIcon from '../../../Icons/ExclamationOctagonIcon'
|
||||
import TimeDisplay from '../../common/TimeDisplay'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
|
||||
@ -59,6 +62,7 @@ const ControlPrinter = () => {
|
||||
const [messageApi] = message.useMessage()
|
||||
const query = useQuery()
|
||||
const printerId = query.get('printerId')
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
const [printerData, setPrinterData] = useState(null)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
@ -390,6 +394,17 @@ const ControlPrinter = () => {
|
||||
>
|
||||
Sub Jobs
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={componentVisibility.misc}
|
||||
onChange={(e) => {
|
||||
setComponentVisibility((prev) => ({
|
||||
...prev,
|
||||
misc: e.target.checked
|
||||
}))
|
||||
}}
|
||||
>
|
||||
Misc Panel
|
||||
</Checkbox>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
@ -471,8 +486,8 @@ const ControlPrinter = () => {
|
||||
</Flex>
|
||||
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||
{printerData ? (
|
||||
<Flex gap={16}>
|
||||
<Flex vertical style={{ flexGrow: 1 }}>
|
||||
<Flex gap={'large'} wrap>
|
||||
<Flex vertical style={{ flexGrow: 1 }} gap={'large'}>
|
||||
<Flex gap={16} vertical style={{ flexGrow: 1 }}>
|
||||
{printerData?.alerts?.some(
|
||||
(alert) => alert.type === 'klippyError'
|
||||
@ -540,14 +555,16 @@ const ControlPrinter = () => {
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='GCode File Name'>
|
||||
{
|
||||
{printerData.currentJob?.gcodeFile?.name ? (
|
||||
<Space>
|
||||
<GCodeFileIcon />
|
||||
<Text ellipsis style={{ maxWidth: 200 }}>
|
||||
{printerData.currentJob?.gcodeFile?.name || 'n/a'}
|
||||
{printerData.currentJob?.gcodeFile?.name}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
) : (
|
||||
'n/a'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='GCode File ID'>
|
||||
{printerData.currentJob?.gcodeFile ? (
|
||||
@ -601,7 +618,14 @@ const ControlPrinter = () => {
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Started At' span={1}>
|
||||
{printerData.name}
|
||||
{printerData.currentSubJob?.startedAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={printerData.currentSubJob.startedAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
'n/a'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</>
|
||||
)}
|
||||
@ -740,7 +764,12 @@ const ControlPrinter = () => {
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Weight'>
|
||||
{printerData.currentFilamentStock?.currentNetWeight ? (
|
||||
<Descriptions style={{ width: '250px' }} column={2}>
|
||||
<div>
|
||||
<Descriptions
|
||||
style={{ width: isMobile ? '100%' : '250px' }}
|
||||
column={2}
|
||||
size={'small'}
|
||||
>
|
||||
<Descriptions.Item label='Net'>
|
||||
{printerData.currentFilamentStock.currentNetWeight.toFixed(
|
||||
2
|
||||
@ -752,6 +781,7 @@ const ControlPrinter = () => {
|
||||
) + 'g'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
) : (
|
||||
'n/a'
|
||||
)}
|
||||
@ -793,7 +823,7 @@ const ControlPrinter = () => {
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Flex>
|
||||
<Flex gap={16} vertical>
|
||||
<Flex gap={'large'} wrap vertical>
|
||||
{componentVisibility.temperature && (
|
||||
<Card>
|
||||
<PrinterTemperaturePanel
|
||||
@ -820,6 +850,12 @@ const ControlPrinter = () => {
|
||||
></PrinterMovementPanel>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{componentVisibility.misc && (
|
||||
<Card>
|
||||
<PrinterMiscPanel printerId={printerId} />
|
||||
</Card>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
) : (
|
||||
|
||||
@ -31,6 +31,7 @@ import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
||||
import useCollapseState from '../../hooks/useCollapseState'
|
||||
|
||||
import config from '../../../../config.js'
|
||||
import AuditLogTable from '../../common/AuditLogTable.jsx'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
@ -46,7 +47,8 @@ const PrinterInfo = () => {
|
||||
const [form] = Form.useForm()
|
||||
const [collapseState, updateCollapseState] = useCollapseState('PrinterInfo', {
|
||||
info: true,
|
||||
jobs: true
|
||||
jobs: true,
|
||||
auditLogs: true
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@ -199,6 +201,7 @@ const PrinterInfo = () => {
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
{contextHolder}
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
@ -273,7 +276,7 @@ const PrinterInfo = () => {
|
||||
>
|
||||
{/* Read-only fields */}
|
||||
<Descriptions.Item label='ID'>
|
||||
<IdText id={printerData.id} type='printer' />
|
||||
<IdText id={printerData._id} type='printer' />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Connected At'>
|
||||
<TimeDisplay
|
||||
@ -292,7 +295,10 @@ const PrinterInfo = () => {
|
||||
required: true,
|
||||
message: 'Please enter a printer name'
|
||||
},
|
||||
{ max: 100, message: 'Name cannot exceed 100 characters' }
|
||||
{
|
||||
max: 100,
|
||||
message: 'Name cannot exceed 100 characters'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
@ -360,7 +366,10 @@ const PrinterInfo = () => {
|
||||
<Form.Item
|
||||
name={['moonraker', 'port']}
|
||||
rules={[
|
||||
{ required: true, message: 'Please enter a port number' },
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a port number'
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
min: 1,
|
||||
@ -504,6 +513,36 @@ const PrinterInfo = () => {
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -9,17 +9,16 @@ import {
|
||||
message,
|
||||
Button,
|
||||
Collapse,
|
||||
Segmented
|
||||
Segmented,
|
||||
Card
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
||||
import { Line } from '@ant-design/charts'
|
||||
import axios from 'axios'
|
||||
import PrinterIcon from '../../Icons/PrinterIcon'
|
||||
import JobIcon from '../../Icons/JobIcon'
|
||||
import ReloadIcon from '../../Icons/ReloadIcon'
|
||||
import PauseIcon from '../../Icons/PauseIcon'
|
||||
import XMarkIcon from '../../Icons/XMarkIcon'
|
||||
import useCollapseState from '../hooks/useCollapseState'
|
||||
import CheckIcon from '../../Icons/CheckIcon'
|
||||
|
||||
import config from '../../../config'
|
||||
|
||||
@ -29,6 +28,7 @@ const ProductionOverview = () => {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const [error, setError] = useState(null)
|
||||
const [fetchPrinterStatsLoading, setFetchPrinterStatsLoading] = useState(true)
|
||||
const [chartData, setChartData] = useState([])
|
||||
const [collapseState, updateCollapseState] = useCollapseState(
|
||||
'ProductionOverview',
|
||||
{
|
||||
@ -46,9 +46,9 @@ const ProductionOverview = () => {
|
||||
const [stats, setStats] = useState({
|
||||
totalPrinters: 0,
|
||||
activePrinters: 0,
|
||||
totalPrintJobs: 0,
|
||||
activePrintJobs: 0,
|
||||
completedPrintJobs: 0,
|
||||
totalJobs: 0,
|
||||
activeJobs: 0,
|
||||
completedJobs: 0,
|
||||
printerStatus: {
|
||||
idle: 0,
|
||||
printing: 0,
|
||||
@ -59,7 +59,8 @@ const ProductionOverview = () => {
|
||||
|
||||
const fetchAllStats = useCallback(async () => {
|
||||
await fetchPrinterStats()
|
||||
await fetchPrintJobStats()
|
||||
await fetchJobstats()
|
||||
await fetchChartData()
|
||||
console.log(stats)
|
||||
}, [])
|
||||
|
||||
@ -84,17 +85,17 @@ const ProductionOverview = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPrintJobStats = async () => {
|
||||
const fetchJobstats = async () => {
|
||||
try {
|
||||
setFetchPrinterStatsLoading(true)
|
||||
const response = await axios.get(`${config.backendUrl}/printjobs/stats`, {
|
||||
const response = await axios.get(`${config.backendUrl}/jobs/stats`, {
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
})
|
||||
const printJobStats = response.data
|
||||
setStats((prev) => ({ ...prev, printJobs: printJobStats }))
|
||||
const jobstats = response.data
|
||||
setStats((prev) => ({ ...prev, jobs: jobstats }))
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError('Failed to fetch printer details')
|
||||
@ -104,6 +105,20 @@ const ProductionOverview = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchChartData = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${config.backendUrl}/stats/history`, {
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
withCredentials: true
|
||||
})
|
||||
setChartData(response.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch chart data:', err)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllStats()
|
||||
}, [fetchAllStats])
|
||||
@ -131,8 +146,9 @@ const ProductionOverview = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
{contextHolder}
|
||||
<Flex gap='large' vertical>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
@ -200,7 +216,7 @@ const ProductionOverview = () => {
|
||||
<Flex gap={'small'}>
|
||||
<JobIcon style={{ fontSize: 26 }} />
|
||||
<Text style={{ fontSize: 28, fontWeight: 600 }}>
|
||||
{stats.printJobs.queued || 0}
|
||||
{stats.jobs.queued || 0}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
@ -215,7 +231,7 @@ const ProductionOverview = () => {
|
||||
<Flex gap={'small'}>
|
||||
<JobIcon style={{ fontSize: 26 }} />
|
||||
<Text style={{ fontSize: 28, fontWeight: 600 }}>
|
||||
{stats.printJobs.printing || 0}
|
||||
{stats.jobs.printing || 0}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
@ -230,8 +246,7 @@ const ProductionOverview = () => {
|
||||
<Flex gap={'small'}>
|
||||
<JobIcon style={{ fontSize: 26 }} />
|
||||
<Text style={{ fontSize: 28, fontWeight: 600 }}>
|
||||
{(stats.printJobs.failed || 0) +
|
||||
(stats.printJobs.cancelled || 0)}
|
||||
{(stats.jobs.failed || 0) + (stats.jobs.cancelled || 0)}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
@ -246,7 +261,7 @@ const ProductionOverview = () => {
|
||||
<Flex gap={'small'}>
|
||||
<JobIcon style={{ fontSize: 26 }} />
|
||||
<Text style={{ fontSize: 28, fontWeight: 600 }}>
|
||||
{stats.printJobs.complete || 0}
|
||||
{stats.jobs.complete || 0}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
@ -256,7 +271,7 @@ const ProductionOverview = () => {
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Flex gap='middle' wrap='wrap'>
|
||||
<Flex gap='large' wrap='wrap'>
|
||||
<Flex flex={1} vertical>
|
||||
<Collapse
|
||||
ghost
|
||||
@ -281,7 +296,7 @@ const ProductionOverview = () => {
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Printer Statistics
|
||||
Production Statistics
|
||||
</Title>
|
||||
<Segmented
|
||||
options={['4h', '8h', '12h', '24h']}
|
||||
@ -298,40 +313,44 @@ const ProductionOverview = () => {
|
||||
}
|
||||
key='2'
|
||||
>
|
||||
<Space direction='vertical' style={{ width: '100%' }}>
|
||||
<Descriptions column={1} bordered>
|
||||
<Descriptions.Item
|
||||
label={
|
||||
<Space>
|
||||
<CheckIcon />
|
||||
{'Completed'}
|
||||
</Space>
|
||||
<Flex vertical gap={'middle'}>
|
||||
<Card style={{ height: 250, width: '100%' }}>
|
||||
<Line
|
||||
data={chartData}
|
||||
xField='timestamp'
|
||||
yField='value'
|
||||
seriesField='type'
|
||||
smooth
|
||||
animation={{
|
||||
appear: {
|
||||
animation: 'wave-in',
|
||||
duration: 1000
|
||||
}
|
||||
>
|
||||
}}
|
||||
point={{
|
||||
size: 4,
|
||||
shape: 'circle'
|
||||
}}
|
||||
tooltip={{
|
||||
showMarkers: false
|
||||
}}
|
||||
legend={{
|
||||
position: 'top'
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<Descriptions column={1} bordered>
|
||||
<Descriptions.Item label='Completed'>
|
||||
{stats.totalPrinters}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
label={
|
||||
<Space>
|
||||
<XMarkIcon />
|
||||
{'Error'}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Descriptions.Item label='Error'>
|
||||
{stats.activePrinters}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
label={
|
||||
<Space>
|
||||
<PauseIcon />
|
||||
{'Paused'}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Descriptions.Item label='Paused'>
|
||||
{stats.activePrinters}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Flex>
|
||||
@ -373,45 +392,52 @@ const ProductionOverview = () => {
|
||||
}
|
||||
key='3'
|
||||
>
|
||||
<Space direction='vertical' style={{ width: '100%' }}>
|
||||
<Flex vertical gap={'middle'}>
|
||||
<Card
|
||||
style={{ height: 250, width: '100%', minWidth: '300px' }}
|
||||
>
|
||||
<Line
|
||||
data={chartData}
|
||||
xField='timestamp'
|
||||
yField='value'
|
||||
seriesField='type'
|
||||
smooth
|
||||
animation={{
|
||||
appear: {
|
||||
animation: 'wave-in',
|
||||
duration: 1000
|
||||
}
|
||||
}}
|
||||
point={{
|
||||
size: 4,
|
||||
shape: 'circle'
|
||||
}}
|
||||
tooltip={{
|
||||
showMarkers: false
|
||||
}}
|
||||
legend={{
|
||||
position: 'top'
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<Descriptions column={1} bordered>
|
||||
<Descriptions.Item
|
||||
label={
|
||||
<Space>
|
||||
<CheckIcon />
|
||||
{'Completed'}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{stats.totalPrintJobs}
|
||||
<Descriptions.Item label='Completed'>
|
||||
{stats.totalJobs}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
label={
|
||||
<Space>
|
||||
<XMarkIcon />
|
||||
{'Failed'}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{stats.activePrintJobs}
|
||||
<Descriptions.Item label='Failed'>
|
||||
{stats.activeJobs}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
label={
|
||||
<Space>
|
||||
<PauseIcon />
|
||||
{'Queued'}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{stats.completedPrintJobs}
|
||||
<Descriptions.Item label='Queued'>
|
||||
{stats.completedJobs}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
214
src/components/Dashboard/common/AuditLogTable.jsx
Normal 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
|
||||
@ -13,8 +13,8 @@ const breadcrumbNameMap = {
|
||||
'/dashboard/production/printers': 'Printers',
|
||||
'/dashboard/production/printers/control': 'Control',
|
||||
'/dashboard/production/printers/info': 'Info',
|
||||
'/dashboard/production/printjobs': 'Print Jobs',
|
||||
'/dashboard/production/printjobs/info': 'Info',
|
||||
'/dashboard/production/jobs': 'Print Jobs',
|
||||
'/dashboard/production/jobs/info': 'Info',
|
||||
'/dashboard/production/gcodefiles': 'G Code Files',
|
||||
'/dashboard/production/gcodefiles/info': 'Info',
|
||||
'/dashboard/management/filaments': 'Filaments',
|
||||
@ -27,7 +27,10 @@ const breadcrumbNameMap = {
|
||||
'/dashboard/management/vendors/info': 'Info',
|
||||
'/dashboard/management/materials': 'Materials',
|
||||
'/dashboard/management/materials/info': 'Info',
|
||||
'/dashboard/management/notetypes': 'Note Types',
|
||||
'/dashboard/management/notetypes/info': 'Info',
|
||||
'/dashboard/management/settings': 'Settings',
|
||||
'/dashboard/management/auditlogs': 'Audit Logs',
|
||||
'/dashboard/inventory/filamentstocks': 'Filament Stocks',
|
||||
'/dashboard/inventory/filamentstocks/info': 'Info',
|
||||
'/dashboard/inventory/partstocks': 'Part Stocks',
|
||||
@ -70,7 +73,7 @@ const DashboardBreadcrumb = () => {
|
||||
})
|
||||
|
||||
return (
|
||||
<Flex align='center' gap={'large'}>
|
||||
<Flex align='center' gap={'small'}>
|
||||
<Flex gap={'small'}>
|
||||
<Space.Compact>
|
||||
<Button
|
||||
|
||||
@ -6,11 +6,8 @@
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.no-t-padding-collapse .ant-collapse-header {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
.no-h-padding-collapse .ant-collapse-header {
|
||||
padding-top: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
padding-bottom: 4px !important;
|
||||
@ -19,6 +16,7 @@
|
||||
.no-h-padding-collapse .ant-collapse-content-box {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.no-padding-collapse .ant-collapse-item,
|
||||
|
||||
@ -13,9 +13,6 @@ import {
|
||||
} from 'antd'
|
||||
import {
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
ShoppingCartOutlined,
|
||||
PoundOutlined,
|
||||
MailOutlined,
|
||||
MenuOutlined,
|
||||
LoadingOutlined
|
||||
@ -25,14 +22,17 @@ import { SocketContext } from '../context/SocketContext'
|
||||
import { SpotlightContext } from '../context/SpotlightContext'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Header } from 'antd/es/layout/layout'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
import FarmControlLogo from '../../Logos/FarmControlLogo'
|
||||
import FarmControlLogoSmall from '../../Logos/FarmControlLogoSmall'
|
||||
import ProductionIcon from '../../Icons/ProductionIcon'
|
||||
import InventoryIcon from '../../Icons/InventoryIcon'
|
||||
import PersonIcon from '../../Icons/PersonIcon'
|
||||
import CloudIcon from '../../Icons/CloudIcon'
|
||||
import BellIcon from '../../Icons/BellIcon'
|
||||
import SearchIcon from '../../Icons/SearchIcon'
|
||||
import SettingsIcon from '../../Icons/SettingsIcon'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@ -44,6 +44,7 @@ const DashboardNavigation = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [selectedKey, setSelectedKey] = useState('production')
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
useEffect(() => {
|
||||
const pathParts = location.pathname.split('/').filter(Boolean)
|
||||
@ -73,21 +74,10 @@ const DashboardNavigation = () => {
|
||||
label: 'Inventory',
|
||||
icon: <InventoryIcon />
|
||||
},
|
||||
{
|
||||
key: 'sales',
|
||||
label: 'Sales',
|
||||
icon: <ShoppingCartOutlined />
|
||||
},
|
||||
{
|
||||
key: 'finance',
|
||||
label: 'Finance',
|
||||
icon: <PoundOutlined />
|
||||
},
|
||||
|
||||
{
|
||||
key: 'management',
|
||||
label: 'Management',
|
||||
icon: <SettingOutlined />
|
||||
icon: <SettingsIcon />
|
||||
}
|
||||
]
|
||||
|
||||
@ -151,7 +141,11 @@ const DashboardNavigation = () => {
|
||||
borderBottom: '1px solid rgba(5, 5, 5, 0.00)'
|
||||
}}
|
||||
>
|
||||
{!isMobile ? (
|
||||
<FarmControlLogo style={{ fontSize: '200px' }} />
|
||||
) : (
|
||||
<FarmControlLogoSmall style={{ fontSize: '48px' }} />
|
||||
)}
|
||||
<Menu
|
||||
mode='horizontal'
|
||||
items={mainMenuItems}
|
||||
@ -162,7 +156,7 @@ const DashboardNavigation = () => {
|
||||
}}
|
||||
onClick={handleMainMenuClick}
|
||||
selectedKeys={[selectedKey]}
|
||||
overflowedIndicator={<MenuOutlined />}
|
||||
overflowedIndicator={<Button type='text' icon={<MenuOutlined />} />}
|
||||
/>
|
||||
<Flex gap={'middle'} align='center'>
|
||||
<Space>
|
||||
@ -223,7 +217,7 @@ const DashboardNavigation = () => {
|
||||
<Space>
|
||||
<Dropdown menu={userMenuItems} placement='bottomRight'>
|
||||
<Tag style={{ marginRight: 0 }} icon={<PersonIcon />}>
|
||||
{userProfile?.name ? userProfile.name : userProfile.username}
|
||||
{!isMobile && (userProfile?.name || userProfile.username)}
|
||||
</Tag>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
|
||||
177
src/components/Dashboard/common/DashboardNotes.jsx
Normal 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
|
||||
318
src/components/Dashboard/common/DashboardTable.jsx
Normal 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
|
||||
@ -16,7 +16,7 @@ const propertyOrder = [
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const GCodeFileSelect = ({ onChange, filter, useFilter, style }) => {
|
||||
const GCodeFileSelect = ({ onChange, filter, useFilter = false, style }) => {
|
||||
const [gcodeFilesTreeData, setGCodeFilesTreeData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
@ -98,7 +98,7 @@ const GCodeFileSelect = ({ onChange, filter, useFilter, style }) => {
|
||||
value: gcodeFile._id,
|
||||
key: gcodeFile._id,
|
||||
title: (
|
||||
<Flex gap={'small'} align='center'>
|
||||
<Flex gap={'small'} align='center' style={{ width: '100%' }}>
|
||||
<GCodeFileIcon />
|
||||
<Badge color={gcodeFile.filament.color} />
|
||||
<Text ellipsis>
|
||||
@ -211,9 +211,9 @@ const GCodeFileSelect = ({ onChange, filter, useFilter, style }) => {
|
||||
}
|
||||
|
||||
GCodeFileSelect.propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
filter: PropTypes.string.isRequired,
|
||||
useFilter: PropTypes.bool.isRequired,
|
||||
onChange: PropTypes.func,
|
||||
filter: PropTypes.string,
|
||||
useFilter: PropTypes.bool,
|
||||
style: PropTypes.object
|
||||
}
|
||||
|
||||
|
||||
@ -1,9 +1,27 @@
|
||||
// PrinterSelect.js
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Flex, Typography, Button, Tooltip, message } from 'antd'
|
||||
import { Flex, Typography, Button, Tooltip, message, Space } from 'antd'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import CopyIcon from '../../Icons/CopyIcon'
|
||||
import PrinterIcon from '../../Icons/PrinterIcon'
|
||||
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
|
||||
import FilamentIcon from '../../Icons/FilamentIcon'
|
||||
import FilamentStockIcon from '../../Icons/FilamentStockIcon'
|
||||
import GCodeFileIcon from '../../Icons/GCodeFileIcon'
|
||||
import JobIcon from '../../Icons/JobIcon'
|
||||
import PartIcon from '../../Icons/PartIcon'
|
||||
import ProductIcon from '../../Icons/ProductIcon'
|
||||
import VendorIcon from '../../Icons/VendorIcon'
|
||||
import SubJobIcon from '../../Icons/SubJobIcon'
|
||||
import StockEventIcon from '../../Icons/StockEventIcon'
|
||||
import StockAuditIcon from '../../Icons/StockAuditIcon'
|
||||
import PartStockIcon from '../../Icons/PartStockIcon'
|
||||
import ProductStockIcon from '../../Icons/ProductStockIcon'
|
||||
import AuditLogIcon from '../../Icons/AuditLogIcon'
|
||||
import PersonIcon from '../../Icons/PersonIcon'
|
||||
import NoteTypeIcon from '../../Icons/NoteTypeIcon'
|
||||
|
||||
const { Text, Link } = Typography
|
||||
|
||||
@ -16,73 +34,109 @@ const IdText = ({
|
||||
}) => {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
var prefix = 'UNK'
|
||||
var hyperlink = '#'
|
||||
var icon = <QuestionCircleIcon style={{ paddingTop: '4px' }} />
|
||||
|
||||
switch (type) {
|
||||
case 'printer':
|
||||
prefix = 'PRN'
|
||||
hyperlink = `/dashboard/production/printers/info?printerId=${id}`
|
||||
icon = <PrinterIcon style={{ paddingTop: '4px' }} />
|
||||
break
|
||||
case 'filament':
|
||||
prefix = 'FIL'
|
||||
hyperlink = `/dashboard/management/filaments/info?filamentId=${id}`
|
||||
icon = <FilamentIcon style={{ paddingTop: '4px' }} />
|
||||
break
|
||||
case 'spool':
|
||||
prefix = 'SPL'
|
||||
hyperlink = `/dashboard/inventory/spool/info?spoolId=${id}`
|
||||
icon = <FilamentIcon style={{ paddingTop: '4px' }} />
|
||||
break
|
||||
case 'gcodeFile':
|
||||
case 'gcodefile':
|
||||
prefix = 'GCF'
|
||||
hyperlink = `/dashboard/production/gcodefiles/info?gcodeFileId=${id}`
|
||||
icon = <GCodeFileIcon style={{ paddingTop: '4px' }} />
|
||||
break
|
||||
case 'job':
|
||||
prefix = 'JOB'
|
||||
hyperlink = `/dashboard/production/printjobs/info?printJobId=${id}`
|
||||
hyperlink = `/dashboard/production/jobs/info?jobId=${id}`
|
||||
icon = <JobIcon style={{ paddingTop: '4px' }} />
|
||||
break
|
||||
case 'part':
|
||||
prefix = 'PRT'
|
||||
hyperlink = `/dashboard/management/parts/info?partId=${id}`
|
||||
icon = <PartIcon style={{ paddingTop: '4px' }} />
|
||||
break
|
||||
case 'product':
|
||||
prefix = 'PRD'
|
||||
hyperlink = `/dashboard/management/products/info?productId=${id}`
|
||||
icon = <ProductIcon style={{ paddingTop: '4px' }} />
|
||||
break
|
||||
case 'vendor':
|
||||
prefix = 'VEN'
|
||||
hyperlink = `/dashboard/management/vendors/info?vendorId=${id}`
|
||||
icon = <VendorIcon style={{ paddingTop: '4px' }} />
|
||||
break
|
||||
case 'subjob':
|
||||
prefix = 'SJB'
|
||||
hyperlink = `#`
|
||||
icon = <SubJobIcon style={{ paddingTop: '4px' }} />
|
||||
break
|
||||
case 'filamentstock':
|
||||
prefix = 'FLS'
|
||||
hyperlink = `/dashboard/inventory/filamentstocks/info?filamentStockId=${id}`
|
||||
icon = <FilamentStockIcon style={{ paddingTop: '4px' }} />
|
||||
break
|
||||
case 'stockevent':
|
||||
prefix = 'SEV'
|
||||
hyperlink = `#`
|
||||
icon = <StockEventIcon style={{ paddingTop: '4px' }} />
|
||||
break
|
||||
case 'stockaudit':
|
||||
prefix = 'SAU'
|
||||
hyperlink = `/dashboard/inventory/stockaudits/info?stockAuditId=${id}`
|
||||
icon = <StockAuditIcon style={{ paddingTop: '4px' }} />
|
||||
break
|
||||
case 'partstock':
|
||||
prefix = 'PTS'
|
||||
hyperlink = `/dashboard/management/partstocks/info?partStockId=${id}`
|
||||
icon = <PartStockIcon style={{ paddingTop: '4px' }} />
|
||||
break
|
||||
case 'productstock':
|
||||
prefix = 'PDS'
|
||||
hyperlink = `/dashboard/management/productstocks/info?productStockId=${id}`
|
||||
icon = <ProductStockIcon style={{ paddingTop: '4px' }} />
|
||||
break
|
||||
case 'auditlog':
|
||||
prefix = 'ADL'
|
||||
hyperlink = `#`
|
||||
icon = <AuditLogIcon style={{ paddingTop: '4px' }} />
|
||||
break
|
||||
case 'user':
|
||||
prefix = 'USR'
|
||||
hyperlink = `#`
|
||||
icon = <PersonIcon style={{ paddingTop: '4px' }} />
|
||||
break
|
||||
case 'notetype':
|
||||
prefix = 'NTY'
|
||||
hyperlink = `/dashboard/management/notetypes/info?noteTypeId=${id}`
|
||||
icon = <NoteTypeIcon style={{ paddingTop: '4px' }} />
|
||||
break
|
||||
default:
|
||||
hyperlink = `#`
|
||||
prefix = 'UNK'
|
||||
icon = <QuestionCircleIcon style={{ paddingTop: '4px' }} />
|
||||
}
|
||||
|
||||
id = id.toString().toUpperCase()
|
||||
id = typeof id === 'string' ? id.toString().toUpperCase() : 'XXXXXX'
|
||||
var displayId = prefix + ':' + id
|
||||
var copyId = prefix + ':' + id
|
||||
|
||||
if (longId == false) {
|
||||
if (longId == false || isMobile) {
|
||||
displayId = prefix + ':' + id.toString().slice(-6)
|
||||
}
|
||||
|
||||
@ -99,14 +153,20 @@ const IdText = ({
|
||||
}}
|
||||
>
|
||||
<Text code ellipsis>
|
||||
<Space size={4}>
|
||||
{icon}
|
||||
{displayId}
|
||||
</Space>
|
||||
</Text>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!showHyperlink && (
|
||||
<Text code ellipsis>
|
||||
<Space size={4}>
|
||||
{icon}
|
||||
{displayId}
|
||||
</Space>
|
||||
</Text>
|
||||
)}
|
||||
{showCopy && (
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { Layout, Menu, Flex, Button } from 'antd'
|
||||
import { DashboardOutlined } from '@ant-design/icons'
|
||||
import { DashboardOutlined, CaretDownFilled } from '@ant-design/icons'
|
||||
import FilamentStockIcon from '../../Icons/FilamentStockIcon'
|
||||
import PartStockIcon from '../../Icons/PartStockIcon'
|
||||
import ProductStockIcon from '../../Icons/ProductStockIcon'
|
||||
@ -9,6 +9,7 @@ import StockAuditIcon from '../../Icons/StockAuditIcon'
|
||||
import StockEventIcon from '../../Icons/StockEventIcon'
|
||||
import CollapseSidebarIcon from '../../Icons/CollapseSidebarIcon'
|
||||
import ExpandSidebarIcon from '../../Icons/ExpandSidebarIcon'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
const { Sider } = Layout
|
||||
|
||||
@ -21,6 +22,7 @@ const InventorySidebar = () => {
|
||||
const savedState = sessionStorage.getItem(SIDEBAR_COLLAPSED_KEY)
|
||||
return savedState ? JSON.parse(savedState) : false
|
||||
})
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
useEffect(() => {
|
||||
const pathParts = location.pathname.split('/').filter(Boolean)
|
||||
@ -73,6 +75,20 @@ const InventorySidebar = () => {
|
||||
}
|
||||
]
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Menu
|
||||
mode='horizontal'
|
||||
selectedKeys={[selectedKey]}
|
||||
defaultSelectedKeys={['overview']}
|
||||
items={items}
|
||||
style={{ width: '100%' }}
|
||||
_internalDisableMenuItemTitleTooltip
|
||||
overflowedIndicator={<Button type='text' icon={<CaretDownFilled />} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sider width={250} theme='light' collapsed={collapsed}>
|
||||
<Flex
|
||||
|
||||
@ -72,7 +72,7 @@ const JobState = ({
|
||||
{showId && (
|
||||
<IdText id={job._id} showCopy={false} type='job' longId={false} />
|
||||
)}
|
||||
{showQuantity && <Text>(Quantity: {job.quantity})</Text>}
|
||||
{showQuantity && <Text>({job.quantity})</Text>}
|
||||
{showStatus && (
|
||||
<Space>
|
||||
<Tag color={badgeStatus} style={{ marginRight: 0 }}>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { Layout, Menu, Flex, Button } from 'antd'
|
||||
import { SettingOutlined, AuditOutlined } from '@ant-design/icons'
|
||||
import { CaretDownFilled } from '@ant-design/icons'
|
||||
import FilamentIcon from '../../Icons/FilamentIcon'
|
||||
import PartIcon from '../../Icons/PartIcon'
|
||||
import ProductIcon from '../../Icons/ProductIcon'
|
||||
@ -9,6 +9,10 @@ import VendorIcon from '../../Icons/VendorIcon'
|
||||
import MaterialIcon from '../../Icons/MaterialIcon'
|
||||
import CollapseSidebarIcon from '../../Icons/CollapseSidebarIcon'
|
||||
import ExpandSidebarIcon from '../../Icons/ExpandSidebarIcon'
|
||||
import AuditLogIcon from '../../Icons/AuditLogIcon'
|
||||
import NoteTypeIcon from '../../Icons/NoteTypeIcon'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import SettingsIcon from '../../Icons/SettingsIcon'
|
||||
|
||||
const { Sider } = Layout
|
||||
|
||||
@ -21,6 +25,7 @@ const ManagementSidebar = () => {
|
||||
const savedState = sessionStorage.getItem(SIDEBAR_COLLAPSED_KEY)
|
||||
return savedState ? JSON.parse(savedState) : false
|
||||
})
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
useEffect(() => {
|
||||
const pathParts = location.pathname.split('/').filter(Boolean)
|
||||
@ -60,18 +65,38 @@ const ManagementSidebar = () => {
|
||||
label: <Link to='/dashboard/management/materials'>Materials</Link>,
|
||||
icon: <MaterialIcon />
|
||||
},
|
||||
{
|
||||
key: 'notetypes',
|
||||
label: <Link to='/dashboard/management/notetypes'>Note Types</Link>,
|
||||
icon: <NoteTypeIcon />
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'settings',
|
||||
label: <Link to='/dashboard/management/settings'>Settings</Link>,
|
||||
icon: <SettingOutlined />
|
||||
icon: <SettingsIcon />
|
||||
},
|
||||
{
|
||||
key: 'audit',
|
||||
label: <Link to='/dashboard/management/audit'>Audit Log</Link>,
|
||||
icon: <AuditOutlined />
|
||||
key: 'auditlogs',
|
||||
label: <Link to='/dashboard/management/auditlogs'>Audit Log</Link>,
|
||||
icon: <AuditLogIcon />
|
||||
}
|
||||
]
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Menu
|
||||
mode='horizontal'
|
||||
selectedKeys={[selectedKey]}
|
||||
defaultSelectedKeys={['filaments']}
|
||||
items={items}
|
||||
style={{ width: '100%' }}
|
||||
_internalDisableMenuItemTitleTooltip
|
||||
overflowedIndicator={<Button type='text' icon={<CaretDownFilled />} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sider width={250} theme='light' collapsed={collapsed}>
|
||||
<Flex
|
||||
|
||||
89
src/components/Dashboard/common/MarkdownDisplay.jsx
Normal 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
|
||||
@ -27,7 +27,7 @@ const PartsTable = ({ data = [], loading = false, showHeader = false }) => {
|
||||
title: 'ID',
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 165,
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type={'part'} showHyperlink={true} />
|
||||
}
|
||||
]
|
||||
|
||||
@ -13,9 +13,12 @@ import ReloadIcon from '../../Icons/ReloadIcon'
|
||||
|
||||
import config from '../../../config'
|
||||
|
||||
const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
|
||||
const PrinterJobsTree = ({
|
||||
subJobs: initialSubJobs,
|
||||
loading: initialLoading
|
||||
}) => {
|
||||
const [subJobs, setSubJobs] = useState(initialSubJobs || [])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [treeLoading, setTreeLoading] = useState(initialLoading)
|
||||
const [error, setError] = useState(null)
|
||||
const { socket } = useContext(SocketContext)
|
||||
const [messageApi] = message.useMessage()
|
||||
@ -25,9 +28,9 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
|
||||
|
||||
const handleNodeClick = (selectedKeys) => {
|
||||
const key = selectedKeys[0]
|
||||
if (key.startsWith('printjob-')) {
|
||||
const jobId = key.replace('printjob-', '')
|
||||
navigate(`/dashboard/production/printjobs/info?printJobId=${jobId}`)
|
||||
if (key.startsWith('job-')) {
|
||||
const jobId = key.replace('job-', '')
|
||||
navigate(`/dashboard/production/jobs/info?jobId=${jobId}`)
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,32 +41,31 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
|
||||
return
|
||||
}
|
||||
|
||||
// Group subjobs by printJob
|
||||
const printJobGroups = subJobsData.reduce((acc, subJob) => {
|
||||
const printJobId = subJob.printJob._id
|
||||
if (!acc[printJobId]) {
|
||||
acc[printJobId] = {
|
||||
printJob: subJob.printJob,
|
||||
// Group subjobs by job
|
||||
const jobGroups = subJobsData.reduce((acc, subJob) => {
|
||||
const jobId = subJob.job._id
|
||||
if (!acc[jobId]) {
|
||||
acc[jobId] = {
|
||||
job: subJob.job,
|
||||
subJobs: []
|
||||
}
|
||||
}
|
||||
acc[printJobId].subJobs.push(subJob)
|
||||
acc[jobId].subJobs.push(subJob)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// Create tree nodes for each printJob
|
||||
const printJobNodes = Object.values(printJobGroups).map(
|
||||
({ printJob, subJobs }) => {
|
||||
setExpandedKeys((prev) => [...prev, `printjob-${printJob._id}`])
|
||||
// Create tree nodes for each job
|
||||
const jobNodes = Object.values(jobGroups).map(({ job, subJobs }) => {
|
||||
setExpandedKeys((prev) => [...prev, `job-${job._id}`])
|
||||
return {
|
||||
title: (
|
||||
<Space size={5}>
|
||||
<JobIcon />
|
||||
{'Job'}
|
||||
<JobState job={printJob} />
|
||||
<JobState job={job} />
|
||||
</Space>
|
||||
),
|
||||
key: `printjob-${printJob._id}`,
|
||||
key: `job-${job._id}`,
|
||||
children: subJobs.map((subJob) => ({
|
||||
title: (
|
||||
<Space>
|
||||
@ -80,10 +82,9 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
|
||||
isLeaf: true
|
||||
}))
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
setTreeData(printJobNodes)
|
||||
setTreeData(jobNodes)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@ -94,8 +95,8 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
|
||||
const initializeData = async () => {
|
||||
if (!initialSubJobs) {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await axios.get(`${config.backendUrl}/printjobs`, {
|
||||
setTreeLoading(true)
|
||||
const response = await axios.get(`${config.backendUrl}/jobs`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
withCredentials: true
|
||||
})
|
||||
@ -106,7 +107,7 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
|
||||
setError('Failed to fetch sub jobs')
|
||||
messageApi.error('Failed to fetch sub jobs')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setTreeLoading(false)
|
||||
}
|
||||
} else {
|
||||
setSubJobs(initialSubJobs)
|
||||
@ -142,14 +143,6 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
|
||||
}
|
||||
}, [initialSubJobs, socket])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Space
|
||||
@ -165,6 +158,7 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Spin indicator={<LoadingOutlined />} spinning={treeLoading}>
|
||||
<Card>
|
||||
<Tree
|
||||
treeData={treeData}
|
||||
@ -174,6 +168,7 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
|
||||
showLine={true}
|
||||
/>
|
||||
</Card>
|
||||
</Spin>
|
||||
)
|
||||
}
|
||||
|
||||
@ -183,7 +178,7 @@ PrinterJobsTree.propTypes = {
|
||||
state: PropTypes.object.isRequired,
|
||||
_id: PropTypes.string.isRequired,
|
||||
printer: PropTypes.string.isRequired,
|
||||
printJob: PropTypes.shape({
|
||||
job: PropTypes.shape({
|
||||
state: PropTypes.object.isRequired,
|
||||
_id: PropTypes.string.isRequired,
|
||||
printers: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
@ -199,7 +194,8 @@ PrinterJobsTree.propTypes = {
|
||||
createdAt: PropTypes.string.isRequired,
|
||||
updatedAt: PropTypes.string.isRequired
|
||||
})
|
||||
)
|
||||
),
|
||||
loading: PropTypes.bool
|
||||
}
|
||||
|
||||
export default PrinterJobsTree
|
||||
|
||||
230
src/components/Dashboard/common/PrinterMiscPanel.jsx
Normal 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
|
||||
@ -6,8 +6,9 @@ import {
|
||||
Space,
|
||||
Collapse,
|
||||
InputNumber,
|
||||
Switch,
|
||||
Descriptions
|
||||
Descriptions,
|
||||
Button,
|
||||
Tag
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
||||
import { SocketContext } from '../context/SocketContext'
|
||||
@ -46,6 +47,10 @@ const PrinterPositionPanel = ({
|
||||
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const { socket } = useContext(SocketContext)
|
||||
const [speedFactor, setSpeedFactor] = useState(positionData.speed_factor)
|
||||
const [extrudeFactor, setExtrudeFactor] = useState(
|
||||
positionData.extrude_factor
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const params = {
|
||||
@ -57,7 +62,6 @@ const PrinterPositionPanel = ({
|
||||
}
|
||||
|
||||
const notifyPositionStatusUpdate = (statusUpdate) => {
|
||||
console.log(statusUpdate)
|
||||
if (statusUpdate?.toolhead) {
|
||||
setPositionData((prevData) => ({
|
||||
...prevData,
|
||||
@ -72,44 +76,44 @@ const PrinterPositionPanel = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (!initialized && socket) {
|
||||
if (!initialized && socket.connected) {
|
||||
setInitialized(true)
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to socket!')
|
||||
socket.emit('printer.objects.subscribe', params)
|
||||
socket.emit('printer.objects.query', params)
|
||||
})
|
||||
|
||||
console.log('Subscribing to position data')
|
||||
socket.emit('printer.objects.subscribe', params)
|
||||
socket.emit('printer.objects.query', params)
|
||||
socket.on('notify_status_update', notifyPositionStatusUpdate)
|
||||
}
|
||||
|
||||
setSpeedFactor(positionData.speed_factor)
|
||||
setExtrudeFactor(positionData.extrude_factor)
|
||||
|
||||
return () => {
|
||||
if (socket && initialized && shouldUnsubscribe) {
|
||||
console.log('Unsubscribing...')
|
||||
if (socket.connected && initialized && shouldUnsubscribe) {
|
||||
socket.off('notify_status_update', notifyPositionStatusUpdate)
|
||||
socket.emit('printer.objects.unsubscribe', params)
|
||||
}
|
||||
}
|
||||
}, [socket, initialized, printerId, shouldUnsubscribe])
|
||||
|
||||
const handleSetSpeedFactor = (value) => {
|
||||
const handleSetSpeedFactor = () => {
|
||||
if (socket) {
|
||||
socket.emit('printer.gcode.script', {
|
||||
printerId,
|
||||
script: `M220 S${value * 100}`
|
||||
script: `M220 S${speedFactor * 100}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetExtrudeFactor = (value) => {
|
||||
const handleSetExtrudeFactor = () => {
|
||||
if (socket) {
|
||||
socket.emit('printer.gcode.script', {
|
||||
printerId,
|
||||
script: `M221 S${value * 100}`
|
||||
script: `M221 S${extrudeFactor * 100}`
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -181,49 +185,69 @@ const PrinterPositionPanel = ({
|
||||
{positionData.position[3].toFixed(2)}mm
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Descriptions column={1} size='small' bordered>
|
||||
<Descriptions.Item label='Current Speed'>
|
||||
{positionData.speed}mm/s
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{showControls && (
|
||||
<>
|
||||
<Space direction='vertical' style={{ width: '100%' }}>
|
||||
<Space direction='horizontal'>
|
||||
<Text>Speed Factor:</Text>
|
||||
<Space.Compact block size='small'>
|
||||
<InputNumber
|
||||
value={positionData.speed_factor}
|
||||
value={speedFactor.toFixed(2)}
|
||||
min={0.1}
|
||||
max={2}
|
||||
step={0.1}
|
||||
style={{ width: '100px' }}
|
||||
onChange={(value) => handleSetSpeedFactor(value)}
|
||||
onChange={(value) => setSpeedFactor(value)}
|
||||
onPressEnter={handleSetSpeedFactor}
|
||||
size='small'
|
||||
/>
|
||||
<Button
|
||||
type='default'
|
||||
style={{ width: 40 }}
|
||||
onClick={handleSetSpeedFactor}
|
||||
>
|
||||
Set
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Space>
|
||||
|
||||
<Space direction='horizontal'>
|
||||
<Text>Extrude Factor:</Text>
|
||||
<Space.Compact block size='small'>
|
||||
<InputNumber
|
||||
value={positionData.extrude_factor}
|
||||
value={extrudeFactor.toFixed(2)}
|
||||
min={0.1}
|
||||
max={2}
|
||||
step={0.1}
|
||||
style={{ width: '100px' }}
|
||||
onChange={(value) => handleSetExtrudeFactor(value)}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Space direction='horizontal'>
|
||||
<Text>Absolute Coordinates:</Text>
|
||||
<Switch
|
||||
checked={positionData.absolute_coordinates}
|
||||
disabled={true}
|
||||
onChange={(value) => setExtrudeFactor(value)}
|
||||
onPressEnter={handleSetExtrudeFactor}
|
||||
size='small'
|
||||
/>
|
||||
<Button
|
||||
type='default'
|
||||
style={{ width: 40 }}
|
||||
onClick={handleSetExtrudeFactor}
|
||||
>
|
||||
Set
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Space>
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
<Descriptions column={1} size='small' bordered>
|
||||
<Descriptions.Item label='Current Speed'>
|
||||
{positionData.speed.toFixed(2)}mm/s
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Absolute Coordinates'>
|
||||
{positionData.absolute_coordinates ? (
|
||||
<Tag color='green'>Yes</Tag>
|
||||
) : (
|
||||
<Tag color='red'>No</Tag>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Flex>
|
||||
|
||||
{showMoreInfo && (
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// PrinterSelect.js
|
||||
import PropTypes from 'prop-types'
|
||||
import { TreeSelect, message, Tag } from 'antd'
|
||||
import React, { useEffect, useState, useContext } from 'react'
|
||||
import React, { useEffect, useState, useContext, useCallback } from 'react'
|
||||
import axios from 'axios'
|
||||
import PrinterState from './PrinterState'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
@ -16,7 +16,7 @@ const PrinterSelect = ({ onChange, disabled, checkable, value = [] }) => {
|
||||
|
||||
const { authenticated } = useContext(AuthContext)
|
||||
|
||||
const fetchPrintersTreeData = async () => {
|
||||
const fetchPrintersTreeData = useCallback(async () => {
|
||||
if (!authenticated) {
|
||||
return
|
||||
}
|
||||
@ -41,11 +41,10 @@ const PrinterSelect = ({ onChange, disabled, checkable, value = [] }) => {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [authenticated, messageApi])
|
||||
|
||||
const generatePrinterItems = async () => {
|
||||
const generatePrinterItems = useCallback(async () => {
|
||||
const printerData = await fetchPrintersTreeData()
|
||||
|
||||
setPrintersData(printerData)
|
||||
|
||||
// Create a map to store tags and their printers
|
||||
@ -92,7 +91,7 @@ const PrinterSelect = ({ onChange, disabled, checkable, value = [] }) => {
|
||||
return [...filtered, newNode]
|
||||
})
|
||||
})
|
||||
}
|
||||
}, [fetchPrintersTreeData])
|
||||
|
||||
const handleOnChange = (value, selectedOptions) => {
|
||||
if (checkable) {
|
||||
@ -113,18 +112,10 @@ const PrinterSelect = ({ onChange, disabled, checkable, value = [] }) => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
if (Array.isArray(value)) {
|
||||
setDefaultValue(value)
|
||||
} else {
|
||||
setDefaultValue([value])
|
||||
}
|
||||
}
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
if (authenticated) {
|
||||
generatePrinterItems()
|
||||
}, [])
|
||||
}
|
||||
}, [authenticated, generatePrinterItems])
|
||||
|
||||
return (
|
||||
<TreeSelect
|
||||
@ -135,18 +126,18 @@ const PrinterSelect = ({ onChange, disabled, checkable, value = [] }) => {
|
||||
treeDefaultExpandAll
|
||||
treeCheckable={checkable}
|
||||
treeNodeFilterProp='title'
|
||||
placeholder='Select printer'
|
||||
placeholder='Select Printer'
|
||||
style={{ width: '100%' }}
|
||||
value={
|
||||
checkable ? defaultValue.map((item) => item._id) : defaultValue[0]?._id
|
||||
checkable ? defaultValue.map((item) => item._id) : defaultValue?._id
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
PrinterSelect.propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
disabled: PropTypes.bool.isRequired,
|
||||
onChange: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
checkable: PropTypes.bool,
|
||||
value: PropTypes.oneOfType([PropTypes.object, PropTypes.array])
|
||||
}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { Layout, Menu, Flex, Button } from 'antd'
|
||||
import { DashboardOutlined } from '@ant-design/icons'
|
||||
import { DashboardOutlined, CaretDownFilled } from '@ant-design/icons'
|
||||
import GCodeFileIcon from '../../Icons/GCodeFileIcon'
|
||||
import JobIcon from '../../Icons/JobIcon'
|
||||
import PrinterIcon from '../../Icons/PrinterIcon'
|
||||
import CollapseSidebarIcon from '../../Icons/CollapseSidebarIcon'
|
||||
import ExpandSidebarIcon from '../../Icons/ExpandSidebarIcon'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
const { Sider } = Layout
|
||||
|
||||
@ -19,6 +20,7 @@ const ProductionSidebar = () => {
|
||||
const savedState = sessionStorage.getItem(SIDEBAR_COLLAPSED_KEY)
|
||||
return savedState ? JSON.parse(savedState) : false
|
||||
})
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
useEffect(() => {
|
||||
const pathParts = location.pathname.split('/').filter(Boolean)
|
||||
@ -45,8 +47,8 @@ const ProductionSidebar = () => {
|
||||
icon: <PrinterIcon />
|
||||
},
|
||||
{
|
||||
key: 'printjobs',
|
||||
label: <Link to='/dashboard/production/printjobs'>Print Jobs</Link>,
|
||||
key: 'jobs',
|
||||
label: <Link to='/dashboard/production/jobs'>Print Jobs</Link>,
|
||||
icon: <JobIcon />
|
||||
},
|
||||
{
|
||||
@ -55,6 +57,20 @@ const ProductionSidebar = () => {
|
||||
icon: <GCodeFileIcon />
|
||||
}
|
||||
]
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Menu
|
||||
mode='horizontal'
|
||||
selectedKeys={[selectedKey]}
|
||||
defaultSelectedKeys={['overview']}
|
||||
items={items}
|
||||
_internalDisableMenuItemTitleTooltip
|
||||
overflowedIndicator={<Button type='text' icon={<CaretDownFilled />} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sider width={250} theme='light' collapsed={collapsed}>
|
||||
<Flex
|
||||
|
||||
67
src/components/Dashboard/common/RichTextEditor.jsx
Normal 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
|
||||
@ -14,14 +14,14 @@ import ReloadIcon from '../../Icons/ReloadIcon'
|
||||
|
||||
import config from '../../../config'
|
||||
|
||||
const SubJobsTree = ({ printJobData }) => {
|
||||
const SubJobsTree = ({ jobData, loading }) => {
|
||||
const [treeData, setTreeData] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [treeLoading, setTreeLoading] = useState(loading)
|
||||
const [error, setError] = useState(null)
|
||||
const { socket } = useContext(SocketContext)
|
||||
const [messageApi] = message.useMessage()
|
||||
const [expandedKeys, setExpandedKeys] = useState([])
|
||||
const [currentPrintJobData, setCurrentPrintJobData] = useState(null)
|
||||
const [currentJobData, setCurrentJobData] = useState(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const buildTreeData = useCallback(
|
||||
@ -83,29 +83,29 @@ const SubJobsTree = ({ printJobData }) => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
buildTreeData(currentPrintJobData)
|
||||
}, [currentPrintJobData])
|
||||
buildTreeData(currentJobData)
|
||||
}, [currentJobData])
|
||||
|
||||
useEffect(() => {
|
||||
const initializeData = async () => {
|
||||
if (!printJobData) {
|
||||
if (!jobData) {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await axios.get(`${config.backendUrl}/printjobs`, {
|
||||
setTreeLoading(true)
|
||||
const response = await axios.get(`${config.backendUrl}/jobs`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
withCredentials: true
|
||||
})
|
||||
if (response.data) {
|
||||
setCurrentPrintJobData(response.data)
|
||||
setCurrentJobData(response.data)
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to fetch print job details')
|
||||
messageApi.error('Failed to fetch print job details')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setTreeLoading(false)
|
||||
}
|
||||
} else {
|
||||
setCurrentPrintJobData(printJobData)
|
||||
setCurrentJobData(jobData)
|
||||
}
|
||||
}
|
||||
|
||||
@ -115,7 +115,7 @@ const SubJobsTree = ({ printJobData }) => {
|
||||
if (socket) {
|
||||
socket.on('notify_deployment_update', (updateData) => {
|
||||
console.log('Received deployment update:', updateData)
|
||||
setCurrentPrintJobData((prevData) => {
|
||||
setCurrentJobData((prevData) => {
|
||||
if (!prevData) return prevData
|
||||
|
||||
// Handle printer updates
|
||||
@ -152,7 +152,7 @@ const SubJobsTree = ({ printJobData }) => {
|
||||
// Handle sub-job updates
|
||||
if (updateData.subJobId) {
|
||||
console.log('Received subjob update:', updateData)
|
||||
setCurrentPrintJobData((prevData) => {
|
||||
setCurrentJobData((prevData) => {
|
||||
if (!prevData) return prevData
|
||||
return {
|
||||
...prevData,
|
||||
@ -178,15 +178,7 @@ const SubJobsTree = ({ printJobData }) => {
|
||||
socket.off('notify_deployment_update')
|
||||
}
|
||||
}
|
||||
}, [printJobData, socket])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}, [jobData, socket])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@ -195,10 +187,7 @@ const SubJobsTree = ({ printJobData }) => {
|
||||
style={{ width: '100%', textAlign: 'center' }}
|
||||
>
|
||||
<p>{error}</p>
|
||||
<Button
|
||||
icon={<ReloadIcon />}
|
||||
onClick={() => buildTreeData(printJobData)}
|
||||
>
|
||||
<Button icon={<ReloadIcon />} onClick={() => buildTreeData(jobData)}>
|
||||
Retry
|
||||
</Button>
|
||||
</Space>
|
||||
@ -206,7 +195,8 @@ const SubJobsTree = ({ printJobData }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Spin indicator={<LoadingOutlined />} spinning={treeLoading}>
|
||||
<Card style={{ minHeight: 160 }}>
|
||||
<Tree
|
||||
treeData={treeData}
|
||||
expandedKeys={expandedKeys}
|
||||
@ -215,11 +205,13 @@ const SubJobsTree = ({ printJobData }) => {
|
||||
showLine={true}
|
||||
/>
|
||||
</Card>
|
||||
</Spin>
|
||||
)
|
||||
}
|
||||
|
||||
SubJobsTree.propTypes = {
|
||||
printJobData: PropTypes.object.isRequired
|
||||
jobData: PropTypes.object.isRequired,
|
||||
loading: PropTypes.bool
|
||||
}
|
||||
|
||||
export default SubJobsTree
|
||||
|
||||
@ -215,7 +215,7 @@ const AuthProvider = ({ children }) => {
|
||||
open={showSessionExpiredModal}
|
||||
onOk={handleSessionExpiredModalOk}
|
||||
okText='Log In'
|
||||
style={{ maxWidth: 430 }}
|
||||
style={{ maxWidth: 430, top: '50%', transform: 'translateY(-50%)' }}
|
||||
closable={false}
|
||||
centered
|
||||
maskClosable={false}
|
||||
@ -244,9 +244,8 @@ const AuthProvider = ({ children }) => {
|
||||
loginWithSSO()
|
||||
}}
|
||||
okText='Log In'
|
||||
style={{ maxWidth: 430 }}
|
||||
style={{ maxWidth: 430, top: '50%', transform: 'translateY(-50%)' }}
|
||||
closable={false}
|
||||
centered
|
||||
maskClosable={false}
|
||||
footer={[
|
||||
<Button
|
||||
|
||||
176
src/components/Dashboard/hooks/useTableData.js
Normal 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
|
||||
}
|
||||
}
|
||||
121
src/components/Dashboard/hooks/useTableScroll.js
Normal 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 }
|
||||
}
|
||||
7
src/components/Icons/AuditLogIcon.jsx
Normal 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
|
||||
7
src/components/Icons/NoteTypeIcon.jsx
Normal 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
|
||||
7
src/components/Icons/SettingsIcon.jsx
Normal 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
|
||||
9
src/components/Logos/FarmControlLogoSmall.jsx
Normal 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
|
||||
@ -1,12 +1,23 @@
|
||||
// PrivateRoute.js
|
||||
import PropTypes from 'prop-types'
|
||||
import React, { useContext } from 'react'
|
||||
import React, { useContext, useState, useEffect } from 'react'
|
||||
import { AuthContext } from './Dashboard/context/AuthContext'
|
||||
import AuthLoading from './App/AppLoading'
|
||||
import { useThemeContext } from './Dashboard/context/ThemeContext'
|
||||
|
||||
const PrivateRoute = ({ component: Component }) => {
|
||||
const { isDarkMode } = useThemeContext()
|
||||
const { authenticated, loading, showSessionExpiredModal } =
|
||||
useContext(AuthContext)
|
||||
const [fadeIn, setFadeIn] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
// Small delay to ensure smooth transition
|
||||
const timer = setTimeout(() => setFadeIn(true), 50)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [loading])
|
||||
|
||||
// Show loading state while auth state is being determined
|
||||
if (loading) {
|
||||
@ -14,10 +25,21 @@ const PrivateRoute = ({ component: Component }) => {
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
return authenticated || showSessionExpiredModal ? (
|
||||
return (
|
||||
<div style={{ background: isDarkMode ? '#000000' : '#ffffff' }}>
|
||||
<div
|
||||
style={{
|
||||
opacity: fadeIn ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease-in-out'
|
||||
}}
|
||||
>
|
||||
{authenticated || showSessionExpiredModal ? (
|
||||
<Component />
|
||||
) : (
|
||||
<Component />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
const config = {
|
||||
development: {
|
||||
backendUrl: 'http://localhost:8080',
|
||||
wsUrl: 'ws://localhost:8081'
|
||||
backendUrl: 'http://192.168.68.53:8080',
|
||||
wsUrl: 'ws://192.168.68.53:8081'
|
||||
},
|
||||
production: {
|
||||
backendUrl: 'http://localhost:8080', // Replace with your production backend URL
|
||||
|
||||
@ -1,15 +1,5 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
.ant-modal-mask {
|
||||
|
||||