Enhanced UI with new view modes for inventory components, added audit log displays, and improved loading states. Updated configuration for production URLs and removed unused components. Refactored styles for better consistency.

This commit is contained in:
Tom Butcher 2025-06-28 00:17:58 +01:00
parent bd5085cded
commit f220d81722
97 changed files with 8650 additions and 3748 deletions

View File

@ -15,6 +15,10 @@
background-color: transparent !important; background-color: transparent !important;
} }
code {
margin: 0;
}
.App { .App {
text-align: center; text-align: center;
} }
@ -53,3 +57,28 @@
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
.markdown-display > .ant-space-item *:first-child {
margin-top: 0;
}
.markdown-display > .ant-space-item *:last-child {
margin-bottom: 0;
}
.markdown-display > .ant-space-item h1,
.markdown-display > .ant-space-item h2,
.markdown-display > .ant-space-item h3,
.markdown-display > .ant-space-item h4,
.markdown-display > .ant-space-item h5,
.markdown-display > .ant-space-item h6 {
margin-bottom: 0.15em;
}
.idtext .ant-popover-inner {
padding: 0 !important;
}
.ant-popover-inner:has(.spotlight-tooltip) {
padding: 0 !important;
}

View File

@ -41,7 +41,7 @@ import PartStocks from './components/Dashboard/Inventory/PartStocks.jsx'
import StockAudits from './components/Dashboard/Inventory/StockAudits.jsx' import StockAudits from './components/Dashboard/Inventory/StockAudits.jsx'
import StockAuditInfo from './components/Dashboard/Inventory/StockAudits/StockAuditInfo.jsx' import StockAuditInfo from './components/Dashboard/Inventory/StockAudits/StockAuditInfo.jsx'
import Dashboard from './components/Dashboard/common/Dashboard' import Dashboard from './components/Dashboard/Dashboard.jsx'
import PrivateRoute from './components/PrivateRoute' import PrivateRoute from './components/PrivateRoute'
import './App.css' import './App.css'
import { SocketProvider } from './components/Dashboard/context/SocketContext.js' import { SocketProvider } from './components/Dashboard/context/SocketContext.js'
@ -59,6 +59,12 @@ import {
import AppError from './components/App/AppError' import AppError from './components/App/AppError'
import NoteTypes from './components/Dashboard/Management/NoteTypes.jsx' import NoteTypes from './components/Dashboard/Management/NoteTypes.jsx'
import NoteTypeInfo from './components/Dashboard/Management/NoteTypes/NoteTypeInfo.jsx' import NoteTypeInfo from './components/Dashboard/Management/NoteTypes/NoteTypeInfo.jsx'
import SessionStorage from './components/Dashboard/Developer/SessionStorage.jsx'
import AuthContextDebug from './components/Dashboard/Developer/AuthContextDebug.jsx'
import SocketContextDebug from './components/Dashboard/Developer/SocketContextDebug.jsx'
import { NotificationProvider } from './components/Dashboard/context/NotificationContext.js'
import Users from './components/Dashboard/Management/Users.jsx'
import UserInfo from './components/Dashboard/Management/Users/UserInfo.jsx'
const AppContent = () => { const AppContent = () => {
const { themeConfig } = useThemeContext() const { themeConfig } = useThemeContext()
@ -67,9 +73,10 @@ const AppContent = () => {
<ConfigProvider theme={themeConfig}> <ConfigProvider theme={themeConfig}>
<App> <App>
<AuthProvider> <AuthProvider>
<SocketProvider>
<SpotlightProvider>
<Router> <Router>
<SocketProvider>
<NotificationProvider>
<SpotlightProvider>
<Routes> <Routes>
<Route <Route
path='/' path='/'
@ -93,7 +100,10 @@ const AppContent = () => {
path='production/overview' path='production/overview'
element={<ProductionOverview />} element={<ProductionOverview />}
/> />
<Route path='production/printers' element={<Printers />} /> <Route
path='production/printers'
element={<Printers />}
/>
<Route <Route
path='production/printers/control' path='production/printers/control'
element={<ControlPrinter />} element={<ControlPrinter />}
@ -103,7 +113,10 @@ const AppContent = () => {
element={<PrinterInfo />} element={<PrinterInfo />}
/> />
<Route path='production/jobs' element={<Jobs />} /> <Route path='production/jobs' element={<Jobs />} />
<Route path='production/jobs/info' element={<JobInfo />} /> <Route
path='production/jobs/info'
element={<JobInfo />}
/>
<Route <Route
path='production/gcodefiles' path='production/gcodefiles'
element={<GCodeFiles />} element={<GCodeFiles />}
@ -153,12 +166,19 @@ const AppContent = () => {
path='management/parts/info' path='management/parts/info'
element={<PartInfo />} element={<PartInfo />}
/> />
<Route path='management/products' element={<Products />} /> <Route
path='management/products'
element={<Products />}
/>
<Route <Route
path='management/products/info' path='management/products/info'
element={<ProductInfo />} element={<ProductInfo />}
/> />
<Route path='management/vendors' element={<Vendors />} /> <Route path='management/vendors' element={<Vendors />} />
<Route
path='management/users/info'
element={<UserInfo />}
/>
<Route <Route
path='management/vendors/info' path='management/vendors/info'
element={<VendorInfo />} element={<VendorInfo />}
@ -171,15 +191,31 @@ const AppContent = () => {
path='management/notetypes' path='management/notetypes'
element={<NoteTypes />} element={<NoteTypes />}
/> />
<Route path='management/users' element={<Users />} />
<Route <Route
path='management/notetypes/info' path='management/notetypes/info'
element={<NoteTypeInfo />} element={<NoteTypeInfo />}
/> />
<Route path='management/settings' element={<Settings />} /> <Route
path='management/settings'
element={<Settings />}
/>
<Route <Route
path='management/auditlogs' path='management/auditlogs'
element={<AuditLogs />} element={<AuditLogs />}
/> />
<Route
path='developer/sessionstorage'
element={<SessionStorage />}
/>
<Route
path='developer/authcontextdebug'
element={<AuthContextDebug />}
/>
<Route
path='developer/socketcontextdebug'
element={<SocketContextDebug />}
/>
</Route> </Route>
<Route <Route
path='*' path='*'
@ -191,9 +227,10 @@ const AppContent = () => {
} }
/> />
</Routes> </Routes>
</Router>
</SpotlightProvider> </SpotlightProvider>
</NotificationProvider>
</SocketProvider> </SocketProvider>
</Router>
</AuthProvider> </AuthProvider>
</App> </App>
</ConfigProvider> </ConfigProvider>

View File

@ -1 +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> <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="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(30.624 23.775)scale(.55961)"/><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" transform="translate(.142 -.41)scale(.95254)"/></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -1,9 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg 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;"> <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)"> <g transform="matrix(1,0,0,1,0,-0.603074)">
<rect x="0" y="0" width="54.469" height="65.719" style="fill-opacity:0;"/> <g transform="matrix(0.55961,0,0,0.55961,30.6237,24.378)">
<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;"/> <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> </g>
<g transform="matrix(0.952541,0,0,0.952541,0.142378,0.192271)">
<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;"/> <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;"/>
</g>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 64 64"><path d="M10.251 58.341h53.512c6.77 0 10.251-3.455 10.251-10.123V10.16c0-6.674-3.481-10.149-10.251-10.149H10.251C3.506.011 0 3.486 0 10.16v38.058c0 6.668 3.506 10.123 10.251 10.123m.355-6.122c-2.905 0-4.484-1.505-4.484-4.541V10.694c0-3.036 1.579-4.56 4.484-4.56h52.802c2.88 0 4.483 1.524 4.483 4.56v36.984c0 3.036-1.603 4.541-4.483 4.541z" style="fill-rule:nonzero" transform="translate(3 9.136)scale(.78364)"/><path d="M14.152 24.679c-2.691 1.627-.403 5.734 2.549 3.883l8.837-5.535c1.921-1.209 1.833-4.068 0-5.221l-8.837-5.523c-2.947-1.857-5.265 2.23-2.555 3.871l7.065 4.255zm13.137 3.689a2.22 2.22 0 0 0 2.23 2.225h11.727a2.215 2.215 0 0 0 2.219-2.225c0-1.255-.971-2.251-2.219-2.251H29.519c-1.248 0-2.23.996-2.23 2.251" style="fill-rule:nonzero" transform="translate(3 9.136)scale(.78364)"/></svg>

After

Width:  |  Height:  |  Size: 966 B

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<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.783639,0,0,0.783639,3,9.13638)">
<path d="M10.251,58.341L63.763,58.341C70.533,58.341 74.014,54.886 74.014,48.218L74.014,10.16C74.014,3.486 70.533,0.011 63.763,0.011L10.251,0.011C3.506,0.011 0,3.486 0,10.16L0,48.218C0,54.886 3.506,58.341 10.251,58.341ZM10.606,52.219C7.701,52.219 6.122,50.714 6.122,47.678L6.122,10.694C6.122,7.658 7.701,6.134 10.606,6.134L63.408,6.134C66.288,6.134 67.891,7.658 67.891,10.694L67.891,47.678C67.891,50.714 66.288,52.219 63.408,52.219L10.606,52.219Z" style="fill-rule:nonzero;"/>
<path d="M14.152,24.679C11.461,26.306 13.749,30.413 16.701,28.562L25.538,23.027C27.459,21.818 27.371,18.959 25.538,17.806L16.701,12.283C13.754,10.426 11.436,14.513 14.146,16.154L21.211,20.409L14.152,24.679ZM27.289,28.368C27.289,29.585 28.265,30.593 29.519,30.593L41.246,30.593C42.494,30.593 43.465,29.585 43.465,28.368C43.465,27.113 42.494,26.117 41.246,26.117L29.519,26.117C28.271,26.117 27.289,27.113 27.289,28.368Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 64 64"><path d="M38.339 58.341h13.274c4.503 0 6.717-2.188 6.717-6.825V38.453c0-4.631-2.214-6.819-6.717-6.819H38.339c-4.528 0-6.736 2.188-6.736 6.819v13.063c0 4.637 2.208 6.825 6.736 6.825m.04-5.137c-1.119 0-1.639-.551-1.639-1.645V38.442c0-1.119.52-1.671 1.639-1.671h13.194c1.1 0 1.62.552 1.62 1.671v13.117c0 1.094-.52 1.645-1.62 1.645zM6.717 58.341h13.299c4.503 0 6.712-2.188 6.712-6.825V38.453c0-4.631-2.209-6.819-6.712-6.819H6.717C2.214 31.634 0 33.822 0 38.453v13.063c0 4.637 2.214 6.825 6.717 6.825m.04-5.137c-1.1 0-1.62-.551-1.62-1.645V38.442c0-1.119.52-1.671 1.62-1.671h13.194c1.093 0 1.639.552 1.639 1.671v13.117q0 1.643-1.639 1.645zm31.582-26.465h13.274c4.503 0 6.717-2.188 6.717-6.82V6.831c0-4.612-2.214-6.82-6.717-6.82H38.339c-4.528 0-6.736 2.208-6.736 6.82v13.088c0 4.632 2.208 6.82 6.736 6.82m.04-5.138c-1.119 0-1.639-.545-1.639-1.696V6.814c0-1.12.52-1.665 1.639-1.665h13.194c1.1 0 1.62.545 1.62 1.665v13.091c0 1.151-.52 1.696-1.62 1.696zM6.717 26.739h13.299c4.503 0 6.712-2.188 6.712-6.82V6.831c0-4.612-2.209-6.82-6.712-6.82H6.717C2.214.011 0 2.219 0 6.831v13.088c0 4.632 2.214 6.82 6.717 6.82m.04-5.138c-1.1 0-1.62-.545-1.62-1.696V6.814c0-1.12.52-1.665 1.62-1.665h13.194c1.093 0 1.639.545 1.639 1.665v13.091c0 1.151-.546 1.696-1.639 1.696z" style="fill-rule:nonzero" transform="translate(3 2.989)scale(.99434)"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.994343,0,0,0.994343,1.35287,1.25405)">
<g transform="matrix(1,0,0,1,1.6565,1.74465)">
<path d="M38.339,58.341L51.613,58.341C56.116,58.341 58.33,56.153 58.33,51.516L58.33,38.453C58.33,33.822 56.116,31.634 51.613,31.634L38.339,31.634C33.811,31.634 31.603,33.822 31.603,38.453L31.603,51.516C31.603,56.153 33.811,58.341 38.339,58.341ZM38.379,53.204C37.26,53.204 36.74,52.653 36.74,51.559L36.74,38.442C36.74,37.323 37.26,36.771 38.379,36.771L51.573,36.771C52.673,36.771 53.193,37.323 53.193,38.442L53.193,51.559C53.193,52.653 52.673,53.204 51.573,53.204L38.379,53.204Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,1.6565,1.74465)">
<path d="M6.717,58.341L20.016,58.341C24.519,58.341 26.728,56.153 26.728,51.516L26.728,38.453C26.728,33.822 24.519,31.634 20.016,31.634L6.717,31.634C2.214,31.634 0,33.822 0,38.453L0,51.516C0,56.153 2.214,58.341 6.717,58.341ZM6.757,53.204C5.657,53.204 5.137,52.653 5.137,51.559L5.137,38.442C5.137,37.323 5.657,36.771 6.757,36.771L19.951,36.771C21.044,36.771 21.59,37.323 21.59,38.442L21.59,51.559C21.59,52.653 21.044,53.204 19.951,53.204L6.757,53.204Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,1.6565,1.74465)">
<path d="M38.339,26.739L51.613,26.739C56.116,26.739 58.33,24.551 58.33,19.919L58.33,6.831C58.33,2.219 56.116,0.011 51.613,0.011L38.339,0.011C33.811,0.011 31.603,2.219 31.603,6.831L31.603,19.919C31.603,24.551 33.811,26.739 38.339,26.739ZM38.379,21.601C37.26,21.601 36.74,21.056 36.74,19.905L36.74,6.814C36.74,5.694 37.26,5.149 38.379,5.149L51.573,5.149C52.673,5.149 53.193,5.694 53.193,6.814L53.193,19.905C53.193,21.056 52.673,21.601 51.573,21.601L38.379,21.601Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,1.6565,1.74465)">
<path d="M6.717,26.739L20.016,26.739C24.519,26.739 26.728,24.551 26.728,19.919L26.728,6.831C26.728,2.219 24.519,0.011 20.016,0.011L6.717,0.011C2.214,0.011 0,2.219 0,6.831L0,19.919C0,24.551 2.214,26.739 6.717,26.739ZM6.757,21.601C5.657,21.601 5.137,21.056 5.137,19.905L5.137,6.814C5.137,5.694 5.657,5.149 6.757,5.149L19.951,5.149C21.044,5.149 21.59,5.694 21.59,6.814L21.59,19.905C21.59,21.056 21.044,21.601 19.951,21.601L6.757,21.601Z" style="fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 64 64"><path d="M2.976 45.772h58.485c1.654 0 2.976-1.291 2.976-2.95a2.943 2.943 0 0 0-2.976-2.976H2.976C1.296 39.846 0 41.137 0 42.822c0 1.654 1.291 2.95 2.976 2.95m0-19.173h58.485a2.96 2.96 0 0 0 2.976-2.976c0-1.654-1.316-2.95-2.976-2.95H2.976c-1.68 0-2.976 1.291-2.976 2.95a2.943 2.943 0 0 0 2.976 2.976m0-19.199h58.485c1.654 0 2.976-1.291 2.976-2.95a2.947 2.947 0 0 0-2.976-2.976H2.976C1.296 1.474 0 2.765 0 4.45 0 6.104 1.291 7.4 2.976 7.4" style="fill-rule:nonzero" transform="translate(3 10.737)scale(.9001)"/></svg>

After

Width:  |  Height:  |  Size: 682 B

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.900107,0,0,0.900107,3,10.7367)">
<path d="M2.976,45.772L61.461,45.772C63.115,45.772 64.437,44.481 64.437,42.822C64.437,41.143 63.121,39.846 61.461,39.846L2.976,39.846C1.296,39.846 0,41.137 0,42.822C0,44.476 1.291,45.772 2.976,45.772Z" style="fill-rule:nonzero;"/>
<path d="M2.976,26.599L61.461,26.599C63.115,26.599 64.437,25.283 64.437,23.623C64.437,21.969 63.121,20.673 61.461,20.673L2.976,20.673C1.296,20.673 0,21.964 0,23.623C0,25.277 1.291,26.599 2.976,26.599Z" style="fill-rule:nonzero;"/>
<path d="M2.976,7.4L61.461,7.4C63.115,7.4 64.437,6.109 64.437,4.45C64.437,2.776 63.121,1.474 61.461,1.474L2.976,1.474C1.296,1.474 0,2.765 0,4.45C0,6.104 1.291,7.4 2.976,7.4Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 64 64"><path d="M9.212 56.664h43.472c6.105 0 9.2-3.124 9.2-9.136V9.217c0-6.011-3.095-9.135-9.2-9.135H9.212C3.117.082 0 3.155 0 9.217v38.311c0 6.062 3.117 9.136 9.212 9.136m-.275-3.869c-3.304 0-5.068-1.73-5.068-5.111V19.097c0-3.36 1.764-5.112 5.068-5.112h43.989c3.246 0 5.089 1.752 5.089 5.112v28.587c0 3.381-1.843 5.111-5.089 5.111z" style="fill-rule:nonzero" transform="translate(3 5.408)scale(.93724)"/><path d="M13.938 25.32H48.02c.881 0 1.56-.701 1.56-1.572 0-.849-.679-1.507-1.56-1.507H13.938c-.923 0-1.581.658-1.581 1.507 0 .871.658 1.572 1.581 1.572m0 9.609H48.02c.881 0 1.56-.657 1.56-1.506 0-.893-.679-1.572-1.56-1.572H13.938c-.923 0-1.581.679-1.581 1.572 0 .849.658 1.506 1.581 1.506m0 9.61h22.113c.881 0 1.56-.657 1.56-1.507 0-.871-.679-1.571-1.56-1.571H13.938c-.923 0-1.581.7-1.581 1.571 0 .85.658 1.507 1.581 1.507" style="fill-rule:nonzero" transform="translate(3 5.408)scale(.93724)"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<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.937236,0,0,0.937236,3,5.40796)">
<path d="M9.212,56.664L52.684,56.664C58.789,56.664 61.884,53.54 61.884,47.528L61.884,9.217C61.884,3.206 58.789,0.082 52.684,0.082L9.212,0.082C3.117,0.082 0,3.155 0,9.217L0,47.528C0,53.59 3.117,56.664 9.212,56.664ZM8.937,52.795C5.633,52.795 3.869,51.065 3.869,47.684L3.869,19.097C3.869,15.737 5.633,13.985 8.937,13.985L52.926,13.985C56.172,13.985 58.015,15.737 58.015,19.097L58.015,47.684C58.015,51.065 56.172,52.795 52.926,52.795L8.937,52.795Z" style="fill-rule:nonzero;"/>
<path d="M13.938,25.32L48.02,25.32C48.901,25.32 49.58,24.619 49.58,23.748C49.58,22.899 48.901,22.241 48.02,22.241L13.938,22.241C13.015,22.241 12.357,22.899 12.357,23.748C12.357,24.619 13.015,25.32 13.938,25.32ZM13.938,34.929L48.02,34.929C48.901,34.929 49.58,34.272 49.58,33.423C49.58,32.53 48.901,31.851 48.02,31.851L13.938,31.851C13.015,31.851 12.357,32.53 12.357,33.423C12.357,34.272 13.015,34.929 13.938,34.929ZM13.938,44.539L36.051,44.539C36.932,44.539 37.611,43.882 37.611,43.032C37.611,42.161 36.932,41.461 36.051,41.461L13.938,41.461C13.015,41.461 12.357,42.161 12.357,43.032C12.357,43.882 13.015,44.539 13.938,44.539Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 64 64"><circle cx="33.7" cy="31.636" r="6.698" transform="translate(-3.217 -1.061)scale(1.04503)"/><circle cx="33.7" cy="31.636" r="6.698" transform="translate(19.783 -1.061)scale(1.04503)"/><circle cx="33.7" cy="31.636" r="6.698" transform="translate(-26.217 -1.061)scale(1.04503)"/></svg>

After

Width:  |  Height:  |  Size: 450 B

View File

@ -0,0 +1,13 @@
<?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(1.04503,0,0,1.04503,-3.21714,-1.06112)">
<circle cx="33.7" cy="31.636" r="6.698"/>
</g>
<g transform="matrix(1.04503,0,0,1.04503,19.7829,-1.06112)">
<circle cx="33.7" cy="31.636" r="6.698"/>
</g>
<g transform="matrix(1.04503,0,0,1.04503,-26.2171,-1.06112)">
<circle cx="33.7" cy="31.636" r="6.698"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 824 B

View File

@ -1,13 +1,13 @@
// Dashboard.js // Dashboard.js
import React from 'react' import React from 'react'
import DashboardLayout from './DashboardLayout' import Layout from './Layout'
import { Outlet } from 'react-router-dom' import { Outlet } from 'react-router-dom'
const Dashboard = () => { const Dashboard = () => {
return ( return (
<DashboardLayout> <Layout>
<Outlet /> <Outlet />
</DashboardLayout> </Layout>
) )
} }

View File

@ -0,0 +1,105 @@
import React, { useContext } from 'react'
import {
Descriptions,
Button,
Typography,
Flex,
Space,
Dropdown,
message
} from 'antd'
import ReloadIcon from '../../Icons/ReloadIcon.jsx'
import { AuthContext } from '../context/AuthContext.js'
import BoolDisplay from '../common/BoolDisplay.jsx'
const { Text, Paragraph } = Typography
const AuthContextDebug = () => {
const { authenticated, userProfile, token, loading, loginWithSSO, logout } =
useContext(AuthContext)
const [msgApi, contextHolder] = message.useMessage()
const handleLogin = () => {
loginWithSSO()
}
const handleLogout = () => {
logout()
}
const actionItems = {
items: [
{
label: 'Log In',
key: 'login',
disabled: authenticated
},
{
label: 'Log Out',
key: 'logout',
disabled: !authenticated
},
{
label: 'Reload',
key: 'reload',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'login') handleLogin()
if (key === 'logout') handleLogout()
if (key === 'reload') {
msgApi.info('Reloading Auth State...')
window.location.reload()
}
}
}
return (
<Flex vertical gap='large' style={{ height: '100%', minHeight: 0 }}>
{contextHolder}
<Flex justify={'space-between'} align={'center'}>
<Space>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
</Space>
</Flex>
<div style={{ height: '100%', overflow: 'auto' }}>
<Descriptions bordered column={1}>
<Descriptions.Item label='Authenticated'>
<BoolDisplay value={authenticated} />
</Descriptions.Item>
<Descriptions.Item label='Loading'>
<BoolDisplay value={loading} />
</Descriptions.Item>
<Descriptions.Item label='Token'>
<Paragraph>
<pre>{token || <Text type='secondary'>None</Text>}</pre>
</Paragraph>
</Descriptions.Item>
<Descriptions.Item label='User Profile'>
<pre style={{ margin: 0, fontSize: 12 }}>
{userProfile ? (
<Paragraph>
<pre>
{JSON.stringify(
// eslint-disable-next-line
{ ...userProfile, access_token: '...' },
null,
2
)}
</pre>
</Paragraph>
) : (
<Text>n/a</Text>
)}
</pre>
</Descriptions.Item>
</Descriptions>
</div>
</Flex>
)
}
export default AuthContextDebug

View File

@ -0,0 +1,41 @@
import React from 'react'
import { useLocation } from 'react-router-dom'
import DashboardSidebar from '../common/DashboardSidebar'
const items = [
{
key: 'sessionstorage',
label: 'Session Storage',
path: '/dashboard/developer/sessionstorage'
},
{
key: 'authcontextdebug',
label: 'Auth Context Debug',
path: '/dashboard/developer/authcontextdebug'
},
{
key: 'socketcontextdebug',
label: 'Socket Context Debug',
path: '/dashboard/developer/socketcontextdebug'
}
]
const routeKeyMap = {
'/dashboard/developer/sessionstorage': 'sessionstorage',
'/dashboard/developer/authcontext': 'authcontextdebug',
'/dashboard/developer/socketcontext': 'socketcontextdebug'
}
const DeveloperSidebar = (props) => {
const location = useLocation()
const selectedKey = (() => {
const match = Object.keys(routeKeyMap).find((path) =>
location.pathname.startsWith(path)
)
return match ? routeKeyMap[match] : 'sessionstorage'
})()
return <DashboardSidebar items={items} selectedKey={selectedKey} {...props} />
}
export default DeveloperSidebar

View File

@ -0,0 +1,86 @@
import React, { useState } from 'react'
import { Descriptions, Button, Typography, Flex, Space, Dropdown } from 'antd'
import ReloadIcon from '../../Icons/ReloadIcon'
import BoolDisplay from '../common/BoolDisplay'
const { Text } = Typography
const getSessionStorageItems = () => {
const items = []
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i)
items.push({ key, value: sessionStorage.getItem(key) })
}
return items
}
const SessionStorage = () => {
const [items, setItems] = useState(getSessionStorageItems())
const reload = () => {
setItems(getSessionStorageItems())
}
const actionItems = {
items: [
{
label: 'Reload',
key: 'reload',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reload') reload()
}
}
return (
<Flex vertical gap='large' style={{ height: '100%', minHeight: 0 }}>
<Flex justify={'space-between'} align={'center'}>
<Space>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
</Space>
</Flex>
<div style={{ height: '100%', overflow: 'auto' }}>
<Descriptions
bordered
column={{ xs: 1, sm: 1, md: 1, lg: 2, xl: 2, xxl: 2 }}
>
{items.length === 0 ? (
<Descriptions.Item label='No sessionStorage items' span={2}>
<Text type='secondary'>Empty</Text>
</Descriptions.Item>
) : (
items.map(({ key, value }) => {
// Try to detect boolean values (true/false or 'true'/'false')
let isBool = false
let boolValue = false
if (typeof value === 'boolean') {
isBool = true
boolValue = value
} else if (value === 'true' || value === 'false') {
isBool = true
boolValue = value === 'true'
}
return (
<Descriptions.Item label={key} key={key} span={2}>
{isBool ? (
<BoolDisplay value={boolValue} />
) : (
<Text code style={{ wordBreak: 'break-all' }}>
{value}
</Text>
)}
</Descriptions.Item>
)
})
)}
</Descriptions>
</div>
</Flex>
)
}
export default SessionStorage

View File

@ -0,0 +1,77 @@
import React, { useContext } from 'react'
import {
Descriptions,
Button,
Typography,
Flex,
Space,
Dropdown,
message
} from 'antd'
import ReloadIcon from '../../Icons/ReloadIcon.jsx'
import { SocketContext } from '../context/SocketContext.js'
import BoolDisplay from '../common/BoolDisplay.jsx'
const { Text, Paragraph } = Typography
const SocketContextDebug = () => {
const { socket, error, connecting } = useContext(SocketContext)
const [msgApi, contextHolder] = message.useMessage()
const actionItems = {
items: [
{
label: 'Reload',
key: 'reload',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reload') {
msgApi.info('Reloading Page...')
window.location.reload()
}
}
}
// Helper to display socket info safely
const getSocketInfo = () => {
if (!socket) return 'n/a'
// Only show safe properties
const { id, connected, disconnected, nsp } = socket
return JSON.stringify({ id, connected, disconnected, nsp }, null, 2)
}
return (
<Flex vertical gap='large' style={{ height: '100%', minHeight: 0 }}>
{contextHolder}
<Flex justify={'space-between'} align={'center'}>
<Space>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
</Space>
</Flex>
<div style={{ height: '100%', overflow: 'auto' }}>
<Descriptions bordered column={1}>
<Descriptions.Item label='Connected'>
<BoolDisplay value={socket?.connected || false} />
</Descriptions.Item>
<Descriptions.Item label='Connecting'>
<BoolDisplay value={connecting} />
</Descriptions.Item>
<Descriptions.Item label='Error'>
{error ? <Text type='danger'>{error}</Text> : <Text>n/a</Text>}
</Descriptions.Item>
<Descriptions.Item label='Socket'>
<Paragraph>
<pre>{getSocketInfo()}</pre>
</Paragraph>
</Descriptions.Item>
</Descriptions>
</div>
</Flex>
)
}
export default SocketContextDebug

View File

@ -30,6 +30,9 @@ import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon' import CheckIcon from '../../Icons/CheckIcon'
import useColumnVisibility from '../hooks/useColumnVisibility' import useColumnVisibility from '../hooks/useColumnVisibility'
import DashboardTable from '../common/DashboardTable' import DashboardTable from '../common/DashboardTable'
import ListIcon from '../../Icons/ListIcon'
import GridIcon from '../../Icons/GridIcon'
import useViewMode from '../hooks/useViewMode'
import config from '../../../config' import config from '../../../config'
@ -46,6 +49,8 @@ const FilamentStocks = () => {
const { authenticated } = useContext(AuthContext) const { authenticated } = useContext(AuthContext)
const [viewMode, setViewMode] = useViewMode('FilamentStocks')
const getFilterDropdown = ({ const getFilterDropdown = ({
setSelectedKeys, setSelectedKeys,
selectedKeys, selectedKeys,
@ -85,12 +90,11 @@ const FilamentStocks = () => {
// Column definitions // Column definitions
const columns = [ const columns = [
{ {
title: '', title: <FilamentStockIcon />,
dataIndex: '',
key: 'icon', key: 'icon',
width: 40, width: 40,
fixed: 'left', fixed: 'left',
render: () => <FilamentStockIcon></FilamentStockIcon> render: () => <FilamentStockIcon />
}, },
{ {
title: 'Filament Name', title: 'Filament Name',
@ -112,7 +116,7 @@ const FilamentStocks = () => {
clearFilters, clearFilters,
propertyName: 'filament name' propertyName: 'filament name'
}), }),
render: (filament) => <Text ellipsis>{filament.name}</Text> render: (filament) => <Text ellipsis>{filament?.name}</Text>
}, },
{ {
title: 'ID', title: 'ID',
@ -136,7 +140,7 @@ const FilamentStocks = () => {
width: 140, width: 140,
sorter: true, sorter: true,
render: (currentNetWeight) => ( render: (currentNetWeight) => (
<Text ellipsis>{currentNetWeight.toFixed(2) + 'g'}</Text> <Text ellipsis>{currentNetWeight?.toFixed(2) + 'g'}</Text>
) )
}, },
{ {
@ -146,7 +150,7 @@ const FilamentStocks = () => {
width: 140, width: 140,
sorter: true, sorter: true,
render: (startingNetWeight) => ( render: (startingNetWeight) => (
<Text ellipsis>{startingNetWeight.toFixed(2) + 'g'}</Text> <Text ellipsis>{startingNetWeight?.toFixed(2) + 'g'}</Text>
) )
}, },
{ {
@ -183,7 +187,7 @@ const FilamentStocks = () => {
key: 'actions', key: 'actions',
fixed: 'right', fixed: 'right',
width: 150, width: 150,
render: (text, record) => { render: (record) => {
return ( return (
<Space gap='small'> <Space gap='small'>
<Button <Button
@ -314,6 +318,14 @@ const FilamentStocks = () => {
<Button>View</Button> <Button>View</Button>
</Popover> </Popover>
</Space> </Space>
<Space>
<Button
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
onClick={() =>
setViewMode(viewMode === 'cards' ? 'list' : 'cards')
}
/>
</Space>
</Flex> </Flex>
<DashboardTable <DashboardTable
@ -321,6 +333,7 @@ const FilamentStocks = () => {
columns={visibleColumns} columns={visibleColumns}
url={`${config.backendUrl}/filamentstocks`} url={`${config.backendUrl}/filamentstocks`}
authenticated={authenticated} authenticated={authenticated}
cards={viewMode === 'cards'}
/> />
</Flex> </Flex>
<Modal <Modal

View File

@ -11,9 +11,13 @@ import {
Form, Form,
Badge, Badge,
Collapse, Collapse,
Flex Flex,
Dropdown,
Popover,
Card,
Checkbox
} from 'antd' } from 'antd'
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons' import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import IdText from '../../common/IdText' import IdText from '../../common/IdText'
import { SocketContext } from '../../context/SocketContext' import { SocketContext } from '../../context/SocketContext'
import FilamentStockState from '../../common/FilamentStockState' import FilamentStockState from '../../common/FilamentStockState'
@ -22,10 +26,16 @@ import useCollapseState from '../../hooks/useCollapseState'
import TimeDisplay from '../../common/TimeDisplay' import TimeDisplay from '../../common/TimeDisplay'
import FilamentIcon from '../../../Icons/FilamentIcon' import FilamentIcon from '../../../Icons/FilamentIcon'
import ReloadIcon from '../../../Icons/ReloadIcon' import ReloadIcon from '../../../Icons/ReloadIcon'
import AuditLogTable from '../../common/AuditLogTable'
import DashboardNotes from '../../common/DashboardNotes'
import config from '../../../../config' import config from '../../../../config'
import FilamentStockIcon from '../../../Icons/FilamentStockIcon'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon'
import NoteIcon from '../../../Icons/NoteIcon'
import AuditLogIcon from '../../../Icons/AuditLogIcon'
const { Title } = Typography const { Title, Text } = Typography
const FilamentStockInfo = () => { const FilamentStockInfo = () => {
const [filamentStockData, setFilamentStockData] = useState(null) const [filamentStockData, setFilamentStockData] = useState(null)
@ -43,7 +53,9 @@ const FilamentStockInfo = () => {
'FilamentStockInfo', 'FilamentStockInfo',
{ {
info: true, info: true,
events: true events: true,
notes: true,
auditLogs: true
} }
) )
@ -108,15 +120,49 @@ const FilamentStockInfo = () => {
} }
} }
if (fetchLoading) { const actionItems = {
items: [
{
label: 'Reload Filament Stock',
key: 'reload',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchFilamentStockDetails()
}
}
}
const getViewDropdownItems = () => {
const sections = [
{ key: 'info', label: 'Filament Stock Information' },
{ key: 'events', label: 'Filament Stock Events' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]
return ( return (
<div style={{ textAlign: 'center', padding: '20px' }}> <Flex vertical>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} /> <Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
</div> {sections.map((section) => (
<Checkbox
checked={collapseState[section.key]}
key={section.key}
onChange={(e) => {
updateCollapseState(section.key, e.target.checked)
}}
>
{section.label}
</Checkbox>
))}
</Flex>
</Flex>
) )
} }
if (error || !filamentStockData) { if (error) {
return ( return (
<Space <Space
direction='vertical' direction='vertical'
@ -131,27 +177,61 @@ const FilamentStockInfo = () => {
} }
return ( return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}> <>
{contextHolder} {contextHolder}
<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 || 'FilamentStock not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchFilamentStockDetails}>
Retry
</Button>
</Space>
) : (
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}> <Flex vertical gap={'large'}>
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.info ? ['1'] : []} activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)} onChange={(keys) =>
updateCollapseState('info', keys.length > 0)
}
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined rotate={isActive ? -90 : 0} />
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)} )}
className='no-h-padding-collapse no-t-padding-collapse' className='no-h-padding-collapse no-t-padding-collapse'
> >
<Collapse.Panel <Collapse.Panel
header={ header={
<Flex align='center' gap={'middle'}>
<InfoCircleIcon />
<Title level={5} style={{ margin: 0 }}> <Title level={5} style={{ margin: 0 }}>
Filament Stock Information Filament Stock Information
</Title> </Title>
</Flex>
} }
key='1' key='1'
> >
@ -159,8 +239,12 @@ const FilamentStockInfo = () => {
form={form} form={form}
layout='vertical' layout='vertical'
initialValues={{ initialValues={{
filament: filamentStockData.filament || {} filament: filamentStockData?.filament || {}
}} }}
>
<Spin
indicator={<LoadingOutlined />}
spinning={fetchLoading}
> >
<Descriptions <Descriptions
bordered bordered
@ -175,32 +259,49 @@ const FilamentStockInfo = () => {
> >
{/* Read-only fields */} {/* Read-only fields */}
<Descriptions.Item label='ID' span={1}> <Descriptions.Item label='ID' span={1}>
{filamentStockData.id ? ( {filamentStockData?.id ? (
<IdText id={filamentStockData.id} type={'filamentstock'} /> <IdText
id={filamentStockData.id}
type={'filamentstock'}
/>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Created At'> <Descriptions.Item label='Created At'>
{filamentStockData?.createdAt ? (
<TimeDisplay <TimeDisplay
dateTime={filamentStockData.createdAt} dateTime={filamentStockData.createdAt}
showSince={true} showSince={true}
/> />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='State'> <Descriptions.Item label='State'>
<FilamentStockState filamentStock={filamentStockData} /> {filamentStockData ? (
<FilamentStockState
filamentStock={filamentStockData}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Updated At'> <Descriptions.Item label='Updated At'>
{filamentStockData?.updatedAt ? (
<TimeDisplay <TimeDisplay
dateTime={filamentStockData.updatedAt} dateTime={filamentStockData.updatedAt}
showSince={true} showSince={true}
/> />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Filament Name'> <Descriptions.Item label='Filament Name'>
{filamentStockData.filament ? ( {filamentStockData?.filament ? (
<Space> <Space>
<FilamentIcon /> <FilamentIcon />
<Badge <Badge
@ -209,83 +310,162 @@ const FilamentStockInfo = () => {
/> />
</Space> </Space>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Filament ID' span={1}> <Descriptions.Item label='Filament ID' span={1}>
{filamentStockData.filament ? ( {filamentStockData?.filament ? (
<IdText <IdText
id={filamentStockData.filament.id} id={filamentStockData.filament.id}
type={'filament'} type={'filament'}
showHyperlink={true} showHyperlink={true}
/> />
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Current Weight'> <Descriptions.Item label='Current Weight'>
{filamentStockData.currentGrossWeight ? ( {filamentStockData?.currentGrossWeight ? (
<Descriptions style={{ width: '250px' }} column={2}> <Descriptions style={{ width: '250px' }} column={2}>
<Descriptions.Item label='Net'> <Descriptions.Item label='Net'>
{filamentStockData.currentNetWeight.toFixed(2) + 'g'} {filamentStockData.currentNetWeight.toFixed(2) +
'g'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Gross'> <Descriptions.Item label='Gross'>
{filamentStockData.currentGrossWeight.toFixed(2) + 'g'} {filamentStockData.currentGrossWeight.toFixed(
2
) + 'g'}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Starting Weight'> <Descriptions.Item label='Starting Weight'>
{filamentStockData.startingGrossWeight ? ( {filamentStockData?.startingGrossWeight ? (
<Space> <Space>
<Descriptions style={{ width: '250px' }} column={2}> <Descriptions
style={{ width: '250px' }}
column={2}
>
<Descriptions.Item label='Net'> <Descriptions.Item label='Net'>
{filamentStockData.startingNetWeight.toFixed(2) + 'g'} {filamentStockData.startingNetWeight.toFixed(
2
) + 'g'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Gross'> <Descriptions.Item label='Gross'>
{filamentStockData.startingGrossWeight.toFixed(2) + {filamentStockData.startingGrossWeight.toFixed(
'g'} 2
) + 'g'}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
</Space> </Space>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
</Spin>
</Form> </Form>
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.events ? ['2'] : []} activeKey={collapseState.events ? ['2'] : []}
onChange={(keys) => updateCollapseState('events', keys.length > 0)} onChange={(keys) =>
updateCollapseState('events', keys.length > 0)
}
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined rotate={isActive ? -90 : 0} />
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)} )}
className='no-h-padding-collapse' className='no-h-padding-collapse'
> >
<Collapse.Panel <Collapse.Panel
header={ header={
<Flex align='center' gap={'middle'}>
<FilamentStockIcon />
<Title level={5} style={{ margin: 0 }}> <Title level={5} style={{ margin: 0 }}>
Filament Stock Events Filament Stock Events
</Title> </Title>
</Flex>
} }
key='2' key='2'
> >
<StockEventTable stockEvents={filamentStockData.stockEvents} /> <Spin indicator={<LoadingOutlined />} spinning={fetchLoading}>
<StockEventTable
stockEvents={filamentStockData?.stockEvents || []}
/>
</Spin>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.notes ? ['notes'] : []}
onChange={(keys) =>
updateCollapseState('notes', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<NoteIcon />
<Title level={5} style={{ margin: 0 }}>
Notes
</Title>
</Flex>
}
key='notes'
>
<Card>
<DashboardNotes _id={filamentStockId} />
</Card>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
onChange={(keys) =>
updateCollapseState('auditLogs', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<AuditLogIcon />
<Title level={5} style={{ margin: 0 }}>
Audit Logs
</Title>
</Flex>
}
key='auditLogs'
>
<AuditLogTable
items={filamentStockData?.auditLogs || []}
loading={fetchLoading}
showTargetColumn={false}
/>
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
</Flex> </Flex>
</div> </div>
)}
</Flex>
</>
) )
} }

View File

@ -0,0 +1,73 @@
import React from 'react'
import { useLocation } from 'react-router-dom'
import DashboardSidebar from '../common/DashboardSidebar'
import { DashboardOutlined } from '@ant-design/icons'
import FilamentStockIcon from '../../Icons/FilamentStockIcon'
import PartStockIcon from '../../Icons/PartStockIcon'
import ProductStockIcon from '../../Icons/ProductStockIcon'
import StockEventIcon from '../../Icons/StockEventIcon'
import StockAuditIcon from '../../Icons/StockAuditIcon'
const items = [
{
key: 'overview',
label: 'Overview',
icon: <DashboardOutlined />,
path: '/dashboard/inventory/overview'
},
{ type: 'divider' },
{
key: 'filamentstocks',
label: 'Filament Stocks',
icon: <FilamentStockIcon />,
path: '/dashboard/inventory/filamentstocks'
},
{
key: 'partstocks',
label: 'Part Stocks',
icon: <PartStockIcon />,
path: '/dashboard/inventory/partstocks'
},
{
key: 'productstocks',
label: 'Product Stocks',
icon: <ProductStockIcon />,
path: '/dashboard/inventory/productstocks'
},
{ type: 'divider' },
{
key: 'stockevents',
label: 'Stock Events',
icon: <StockEventIcon />,
path: '/dashboard/inventory/stockevents'
},
{
key: 'stockaudits',
label: 'Stock Audits',
icon: <StockAuditIcon />,
path: '/dashboard/inventory/stockaudits'
}
]
const routeKeyMap = {
'/dashboard/inventory/overview': 'overview',
'/dashboard/inventory/filamentstocks': 'filamentstocks',
'/dashboard/inventory/partstocks': 'partstocks',
'/dashboard/inventory/productstocks': 'productstocks',
'/dashboard/inventory/stockevents': 'stockevents',
'/dashboard/inventory/stockaudits': 'stockaudits'
}
const InventorySidebar = (props) => {
const location = useLocation()
const selectedKey = (() => {
const match = Object.keys(routeKeyMap).find((path) =>
location.pathname.startsWith(path)
)
return match ? routeKeyMap[match] : 'filaments'
})()
return <DashboardSidebar items={items} selectedKey={selectedKey} {...props} />
}
export default InventorySidebar

View File

@ -9,7 +9,6 @@ import {
Typography, Typography,
Input Input
} from 'antd' } from 'antd'
import { AuditOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { SocketContext } from '../context/SocketContext' import { SocketContext } from '../context/SocketContext'
@ -17,14 +16,17 @@ import IdText from '../common/IdText'
import TimeDisplay from '../common/TimeDisplay' import TimeDisplay from '../common/TimeDisplay'
import ReloadIcon from '../../Icons/ReloadIcon' import ReloadIcon from '../../Icons/ReloadIcon'
import PlusMinusIcon from '../../Icons/PlusMinusIcon' import PlusMinusIcon from '../../Icons/PlusMinusIcon'
import SubJobIcon from '../../Icons/SubJobIcon'
import PlayCircleIcon from '../../Icons/PlayCircleIcon'
import XMarkIcon from '../../Icons/XMarkIcon' import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon' import CheckIcon from '../../Icons/CheckIcon'
import useColumnVisibility from '../hooks/useColumnVisibility' import useColumnVisibility from '../hooks/useColumnVisibility'
import DashboardTable from '../common/DashboardTable' import DashboardTable from '../common/DashboardTable'
import GridIcon from '../../Icons/GridIcon'
import ListIcon from '../../Icons/ListIcon'
import useViewMode from '../hooks/useViewMode'
import config from '../../../config' import config from '../../../config'
import { getTypeMeta } from '../utils/Utils'
import StockEventIcon from '../../Icons/StockEventIcon'
const { Text } = Typography const { Text } = Typography
@ -32,26 +34,16 @@ const StockEvents = () => {
const { socket } = useContext(SocketContext) const { socket } = useContext(SocketContext)
const [initialized, setInitialized] = useState(false) const [initialized, setInitialized] = useState(false)
const tableRef = useRef() const tableRef = useRef()
const [viewMode, setViewMode] = useViewMode('StockEvents')
// Column definitions for visibility // Column definitions for visibility
const columns = [ const columns = [
{ {
title: '', title: <StockEventIcon />,
key: 'icon', key: 'icon',
width: 40, width: 40,
fixed: 'left', fixed: 'left',
render: (record) => { render: () => <StockEventIcon />
switch (record.type.toLowerCase()) {
case 'subjob':
return <SubJobIcon />
case 'audit':
return <AuditOutlined />
case 'initial':
return <PlayCircleIcon />
default:
return null
}
}
}, },
{ {
title: 'Type', title: 'Type',
@ -60,6 +52,9 @@ const StockEvents = () => {
width: 200, width: 200,
fixed: 'left', fixed: 'left',
sorter: true, sorter: true,
render: (type) => {
return <Text>{getTypeMeta(type?.toLowerCase()).title}</Text>
},
filterDropdown: ({ filterDropdown: ({
setSelectedKeys, setSelectedKeys,
selectedKeys, selectedKeys,
@ -90,7 +85,7 @@ const StockEvents = () => {
width: 100, width: 100,
sorter: true, sorter: true,
render: (value, record) => { render: (value, record) => {
const formattedValue = value.toFixed(2) + record.unit const formattedValue = value?.toFixed(2) + record?.unit
return ( return (
<Text type={value < 0 ? 'danger' : 'success'}> <Text type={value < 0 ? 'danger' : 'success'}>
{value > 0 ? '+' + formattedValue : formattedValue} {value > 0 ? '+' + formattedValue : formattedValue}
@ -122,7 +117,7 @@ const StockEvents = () => {
width: 170 * 2, width: 170 * 2,
render: (record) => { render: (record) => {
const ids = ( const ids = (
<Space size={'middle'}> <Flex gap={'small'} wrap>
{record.job ? ( {record.job ? (
<IdText <IdText
id={record.job} id={record.job}
@ -146,7 +141,7 @@ const StockEvents = () => {
showHyperlink={true} showHyperlink={true}
/> />
) : null} ) : null}
</Space> </Flex>
) )
if (!record.stockAudit && !record.job && !record.subJob) { if (!record.stockAudit && !record.job && !record.subJob) {
return 'n/a' return 'n/a'
@ -307,6 +302,14 @@ const StockEvents = () => {
<Button>View</Button> <Button>View</Button>
</Popover> </Popover>
</Space> </Space>
<Space>
<Button
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
onClick={() =>
setViewMode(viewMode === 'cards' ? 'list' : 'cards')
}
/>
</Space>
</Flex> </Flex>
<DashboardTable <DashboardTable
@ -314,6 +317,7 @@ const StockEvents = () => {
columns={visibleColumns} columns={visibleColumns}
url={`${config.backendUrl}/stockevents`} url={`${config.backendUrl}/stockevents`}
authenticated={authenticated} authenticated={authenticated}
cards={viewMode === 'cards'}
/> />
</Flex> </Flex>
</> </>

View File

@ -3,12 +3,13 @@ import PropTypes from 'prop-types'
import React from 'react' import React from 'react'
import { Layout, Flex } from 'antd' import { Layout, Flex } from 'antd'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import DashboardNavigation from './DashboardNavigation' import ProductionSidebar from './Production/ProductionSidebar'
import ProductionSidebar from './ProductionSidebar' import InventorySidebar from './Inventory/InventorySidebar'
import InventorySidebar from './InventorySidebar' import ManagementSidebar from './Management/ManagementSidebar'
import ManagementSidebar from './ManagementSidebar' import DashboardNavigation from './common/DashboardNavigation'
import DashboardBreadcrumb from './DashboardBreadcrumb' import DashboardBreadcrumb from './common/DashboardBreadcrumb'
import './DashboardLayout.css' import './Layout.css'
import DeveloperSidebar from './Developer/DeveloperSidebar'
const { Content } = Layout const { Content } = Layout
@ -17,9 +18,10 @@ const DashboardLayout = ({ children }) => {
const isProduction = location.pathname.startsWith('/dashboard/production') const isProduction = location.pathname.startsWith('/dashboard/production')
const isInventory = location.pathname.startsWith('/dashboard/inventory') const isInventory = location.pathname.startsWith('/dashboard/inventory')
const isManagement = location.pathname.startsWith('/dashboard/management') const isManagement = location.pathname.startsWith('/dashboard/management')
const isDeveloper = location.pathname.startsWith('/dashboard/developer')
return ( return (
<Layout style={{ height: '100vh' }}> <Layout style={{ height: 'var(--unit-100vh)' }}>
<DashboardNavigation /> <DashboardNavigation />
<Layout> <Layout>
{isProduction ? ( {isProduction ? (
@ -28,6 +30,8 @@ const DashboardLayout = ({ children }) => {
<InventorySidebar /> <InventorySidebar />
) : isManagement ? ( ) : isManagement ? (
<ManagementSidebar /> <ManagementSidebar />
) : isDeveloper ? (
<DeveloperSidebar />
) : ( ) : (
<ProductionSidebar /> // Default to production sidebar <ProductionSidebar /> // Default to production sidebar
)} )}

View File

@ -7,7 +7,6 @@ import {
Popover, Popover,
Checkbox, Checkbox,
Dropdown, Dropdown,
Tag,
Descriptions, Descriptions,
Input, Input,
Badge Badge
@ -24,6 +23,7 @@ import config from '../../../config'
import AuditLogIcon from '../../Icons/AuditLogIcon' import AuditLogIcon from '../../Icons/AuditLogIcon'
import XMarkIcon from '../../Icons/XMarkIcon' import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon' import CheckIcon from '../../Icons/CheckIcon'
import BoolDisplay from '../common/BoolDisplay'
const { Text } = Typography const { Text } = Typography
@ -62,11 +62,7 @@ const formatValue = (value, propertyName) => {
} }
if (typeof value === 'boolean') { if (typeof value === 'boolean') {
return ( return <BoolDisplay value={value} yesNo={true} />
<Tag color={value ? 'success' : 'error'} style={{ margin: 0 }}>
{value ? 'Yes' : 'No'}
</Tag>
)
} }
if (isObjectId(value)) { if (isObjectId(value)) {

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import axios from 'axios' import axios from 'axios'
import { import {
Descriptions, Descriptions,
@ -15,14 +15,13 @@ import {
InputNumber, InputNumber,
ColorPicker, ColorPicker,
Select, Select,
Modal, Collapse,
Collapse Dropdown,
Popover,
Checkbox,
Card
} from 'antd' } from 'antd'
import { import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
LoadingOutlined,
DeleteOutlined,
CaretRightOutlined
} from '@ant-design/icons'
import IdText from '../../common/IdText' import IdText from '../../common/IdText'
import ReloadIcon from '../../../Icons/ReloadIcon' import ReloadIcon from '../../../Icons/ReloadIcon'
import EditIcon from '../../../Icons/EditIcon.jsx' import EditIcon from '../../../Icons/EditIcon.jsx'
@ -31,28 +30,33 @@ import CheckIcon from '../../../Icons/CheckIcon.jsx'
import TimeDisplay from '../../common/TimeDisplay.jsx' import TimeDisplay from '../../common/TimeDisplay.jsx'
import VendorSelect from '../../common/VendorSelect' import VendorSelect from '../../common/VendorSelect'
import useCollapseState from '../../hooks/useCollapseState' import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import DashboardNotes from '../../common/DashboardNotes'
import config from '../../../../config.js' import config from '../../../../config.js'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
const { Title, Link, Text } = Typography const { Title, Link, Text } = Typography
const FilamentInfo = () => { const FilamentInfo = () => {
const [filamentData, setFilamentData] = useState(null) const [filamentData, setFilamentData] = useState(null)
const [fetchLoading, setFetchLoading] = useState(true) const [fetchLoading, setFetchLoading] = useState(true)
const [loading, setLoading] = useState(false) const [editLoading, setEditLoading] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const location = useLocation() const location = useLocation()
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const filamentId = new URLSearchParams(location.search).get('filamentId') const filamentId = new URLSearchParams(location.search).get('filamentId')
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [form] = Form.useForm() const [form] = Form.useForm()
const navigate = useNavigate()
const [collapseState, updateCollapseState] = useCollapseState( const [collapseState, updateCollapseState] = useCollapseState(
'FilamentInfo', 'FilamentInfo',
{ {
info: true, info: true,
details: true details: true,
notes: true,
auditLogs: true
} }
) )
@ -92,6 +96,7 @@ const FilamentInfo = () => {
} }
) )
setFilamentData(response.data) setFilamentData(response.data)
form.setFieldsValue(response.data)
setError(null) setError(null)
} catch (err) { } catch (err) {
setError('Failed to fetch filament details') setError('Failed to fetch filament details')
@ -128,7 +133,7 @@ const FilamentInfo = () => {
const updateFilamentInfo = async () => { const updateFilamentInfo = async () => {
try { try {
const values = await form.validateFields() const values = await form.validateFields()
setLoading(true) setEditLoading(true)
await axios.put( await axios.put(
`${config.backendUrl}/filaments/${filamentId}`, `${config.backendUrl}/filaments/${filamentId}`,
@ -165,36 +170,52 @@ const FilamentInfo = () => {
messageApi.error('Failed to update filament information') messageApi.error('Failed to update filament information')
} finally { } finally {
fetchFilamentDetails() fetchFilamentDetails()
setLoading(false) setEditLoading(false)
} }
} }
const handleDelete = async () => { const actionItems = {
try { items: [
setLoading(true) {
await axios.delete(`${config.backendUrl}/filaments/${filamentId}`, { label: 'Reload Filament',
withCredentials: true key: 'reload',
}) icon: <ReloadIcon />
messageApi.success('Filament deleted successfully') }
navigate('/dashboard/filaments') ],
} catch (err) { onClick: ({ key }) => {
console.error('Failed to delete filament:', err) if (key === 'reload') {
messageApi.error('Failed to delete filament') fetchFilamentDetails()
} finally { }
setLoading(false)
setIsDeleteModalOpen(false)
} }
} }
if (fetchLoading) { const getViewDropdownItems = () => {
const sections = [
{ key: 'info', label: 'Filament Information' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]
return ( return (
<div style={{ textAlign: 'center', padding: '20px' }}> <Flex vertical>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} /> <Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
</div> {sections.map((section) => (
<Checkbox
checked={collapseState[section.key]}
key={section.key}
onChange={(e) => {
updateCollapseState(section.key, e.target.checked)
}}
>
{section.label}
</Checkbox>
))}
</Flex>
</Flex>
) )
} }
if (error || !filamentData) { if (error) {
return ( return (
<Space <Space
direction='vertical' direction='vertical'
@ -209,52 +230,40 @@ const FilamentInfo = () => {
} }
return ( return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}> <>
{contextHolder} {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 <Flex
align='center' gap='large'
justify='space-between' vertical='true'
style={{ width: '100%' }} style={{ height: '100%', minHeight: 0 }}
> >
<Title level={5} style={{ margin: 0 }}> <Flex justify={'space-between'}>
Filament Information <Space size='small'>
</Title> <Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button>View</Button>
</Popover>
</Space>
<Space> <Space>
<Button
icon={<DeleteOutlined />}
danger
onClick={() => setIsDeleteModalOpen(true)}
loading={loading}
/>
{isEditing ? ( {isEditing ? (
<> <>
<Button <Button
icon={<CheckIcon />} icon={<CheckIcon />}
type='primary' type='primary'
onClick={updateFilamentInfo} onClick={updateFilamentInfo}
loading={loading} loading={editLoading}
disabled={editLoading}
/> />
<Button <Button
icon={<XMarkIcon />} icon={<XMarkIcon />}
onClick={cancelEditing} onClick={cancelEditing}
disabled={loading} disabled={editLoading}
/> />
</> </>
) : ( ) : (
@ -262,6 +271,40 @@ const FilamentInfo = () => {
)} )}
</Space> </Space>
</Flex> </Flex>
{error ? (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Print job not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchFilamentDetails}>
Retry
</Button>
</Space>
) : (
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) =>
updateCollapseState('info', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<InfoCircleIcon />
<Title level={5} style={{ margin: 0 }}>
Filament Information
</Title>
</Flex>
} }
key='1' key='1'
> >
@ -269,16 +312,20 @@ const FilamentInfo = () => {
form={form} form={form}
layout='vertical' layout='vertical'
initialValues={{ initialValues={{
name: filamentData.name || '', name: filamentData?.name || '',
vendor: filamentData.vendor || { id: null, name: '' }, vendor: filamentData?.vendor || { id: null, name: '' },
type: filamentData.type || '', type: filamentData?.type || '',
cost: filamentData.cost || null, cost: filamentData?.cost || null,
color: filamentData.color || '#000000', color: filamentData?.color || '#000000',
diameter: filamentData.diameter || null, diameter: filamentData?.diameter || null,
density: filamentData.density || null, density: filamentData?.density || null,
url: filamentData.url || '', url: filamentData?.url || '',
barcode: filamentData.barcode || '' barcode: filamentData?.barcode || ''
}} }}
>
<Spin
indicator={<LoadingOutlined />}
spinning={fetchLoading}
> >
<Descriptions <Descriptions
bordered bordered
@ -292,21 +339,24 @@ const FilamentInfo = () => {
}} }}
> >
<Descriptions.Item label='ID' span={1}> <Descriptions.Item label='ID' span={1}>
{filamentData.id ? ( {filamentData?._id ? (
<IdText id={filamentData.id} type={'filament'} /> <IdText id={filamentData._id} type={'filament'} />
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Created At'> <Descriptions.Item label='Created At'>
{filamentData?.createdAt ? (
<TimeDisplay <TimeDisplay
dateTime={filamentData.createdAt} dateTime={filamentData.createdAt}
showSince={true} showSince={true}
/> />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Name'> <Descriptions.Item label='Name'>
<Flex align={'center'} gap={'small'}>
{isEditing ? ( {isEditing ? (
<Form.Item <Form.Item
name='name' name='name'
@ -324,49 +374,58 @@ const FilamentInfo = () => {
> >
<Input placeholder='Enter filament name' /> <Input placeholder='Enter filament name' />
</Form.Item> </Form.Item>
) : filamentData?.name ? (
<Text>{filamentData.name}</Text>
) : ( ) : (
filamentData.name || 'n/a' <Text>n/a</Text>
)} )}
</Flex>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Updated At'> <Descriptions.Item label='Updated At'>
{filamentData?.updatedAt ? (
<TimeDisplay <TimeDisplay
dateTime={filamentData.updatedAt} dateTime={filamentData.updatedAt}
showSince={true} showSince={true}
/> />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Vendor'> <Descriptions.Item label='Vendor'>
<Flex align={'center'} gap={'small'}>
{isEditing ? ( {isEditing ? (
<Form.Item <Form.Item
name='vendor' name='vendor'
rules={[ rules={[
{ required: true, message: 'Please enter a vendor' } {
required: true,
message: 'Please enter a vendor'
}
]} ]}
style={{ margin: 0 }} style={{ margin: 0 }}
> >
<VendorSelect /> <VendorSelect />
</Form.Item> </Form.Item>
) : filamentData.vendor.name ? ( ) : filamentData?.vendor?.name ? (
<Text>{filamentData.vendor.name}</Text> <Text>{filamentData.vendor.name}</Text>
) : ( ) : (
<Text>n/a</Text> <Text>n/a</Text>
)} )}
</Flex>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Vendor ID'> <Descriptions.Item label='Vendor ID'>
{filamentData?.vendor?.id ? (
<IdText <IdText
id={filamentData.vendor.id} id={filamentData.vendor.id}
type={'vendor'} type={'vendor'}
showHyperlink={true} showHyperlink={true}
/> />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Material'> <Descriptions.Item label='Material'>
<Flex align={'center'} gap={'small'}>
{isEditing ? ( {isEditing ? (
<Form.Item <Form.Item
name='type' name='type'
@ -387,20 +446,23 @@ const FilamentInfo = () => {
<Select.Option value='TPU'>TPU</Select.Option> <Select.Option value='TPU'>TPU</Select.Option>
</Select> </Select>
</Form.Item> </Form.Item>
) : filamentData?.type ? (
<Text>{filamentData.type}</Text>
) : ( ) : (
filamentData.type || 'n/a' <Text>n/a</Text>
)} )}
</Flex>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Cost'> <Descriptions.Item label='Cost'>
<Flex align={'center'} gap={'small'}>
{isEditing ? ( {isEditing ? (
<Form.Item <Form.Item
name='cost' name='cost'
style={{ margin: 0 }} style={{ margin: 0 }}
rules={[ rules={[
{ required: true, message: 'Please enter a cost' } {
required: true,
message: 'Please enter a cost'
}
]} ]}
> >
<InputNumber <InputNumber
@ -409,12 +471,11 @@ const FilamentInfo = () => {
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
</Form.Item> </Form.Item>
) : filamentData.cost ? ( ) : filamentData?.cost ? (
`£${filamentData.cost}/kg` <Text>{`£${filamentData.cost}/kg`}</Text>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Flex>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Color'> <Descriptions.Item label='Color'>
@ -424,7 +485,10 @@ const FilamentInfo = () => {
name='color' name='color'
style={{ margin: 0 }} style={{ margin: 0 }}
rules={[ rules={[
{ required: true, message: 'Please select a color' } {
required: true,
message: 'Please select a color'
}
]} ]}
getValueFromEvent={(color) => { getValueFromEvent={(color) => {
return '#' + color.toHex() return '#' + color.toHex()
@ -432,123 +496,160 @@ const FilamentInfo = () => {
> >
<ColorPicker showText disabledAlpha /> <ColorPicker showText disabledAlpha />
</Form.Item> </Form.Item>
) : ( ) : filamentData?.color ? (
<Badge <Badge
color={filamentData.color} color={filamentData.color}
text={filamentData.color} text={filamentData.color}
/> />
) : (
<Text>n/a</Text>
)} )}
</Flex> </Flex>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Diameter'> <Descriptions.Item label='Diameter'>
<Flex align={'center'} gap={'small'}>
{isEditing ? ( {isEditing ? (
<Form.Item <Form.Item
name='diameter' name='diameter'
style={{ margin: 0 }} style={{ margin: 0 }}
rules={[ rules={[
{ required: true, message: 'Please enter a diameter' } {
required: true,
message: 'Please enter a diameter'
}
]} ]}
> >
<InputNumber suffix='mm' style={{ width: '100%' }} /> <InputNumber
suffix='mm'
style={{ width: '100%' }}
/>
</Form.Item> </Form.Item>
) : filamentData.diameter ? ( ) : filamentData?.diameter ? (
`${filamentData.diameter}mm` <Text>{`${filamentData.diameter}mm`}</Text>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Flex>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Density'> <Descriptions.Item label='Density'>
<Flex align={'center'} gap={'small'}>
{isEditing ? ( {isEditing ? (
<Form.Item <Form.Item
name='density' name='density'
style={{ margin: 0 }} style={{ margin: 0 }}
rules={[ rules={[
{ required: true, message: 'Please enter a density' } {
required: true,
message: 'Please enter a density'
}
]} ]}
> >
<InputNumber suffix='g/cm³' style={{ width: '100%' }} /> <InputNumber
suffix='g/cm³'
style={{ width: '100%' }}
/>
</Form.Item> </Form.Item>
) : filamentData.density ? ( ) : filamentData?.density ? (
`${filamentData.density}g/cm³` <Text>{`${filamentData.density}g/cm³`}</Text>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Flex>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='URL'> <Descriptions.Item label='URL'>
<Flex align={'center'} gap={'small'}>
{isEditing ? ( {isEditing ? (
<Form.Item name='url' style={{ margin: 0 }}> <Form.Item name='url' style={{ margin: 0 }}>
<Input placeholder='Enter URL' /> <Input placeholder='Enter URL' />
</Form.Item> </Form.Item>
) : filamentData.url ? ( ) : filamentData?.url ? (
<Link href={filamentData.url} target='_blank'> <Link href={filamentData.url} target='_blank'>
{filamentData.url} {filamentData.url}
</Link> </Link>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Flex>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Barcode'> <Descriptions.Item label='Barcode'>
<Flex align={'center'} gap={'small'}>
{isEditing ? ( {isEditing ? (
<Form.Item name='barcode' style={{ margin: 0 }}> <Form.Item name='barcode' style={{ margin: 0 }}>
<Input placeholder='Enter barcode' /> <Input placeholder='Enter barcode' />
</Form.Item> </Form.Item>
) : filamentData?.barcode ? (
<Text>{filamentData.barcode}</Text>
) : ( ) : (
filamentData.barcode || 'n/a' <Text>n/a</Text>
)} )}
</Flex>
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
</Spin>
</Form> </Form>
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.details ? ['2'] : []} activeKey={collapseState.notes ? ['notes'] : []}
onChange={(keys) => updateCollapseState('details', keys.length > 0)} onChange={(keys) =>
updateCollapseState('notes', keys.length > 0)
}
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined rotate={isActive ? -90 : 0} />
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)} )}
className='no-h-padding-collapse' className='no-h-padding-collapse'
> >
<Collapse.Panel <Collapse.Panel
header={ header={
<Flex align='center' gap={'middle'}>
<NoteIcon />
<Title level={5} style={{ margin: 0 }}> <Title level={5} style={{ margin: 0 }}>
Additional Details Notes
</Title> </Title>
</Flex>
} }
key='2' key='notes'
> >
{/* Add any additional details sections here */} <Card>
<DashboardNotes _id={filamentId} />
</Card>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
onChange={(keys) =>
updateCollapseState('auditLogs', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<AuditLogIcon />
<Title level={5} style={{ margin: 0 }}>
Audit Logs
</Title>
</Flex>
}
key='auditLogs'
>
<AuditLogTable
items={filamentData?.auditLogs || []}
loading={fetchLoading}
showTargetColumn={false}
/>
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
</Flex> </Flex>
<Modal
title='Delete Filament'
open={isDeleteModalOpen}
onOk={handleDelete}
onCancel={() => setIsDeleteModalOpen(false)}
confirmLoading={loading}
>
<p>Are you sure you want to delete this filament?</p>
</Modal>
</div> </div>
)}
</Flex>
</>
) )
} }

View File

@ -0,0 +1,109 @@
import React from 'react'
import { useLocation } from 'react-router-dom'
import DashboardSidebar from '../common/DashboardSidebar'
import FilamentIcon from '../../Icons/FilamentIcon'
import PartIcon from '../../Icons/PartIcon'
import ProductIcon from '../../Icons/ProductIcon'
import VendorIcon from '../../Icons/VendorIcon'
import MaterialIcon from '../../Icons/MaterialIcon'
import NoteTypeIcon from '../../Icons/NoteTypeIcon'
import SettingsIcon from '../../Icons/SettingsIcon'
import AuditLogIcon from '../../Icons/AuditLogIcon'
import DeveloperIcon from '../../Icons/DeveloperIcon'
import PersonIcon from '../../Icons/PersonIcon'
const items = [
{
key: 'filaments',
icon: <FilamentIcon />,
label: 'Filaments',
path: '/dashboard/management/filaments'
},
{
key: 'parts',
icon: <PartIcon />,
label: 'Parts',
path: '/dashboard/management/parts'
},
{
key: 'products',
icon: <ProductIcon />,
label: 'Products',
path: '/dashboard/management/products'
},
{
key: 'vendors',
icon: <VendorIcon />,
label: 'Vendors',
path: '/dashboard/management/vendors'
},
{
key: 'materials',
icon: <MaterialIcon />,
label: 'Materials',
path: '/dashboard/management/materials'
},
{ type: 'divider' },
{
key: 'notetypes',
icon: <NoteTypeIcon />,
label: 'Note Types',
path: '/dashboard/management/notetypes'
},
{
key: 'users',
icon: <PersonIcon />,
label: 'Users',
path: '/dashboard/management/users'
},
{
key: 'settings',
icon: <SettingsIcon />,
label: 'Settings',
path: '/dashboard/management/settings'
},
{
key: 'auditlogs',
icon: <AuditLogIcon />,
label: 'Audit Logs',
path: '/dashboard/management/auditlogs'
}
]
if (process.env.NODE_ENV === 'development') {
items.push(
{ type: 'divider' },
{
key: 'developer',
icon: <DeveloperIcon />,
label: 'Developer',
path: '/dashboard/developer/sessionstorage'
}
)
}
const routeKeyMap = {
'/dashboard/management/filaments': 'filaments',
'/dashboard/management/parts': 'parts',
'/dashboard/management/users': 'users',
'/dashboard/management/products': 'products',
'/dashboard/management/vendors': 'vendors',
'/dashboard/management/materials': 'materials',
'/dashboard/management/notetypes': 'notetypes',
'/dashboard/management/settings': 'settings',
'/dashboard/management/auditlogs': 'auditlogs'
}
const ManagementSidebar = (props) => {
const location = useLocation()
const selectedKey = (() => {
const match = Object.keys(routeKeyMap).find((path) =>
location.pathname.startsWith(path)
)
return match ? routeKeyMap[match] : 'filaments'
})()
return <DashboardSidebar items={items} selectedKey={selectedKey} {...props} />
}
export default ManagementSidebar

View File

@ -11,7 +11,7 @@ import {
Popover, Popover,
Input, Input,
Badge, Badge,
Tag Typography
} from 'antd' } from 'antd'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText' import IdText from '../common/IdText'
@ -24,9 +24,15 @@ import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon' import CheckIcon from '../../Icons/CheckIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import useColumnVisibility from '../hooks/useColumnVisibility' import useColumnVisibility from '../hooks/useColumnVisibility'
import GridIcon from '../../Icons/GridIcon'
import ListIcon from '../../Icons/ListIcon'
import useViewMode from '../hooks/useViewMode'
import config from '../../../config' import config from '../../../config'
import NoteTypeIcon from '../../Icons/NoteTypeIcon' import NoteTypeIcon from '../../Icons/NoteTypeIcon'
import BoolDisplay from '../common/BoolDisplay'
const { Text } = Typography
const NoteTypes = () => { const NoteTypes = () => {
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
@ -34,6 +40,7 @@ const NoteTypes = () => {
const [newNoteTypeOpen, setNewNoteTypeOpen] = useState(false) const [newNoteTypeOpen, setNewNoteTypeOpen] = useState(false)
const tableRef = useRef() const tableRef = useRef()
const { authenticated } = useContext(AuthContext) const { authenticated } = useContext(AuthContext)
const [viewMode, setViewMode] = useViewMode('NoteTypes')
const getFilterDropdown = ({ const getFilterDropdown = ({
setSelectedKeys, setSelectedKeys,
@ -114,8 +121,7 @@ const NoteTypes = () => {
const columns = [ const columns = [
{ {
title: '', title: <NoteTypeIcon />,
dataIndex: '',
key: 'icon', key: 'icon',
width: 40, width: 40,
fixed: 'left', fixed: 'left',
@ -172,18 +178,15 @@ const NoteTypes = () => {
dataIndex: 'color', dataIndex: 'color',
key: 'color', key: 'color',
width: 120, width: 120,
render: (color) => <Badge color={color} text={color} /> render: (color) =>
color ? <Badge color={color} text={color} /> : <Text>n/a</Text>
}, },
{ {
title: 'Active', title: 'Active',
dataIndex: 'isActive', dataIndex: 'active',
key: 'isActive', key: 'active',
width: 100, width: 100,
render: (isActive) => ( render: (active) => <BoolDisplay value={active} yesNo={true} />,
<Tag color={isActive ? 'success' : 'error'}>
{isActive ? 'Yes' : 'No'}
</Tag>
),
sorter: true sorter: true
}, },
{ {
@ -221,7 +224,7 @@ const NoteTypes = () => {
key: 'actions', key: 'actions',
fixed: 'right', fixed: 'right',
width: 150, width: 150,
render: (text, record) => { render: (record) => {
return ( return (
<Space gap='small'> <Space gap='small'>
<Button <Button
@ -290,12 +293,21 @@ const NoteTypes = () => {
<Button>View</Button> <Button>View</Button>
</Popover> </Popover>
</Space> </Space>
<Space>
<Button
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
onClick={() =>
setViewMode(viewMode === 'cards' ? 'list' : 'cards')
}
/>
</Space>
</Flex> </Flex>
<DashboardTable <DashboardTable
ref={tableRef} ref={tableRef}
columns={visibleColumns} columns={visibleColumns}
url={`${config.backendUrl}/notetypes`} url={`${config.backendUrl}/notetypes`}
authenticated={authenticated} authenticated={authenticated}
cards={viewMode === 'cards'}
/> />
</Flex> </Flex>
<Modal <Modal

View File

@ -15,11 +15,11 @@ import {
ColorPicker, ColorPicker,
Switch, Switch,
Badge, Badge,
Checkbox, Checkbox
Tag
} from 'antd' } from 'antd'
import config from '../../../../config' import config from '../../../../config'
import BoolDisplay from '../../common/BoolDisplay'
const { Title, Text } = Typography const { Title, Text } = Typography
@ -73,10 +73,10 @@ const NewNoteType = ({ onOk, reset }) => {
{ {
key: 'active', key: 'active',
label: 'Active', label: 'Active',
children: newNoteTypeFormValues.active ? ( children: newNoteTypeFormValues ? (
<Tag color={'success'}>Yes</Tag> <BoolDisplay value={newNoteTypeFormValues.active} yesNo={true} />
) : ( ) : (
<Tag color={'error'}>No</Tag> <Text>n/a</Text>
) )
} }
] ]

View File

@ -15,12 +15,11 @@ import {
Switch, Switch,
ColorPicker, ColorPicker,
Checkbox, Checkbox,
Tag,
Dropdown, Dropdown,
Popover, Popover,
Badge Badge
} from 'antd' } from 'antd'
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons' import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import IdText from '../../common/IdText' import IdText from '../../common/IdText'
import TimeDisplay from '../../common/TimeDisplay' import TimeDisplay from '../../common/TimeDisplay'
import ReloadIcon from '../../../Icons/ReloadIcon' import ReloadIcon from '../../../Icons/ReloadIcon'
@ -31,8 +30,11 @@ import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable' import AuditLogTable from '../../common/AuditLogTable'
import config from '../../../../config.js' import config from '../../../../config.js'
import BoolDisplay from '../../common/BoolDisplay.jsx'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
const { Title } = Typography const { Title, Text } = Typography
const NoteTypeInfo = () => { const NoteTypeInfo = () => {
const [noteTypeData, setNoteTypeData] = useState(null) const [noteTypeData, setNoteTypeData] = useState(null)
@ -224,24 +226,21 @@ const NoteTypeInfo = () => {
<Flex vertical gap={'large'}> <Flex vertical gap={'large'}>
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.info ? ['1'] : []} activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)} onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined
rotate={isActive ? 90 : 0} rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }} style={{ paddingTop: '2px' }}
/> />
)} )}
className='no-h-padding-collapse no-t-padding-collapse' className='no-h-padding-collapse no-t-padding-collapse'
> >
<Collapse.Panel <Collapse.Panel
header={ header={
<Flex <Flex align='center' gap={'small'}>
align='center' <InfoCircleIcon />
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}> <Title level={5} style={{ margin: 0 }}>
Note Type Information Note Type Information
</Title> </Title>
@ -337,7 +336,7 @@ const NoteTypeInfo = () => {
text={noteTypeData.color} text={noteTypeData.color}
/> />
) : ( ) : (
'No color set' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
@ -350,10 +349,13 @@ const NoteTypeInfo = () => {
> >
<Switch /> <Switch />
</Form.Item> </Form.Item>
) : noteTypeData?.active ? ( ) : noteTypeData ? (
<Tag color='success'>Yes</Tag> <BoolDisplay
value={noteTypeData.active}
yesNo={true}
/>
) : ( ) : (
<Tag color='error'>No</Tag> <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
@ -364,13 +366,13 @@ const NoteTypeInfo = () => {
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.auditLogs ? ['2'] : []} activeKey={collapseState.auditLogs ? ['2'] : []}
onChange={(keys) => onChange={(keys) =>
updateCollapseState('auditLogs', keys.length > 0) updateCollapseState('auditLogs', keys.length > 0)
} }
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined
rotate={isActive ? 90 : 0} rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }} style={{ paddingTop: '2px' }}
/> />
@ -379,9 +381,12 @@ const NoteTypeInfo = () => {
> >
<Collapse.Panel <Collapse.Panel
header={ header={
<Flex align='center' gap={'small'}>
<AuditLogIcon />
<Title level={5} style={{ margin: 0 }}> <Title level={5} style={{ margin: 0 }}>
Audit Logs Audit Logs
</Title> </Title>
</Flex>
} }
key='2' key='2'
> >

View File

@ -28,6 +28,9 @@ import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon' import CheckIcon from '../../Icons/CheckIcon'
import useColumnVisibility from '../hooks/useColumnVisibility' import useColumnVisibility from '../hooks/useColumnVisibility'
import TimeDisplay from '../common/TimeDisplay' import TimeDisplay from '../common/TimeDisplay'
import GridIcon from '../../Icons/GridIcon'
import ListIcon from '../../Icons/ListIcon'
import useViewMode from '../hooks/useViewMode'
import config from '../../../config' import config from '../../../config'
@ -39,16 +42,16 @@ const Parts = () => {
const [newProductOpen, setNewProductOpen] = useState(false) const [newProductOpen, setNewProductOpen] = useState(false)
const tableRef = useRef() const tableRef = useRef()
const { authenticated } = useContext(AuthContext) const { authenticated } = useContext(AuthContext)
const [viewMode, setViewMode] = useViewMode('Parts')
// Column definitions // Column definitions
const columns = [ const columns = [
{ {
title: '', title: <PartIcon />,
dataIndex: '', key: 'icon',
key: '',
width: 40, width: 40,
fixed: 'left', fixed: 'left',
render: () => <PartIcon></PartIcon> render: () => <PartIcon />
}, },
{ {
title: 'Name', title: 'Name',
@ -84,7 +87,7 @@ const Parts = () => {
title: 'Product Name', title: 'Product Name',
key: 'productName', key: 'productName',
width: 200, width: 200,
render: (record) => <Text>{record.product.name}</Text>, render: (record) => <Text>{record?.product?.name}</Text>,
filterDropdown: ({ filterDropdown: ({
setSelectedKeys, setSelectedKeys,
selectedKeys, selectedKeys,
@ -107,7 +110,7 @@ const Parts = () => {
width: 180, width: 180,
render: (record) => ( render: (record) => (
<IdText <IdText
id={record.product._id} id={record?.product?._id}
type={'product'} type={'product'}
longId={false} longId={false}
showHyperlink={true} showHyperlink={true}
@ -147,7 +150,7 @@ const Parts = () => {
key: 'actions', key: 'actions',
fixed: 'right', fixed: 'right',
width: 150, width: 150,
render: (text, record) => { render: (record) => {
return ( return (
<Space gap='small'> <Space gap='small'>
<Button <Button
@ -298,12 +301,21 @@ const Parts = () => {
<Button>View</Button> <Button>View</Button>
</Popover> </Popover>
</Space> </Space>
<Space>
<Button
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
onClick={() =>
setViewMode(viewMode === 'cards' ? 'list' : 'cards')
}
/>
</Space>
</Flex> </Flex>
<DashboardTable <DashboardTable
ref={tableRef} ref={tableRef}
columns={visibleColumns} columns={visibleColumns}
url={`${config.backendUrl}/parts`} url={`${config.backendUrl}/parts`}
authenticated={authenticated} authenticated={authenticated}
cards={viewMode === 'cards'}
/> />
</Flex> </Flex>
<Modal <Modal

View File

@ -16,9 +16,11 @@ import {
InputNumber, InputNumber,
Switch, Switch,
Tag, Tag,
Collapse Collapse,
Dropdown,
Popover
} from 'antd' } from 'antd'
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons' import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import IdText from '../../common/IdText.jsx' import IdText from '../../common/IdText.jsx'
import { StlViewer } from 'react-stl-viewer' import { StlViewer } from 'react-stl-viewer'
import ReloadIcon from '../../../Icons/ReloadIcon' import ReloadIcon from '../../../Icons/ReloadIcon'
@ -27,10 +29,17 @@ import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
import CheckIcon from '../../../Icons/CheckIcon.jsx' import CheckIcon from '../../../Icons/CheckIcon.jsx'
import useCollapseState from '../../hooks/useCollapseState' import useCollapseState from '../../hooks/useCollapseState'
import TimeDisplay from '../../common/TimeDisplay.jsx' import TimeDisplay from '../../common/TimeDisplay.jsx'
import AuditLogTable from '../../common/AuditLogTable'
import DashboardNotes from '../../common/DashboardNotes'
import config from '../../../../config.js' import config from '../../../../config.js'
import BoolDisplay from '../../common/BoolDisplay.jsx'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import PartIcon from '../../../Icons/PartIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
const { Title } = Typography const { Title, Text } = Typography
const PartInfo = () => { const PartInfo = () => {
const [partData, setPartData] = useState(null) const [partData, setPartData] = useState(null)
@ -43,7 +52,9 @@ const PartInfo = () => {
const [useGlobalPricing, setUseGlobalPricing] = useState(true) const [useGlobalPricing, setUseGlobalPricing] = useState(true)
const [collapseState, updateCollapseState] = useCollapseState('PartInfo', { const [collapseState, updateCollapseState] = useCollapseState('PartInfo', {
info: true, info: true,
preview: true preview: true,
notes: true,
auditLogs: true
}) })
const [partForm] = Form.useForm() const [partForm] = Form.useForm()
@ -178,9 +189,16 @@ const PartInfo = () => {
} }
const cancelEditing = () => { const cancelEditing = () => {
// Reset form values to original data
if (partData) {
partForm.setFieldsValue({ partForm.setFieldsValue({
name: partData?.name || '' name: partData.name || '',
price: partData.price || null,
margin: partData.margin || null,
marginOrPrice: partData.marginOrPrice,
useGlobalPricing: partData.useGlobalPricing
}) })
}
setIsEditing(false) setIsEditing(false)
} }
@ -213,15 +231,49 @@ const PartInfo = () => {
} }
} }
if (fetchLoading) { const actionItems = {
items: [
{
label: 'Reload Part',
key: 'reload',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchPartDetails()
}
}
}
const getViewDropdownItems = () => {
const sections = [
{ key: 'info', label: 'Part Information' },
{ key: 'preview', label: 'Part Preview' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]
return ( return (
<div style={{ textAlign: 'center', padding: '20px' }}> <Flex vertical>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} /> <Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
</div> {sections.map((section) => (
<Checkbox
checked={collapseState[section.key]}
key={section.key}
onChange={(e) => {
updateCollapseState(section.key, e.target.checked)
}}
>
{section.label}
</Checkbox>
))}
</Flex>
</Flex>
) )
} }
if (error || !partData) { if (error) {
return ( return (
<Space <Space
direction='vertical' direction='vertical'
@ -236,32 +288,26 @@ const PartInfo = () => {
} }
return ( return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}> <>
{contextHolder} {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 <Flex
align='center' gap='large'
justify='space-between' vertical='true'
style={{ width: '100%' }} style={{ height: '100%', minHeight: 0 }}
> >
<Title level={5} style={{ margin: 0 }}> <Flex justify={'space-between'}>
Part Information <Space size='small'>
</Title> <Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button>View</Button>
</Popover>
</Space>
<Space> <Space>
{isEditing ? ( {isEditing ? (
<> <>
@ -270,6 +316,7 @@ const PartInfo = () => {
type='primary' type='primary'
onClick={updateInfo} onClick={updateInfo}
loading={loading} loading={loading}
disabled={loading}
/> />
<Button <Button
icon={<XMarkIcon />} icon={<XMarkIcon />}
@ -282,6 +329,40 @@ const PartInfo = () => {
)} )}
</Space> </Space>
</Flex> </Flex>
{error ? (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Part not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchPartDetails}>
Retry
</Button>
</Space>
) : (
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) =>
updateCollapseState('info', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<InfoCircleIcon />
<Title level={5} style={{ margin: 0 }}>
Part Information
</Title>
</Flex>
} }
key='1' key='1'
> >
@ -295,10 +376,14 @@ const PartInfo = () => {
})) }))
} }
initialValues={{ initialValues={{
name: partData.name || '', name: partData?.name || '',
version: partData.version || '', version: partData?.version || '',
tags: partData.tags || [] tags: partData?.tags || []
}} }}
>
<Spin
indicator={<LoadingOutlined />}
spinning={fetchLoading}
> >
<Descriptions <Descriptions
bordered bordered
@ -312,14 +397,21 @@ const PartInfo = () => {
}} }}
> >
<Descriptions.Item label='ID' span={1}> <Descriptions.Item label='ID' span={1}>
{partData.id ? ( {partData?.id ? (
<IdText id={partData.id} type='part'></IdText> <IdText id={partData.id} type='part'></IdText>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Created At'> <Descriptions.Item label='Created At'>
<TimeDisplay dateTime={partData.createdAt} showSince={true} /> {partData?.createdAt ? (
<TimeDisplay
dateTime={partData.createdAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Name' span={1}> <Descriptions.Item label='Name' span={1}>
@ -340,26 +432,41 @@ const PartInfo = () => {
> >
<Input placeholder='Enter product name' /> <Input placeholder='Enter product name' />
</Form.Item> </Form.Item>
) : partData?.name ? (
<Text>{partData.name}</Text>
) : ( ) : (
partData.name || 'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Updated At'> <Descriptions.Item label='Updated At'>
<TimeDisplay dateTime={partData.updatedAt} showSince={true} /> {partData?.updatedAt ? (
<TimeDisplay
dateTime={partData.updatedAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Product Name' span={1}> <Descriptions.Item label='Product Name' span={1}>
{partData.product.name || 'n/a'} {partData?.product?.name ? (
<Text>{partData.product.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Product ID' span={1}> <Descriptions.Item label='Product ID' span={1}>
{( {partData?.product?._id ? (
<IdText <IdText
id={partData.product._id} id={partData.product._id}
type={'product'} type={'product'}
showHyperlink={true} showHyperlink={true}
/> />
) || 'n/a'} ) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item <Descriptions.Item
label={!marginOrPrice ? 'Margin' : 'Price'} label={!marginOrPrice ? 'Margin' : 'Price'}
@ -412,16 +519,16 @@ const PartInfo = () => {
<Checkbox>Price</Checkbox> <Checkbox>Price</Checkbox>
</Form.Item> </Form.Item>
</Flex> </Flex>
) : partData.margin && ) : partData?.margin &&
marginOrPrice == false && marginOrPrice == false &&
partData.useGlobalPricing == false ? ( partData?.useGlobalPricing == false ? (
partData.margin + '%' <Text>{partData.margin + '%'}</Text>
) : partData.price && ) : partData?.price &&
marginOrPrice == true && marginOrPrice == true &&
partData.useGlobalPricing == false ? ( partData?.useGlobalPricing == false ? (
'£' + partData.price <Text>{'£' + partData.price}</Text>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Global Pricing'> <Descriptions.Item label='Global Pricing'>
@ -439,48 +546,57 @@ const PartInfo = () => {
> >
<Switch /> <Switch />
</Form.Item> </Form.Item>
) : partData.useGlobalPricing == true ? ( ) : partData ? (
<Tag color='success' icon={<CheckIcon />}> <BoolDisplay
Yes value={partData.useGlobalPricing}
</Tag> yesNo={true}
) : partData.useGlobalPricing == false ? ( />
<Tag icon={<XMarkIcon />}>No</Tag>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Version' span={1}> <Descriptions.Item label='Version' span={1}>
{partData.version || 'n/a'} {partData?.version ? (
<Text>{partData.version}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Tags'> <Descriptions.Item label='Tags'>
{partData.tags && {partData?.tags && partData.tags.length > 0 ? (
partData.tags.map((tag, index) => ( partData.tags.map((tag, index) => (
<Tag key={index}>{tag}</Tag> <Tag key={index}>{tag}</Tag>
))} ))
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
</Spin>
</Form> </Form>
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.preview ? ['2'] : []} activeKey={collapseState.preview ? ['2'] : []}
onChange={(keys) => updateCollapseState('preview', keys.length > 0)} onChange={(keys) =>
updateCollapseState('preview', keys.length > 0)
}
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined rotate={isActive ? -90 : 0} />
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)} )}
className='no-h-padding-collapse' className='no-h-padding-collapse'
> >
<Collapse.Panel <Collapse.Panel
header={ header={
<Flex align='center' gap={'middle'}>
<PartIcon />
<Title level={5} style={{ margin: 0 }}> <Title level={5} style={{ margin: 0 }}>
Part Preview Part Preview
</Title> </Title>
</Flex>
} }
key='2' key='2'
> >
@ -496,7 +612,9 @@ const PartInfo = () => {
}} }}
> >
<Space direction='vertical' align='center'> <Space direction='vertical' align='center'>
<XMarkIcon style={{ fontSize: '24px', color: '#ff4d4f' }} /> <XMarkIcon
style={{ fontSize: '24px', color: '#ff4d4f' }}
/>
<Typography.Text type='danger'> <Typography.Text type='danger'>
{stlLoadError} {stlLoadError}
</Typography.Text> </Typography.Text>
@ -518,8 +636,71 @@ const PartInfo = () => {
</Card> </Card>
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.notes ? ['notes'] : []}
onChange={(keys) =>
updateCollapseState('notes', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<NoteIcon />
<Title level={5} style={{ margin: 0 }}>
Notes
</Title>
</Flex>
}
key='notes'
>
<Card>
<DashboardNotes _id={partId} />
</Card>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
onChange={(keys) =>
updateCollapseState('auditLogs', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<AuditLogIcon />
<Title level={5} style={{ margin: 0 }}>
Audit Logs
</Title>
</Flex>
}
key='auditLogs'
>
<AuditLogTable
items={partData?.auditLogs || []}
loading={fetchLoading}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</Flex> </Flex>
</div> </div>
)}
</Flex>
</>
) )
} }

View File

@ -28,6 +28,9 @@ import ReloadIcon from '../../Icons/ReloadIcon'
import XMarkIcon from '../../Icons/XMarkIcon' import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon' import CheckIcon from '../../Icons/CheckIcon'
import useColumnVisibility from '../hooks/useColumnVisibility' import useColumnVisibility from '../hooks/useColumnVisibility'
import GridIcon from '../../Icons/GridIcon'
import ListIcon from '../../Icons/ListIcon'
import useViewMode from '../hooks/useViewMode'
import config from '../../../config' import config from '../../../config'
@ -37,6 +40,7 @@ const Products = () => {
const [newProductOpen, setNewProductOpen] = useState(false) const [newProductOpen, setNewProductOpen] = useState(false)
const tableRef = useRef() const tableRef = useRef()
const { authenticated } = useContext(AuthContext) const { authenticated } = useContext(AuthContext)
const [viewMode, setViewMode] = useViewMode('Products')
const getProductActionItems = (id) => { const getProductActionItems = (id) => {
return { return {
@ -63,12 +67,11 @@ const Products = () => {
// Column definitions // Column definitions
const columns = [ const columns = [
{ {
title: '', title: <ProductIcon />,
dataIndex: '', key: 'icon',
key: '',
width: 40, width: 40,
fixed: 'left', fixed: 'left',
render: () => <ProductIcon></ProductIcon> render: () => <ProductIcon />
}, },
{ {
title: 'Name', title: 'Name',
@ -114,7 +117,7 @@ const Products = () => {
propertyName: 'ID' propertyName: 'ID'
}), }),
onFilter: (value, record) => onFilter: (value, record) =>
record._id.toLowerCase().includes(value.toLowerCase()), record?._id.toLowerCase().includes(value.toLowerCase()),
sorter: true sorter: true
}, },
{ {
@ -212,7 +215,7 @@ const Products = () => {
key: 'actions', key: 'actions',
fixed: 'right', fixed: 'right',
width: 150, width: 150,
render: (text, record) => { render: (record) => {
return ( return (
<Space gap='small'> <Space gap='small'>
<Button <Button
@ -341,12 +344,21 @@ const Products = () => {
<Button>View</Button> <Button>View</Button>
</Popover> </Popover>
</Space> </Space>
<Space>
<Button
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
onClick={() =>
setViewMode(viewMode === 'cards' ? 'list' : 'cards')
}
/>
</Space>
</Flex> </Flex>
<DashboardTable <DashboardTable
ref={tableRef} ref={tableRef}
columns={visibleColumns} columns={visibleColumns}
url={`${config.backendUrl}/products`} url={`${config.backendUrl}/products`}
authenticated={authenticated} authenticated={authenticated}
cards={viewMode === 'cards'}
/> />
</Flex> </Flex>
<Modal <Modal

View File

@ -14,9 +14,12 @@ import {
Tag, Tag,
Checkbox, Checkbox,
InputNumber, InputNumber,
Collapse Collapse,
Dropdown,
Popover,
Card
} from 'antd' } from 'antd'
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons' import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import IdText from '../../common/IdText.jsx' import IdText from '../../common/IdText.jsx'
import VendorSelect from '../../common/VendorSelect.jsx' import VendorSelect from '../../common/VendorSelect.jsx'
import PartsTable from '../../common/PartsTable.jsx' import PartsTable from '../../common/PartsTable.jsx'
@ -27,10 +30,16 @@ import EditIcon from '../../../Icons/EditIcon.jsx'
import XMarkIcon from '../../../Icons/XMarkIcon.jsx' import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
import CheckIcon from '../../../Icons/CheckIcon.jsx' import CheckIcon from '../../../Icons/CheckIcon.jsx'
import TimeDisplay from '../../common/TimeDisplay.jsx' import TimeDisplay from '../../common/TimeDisplay.jsx'
import AuditLogTable from '../../common/AuditLogTable'
import DashboardNotes from '../../common/DashboardNotes'
import config from '../../../../config.js' import config from '../../../../config.js'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import ProductIcon from '../../../Icons/ProductIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
const { Title } = Typography const { Title, Text } = Typography
const ProductInfo = () => { const ProductInfo = () => {
const [productData, setProductData] = useState(null) const [productData, setProductData] = useState(null)
@ -44,7 +53,9 @@ const ProductInfo = () => {
const [marginOrPrice, setMarginOrPrice] = useState(false) const [marginOrPrice, setMarginOrPrice] = useState(false)
const [collapseState, updateCollapseState] = useCollapseState('ProductInfo', { const [collapseState, updateCollapseState] = useCollapseState('ProductInfo', {
info: true, info: true,
parts: true parts: true,
notes: true,
auditLogs: true
}) })
const [productForm] = Form.useForm() const [productForm] = Form.useForm()
@ -125,17 +136,20 @@ const ProductInfo = () => {
} }
const cancelEditing = () => { const cancelEditing = () => {
// Reset form values to original data
if (productData) {
productForm.setFieldsValue({ productForm.setFieldsValue({
name: productData?.name || '', name: productData.name || '',
vendor: productData?.vendor || { id: null, name: '' }, vendor: productData.vendor || { id: null, name: '' },
version: productData?.version || '', version: productData.version || '',
tags: productData?.tags || [], tags: productData.tags || [],
cost: productData?.cost || null, cost: productData.cost || null,
price: productData?.price || null, price: productData.price || null,
margin: productData?.margin || null, margin: productData.margin || null,
marginOrPrice: productData?.marginOrPrice || null marginOrPrice: productData.marginOrPrice || null
}) })
setMarginOrPrice(productData?.marginOrPrice) setMarginOrPrice(productData.marginOrPrice)
}
setIsEditing(false) setIsEditing(false)
} }
@ -169,15 +183,49 @@ const ProductInfo = () => {
} }
} }
if (fetchLoading) { const actionItems = {
items: [
{
label: 'Reload Product',
key: 'reload',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchProductDetails()
}
}
}
const getViewDropdownItems = () => {
const sections = [
{ key: 'info', label: 'Product Information' },
{ key: 'parts', label: 'Product Parts' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]
return ( return (
<div style={{ textAlign: 'center', padding: '20px' }}> <Flex vertical>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} /> <Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
</div> {sections.map((section) => (
<Checkbox
checked={collapseState[section.key]}
key={section.key}
onChange={(e) => {
updateCollapseState(section.key, e.target.checked)
}}
>
{section.label}
</Checkbox>
))}
</Flex>
</Flex>
) )
} }
if (error || !productData) { if (error) {
return ( return (
<Space <Space
direction='vertical' direction='vertical'
@ -192,32 +240,26 @@ const ProductInfo = () => {
} }
return ( return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}> <>
{contextHolder} {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 <Flex
align='center' gap='large'
justify='space-between' vertical='true'
style={{ width: '100%' }} style={{ height: '100%', minHeight: 0 }}
> >
<Title level={5} style={{ margin: 0 }}> <Flex justify={'space-between'}>
Product Information <Space size='small'>
</Title> <Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button>View</Button>
</Popover>
</Space>
<Space> <Space>
{isEditing ? ( {isEditing ? (
<> <>
@ -226,6 +268,7 @@ const ProductInfo = () => {
type='primary' type='primary'
onClick={updateInfo} onClick={updateInfo}
loading={loading} loading={loading}
disabled={loading}
/> />
<Button <Button
icon={<XMarkIcon />} icon={<XMarkIcon />}
@ -238,6 +281,40 @@ const ProductInfo = () => {
)} )}
</Space> </Space>
</Flex> </Flex>
{error ? (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Product not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchProductDetails}>
Retry
</Button>
</Space>
) : (
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) =>
updateCollapseState('info', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<InfoCircleIcon />
<Title level={5} style={{ margin: 0 }}>
Product Information
</Title>
</Flex>
} }
key='1' key='1'
> >
@ -251,11 +328,15 @@ const ProductInfo = () => {
})) }))
} }
initialValues={{ initialValues={{
name: productData.name || '', name: productData?.name || '',
vendor: productData.vendor || { id: null, name: '' }, vendor: productData?.vendor || { id: null, name: '' },
version: productData.version || '', version: productData?.version || '',
tags: productData.tags || [] tags: productData?.tags || []
}} }}
>
<Spin
indicator={<LoadingOutlined />}
spinning={fetchLoading}
> >
<Descriptions <Descriptions
bordered bordered
@ -269,18 +350,22 @@ const ProductInfo = () => {
}} }}
> >
<Descriptions.Item label='ID' span={1}> <Descriptions.Item label='ID' span={1}>
{productData.id ? ( {productData?.id ? (
<IdText id={productData.id} type='product'></IdText> <IdText id={productData.id} type='product'></IdText>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Created At'> <Descriptions.Item label='Created At'>
{productData?.createdAt ? (
<TimeDisplay <TimeDisplay
dateTime={productData.createdAt} dateTime={productData.createdAt}
showSince={true} showSince={true}
/> />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Name' span={1}> <Descriptions.Item label='Name' span={1}>
@ -301,16 +386,22 @@ const ProductInfo = () => {
> >
<Input placeholder='Enter product name' /> <Input placeholder='Enter product name' />
</Form.Item> </Form.Item>
) : productData?.name ? (
<Text>{productData.name}</Text>
) : ( ) : (
productData.name || 'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Updated At'> <Descriptions.Item label='Updated At'>
{productData?.updatedAt ? (
<TimeDisplay <TimeDisplay
dateTime={productData.updatedAt} dateTime={productData.updatedAt}
showSince={true} showSince={true}
/> />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Vendor'> <Descriptions.Item label='Vendor'>
@ -318,23 +409,32 @@ const ProductInfo = () => {
<Form.Item <Form.Item
name='vendor' name='vendor'
rules={[ rules={[
{ required: true, message: 'Please enter a vendor' } {
required: true,
message: 'Please enter a vendor'
}
]} ]}
style={{ margin: 0 }} style={{ margin: 0 }}
> >
<VendorSelect /> <VendorSelect />
</Form.Item> </Form.Item>
) : productData?.vendor?.name ? (
<Text>{productData.vendor.name}</Text>
) : ( ) : (
productData.vendor.name || 'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Vendor ID'> <Descriptions.Item label='Vendor ID'>
{productData?.vendor?.id ? (
<IdText <IdText
id={productData.vendor.id} id={productData.vendor.id}
type={'vendor'} type={'vendor'}
showHyperlink={true} showHyperlink={true}
/> />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item <Descriptions.Item
@ -388,12 +488,12 @@ const ProductInfo = () => {
<Checkbox>Price</Checkbox> <Checkbox>Price</Checkbox>
</Form.Item> </Form.Item>
</Flex> </Flex>
) : productData.margin && marginOrPrice == false ? ( ) : productData?.margin && marginOrPrice == false ? (
productData.margin + '%' <Text>{productData.margin + '%'}</Text>
) : productData.price && marginOrPrice == true ? ( ) : productData?.price && marginOrPrice == true ? (
'£' + productData.price <Text>{'£' + productData.price}</Text>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
@ -402,10 +502,10 @@ const ProductInfo = () => {
<Form.Item name='version' style={{ margin: 0 }}> <Form.Item name='version' style={{ margin: 0 }}>
<Input placeholder='Enter version' /> <Input placeholder='Enter version' />
</Form.Item> </Form.Item>
) : productData.version ? ( ) : productData?.version ? (
<Tag>{productData.version}</Tag> <Tag>{productData.version}</Tag>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
@ -417,7 +517,7 @@ const ProductInfo = () => {
wrap wrap
style={{ marginBottom: 4, maxWidth: '300px' }} style={{ marginBottom: 4, maxWidth: '300px' }}
> >
{productData.tags.map((tag) => ( {productData?.tags?.map((tag) => (
<Tag <Tag
key={tag} key={tag}
color='blue' color='blue'
@ -433,11 +533,18 @@ const ProductInfo = () => {
<Form.Item name='newTag' noStyle> <Form.Item name='newTag' noStyle>
<Input placeholder='Add new tag' /> <Input placeholder='Add new tag' />
</Form.Item> </Form.Item>
<Button onClick={handleTagAdd} icon={<PlusIcon />} /> <Button
onClick={handleTagAdd}
icon={<PlusIcon />}
/>
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
) : productData.tags?.length > 0 ? ( ) : productData?.tags?.length > 0 ? (
<Space size={[0, 2]} wrap style={{ maxWidth: '300px' }}> <Space
size={[0, 2]}
wrap
style={{ maxWidth: '300px' }}
>
{productData.tags.map((tag, index) => ( {productData.tags.map((tag, index) => (
<Tag key={index} color='blue'> <Tag key={index} color='blue'>
{tag} {tag}
@ -445,40 +552,106 @@ const ProductInfo = () => {
))} ))}
</Space> </Space>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
</Spin>
</Form> </Form>
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.parts ? ['2'] : []} activeKey={collapseState.parts ? ['2'] : []}
onChange={(keys) => updateCollapseState('parts', keys.length > 0)} onChange={(keys) =>
updateCollapseState('parts', keys.length > 0)
}
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined rotate={isActive ? -90 : 0} />
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)} )}
className='no-h-padding-collapse' className='no-h-padding-collapse'
> >
<Collapse.Panel <Collapse.Panel
header={ header={
<Flex align='center' gap={'middle'}>
<ProductIcon />
<Title level={5} style={{ margin: 0 }}> <Title level={5} style={{ margin: 0 }}>
Product Parts Product Parts
</Title> </Title>
</Flex>
} }
key='2' key='2'
> >
<PartsTable data={productData.parts} /> <PartsTable data={productData?.parts || []} />
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.notes ? ['notes'] : []}
onChange={(keys) =>
updateCollapseState('notes', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<NoteIcon />
<Title level={5} style={{ margin: 0 }}>
Notes
</Title>
</Flex>
}
key='notes'
>
<Card>
<DashboardNotes _id={productId} />
</Card>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
onChange={(keys) =>
updateCollapseState('auditLogs', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<AuditLogIcon />
<Title level={5} style={{ margin: 0 }}>
Audit Logs
</Title>
</Flex>
}
key='auditLogs'
>
<AuditLogTable
items={productData?.auditLogs || []}
loading={fetchLoading}
showTargetColumn={false}
/>
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
</Flex> </Flex>
</div> </div>
)}
</Flex>
</>
) )
} }

View File

@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import { Select, Typography, Descriptions, Collapse, Flex } from 'antd' import { Select, Typography, Descriptions, Collapse, Flex } from 'antd'
import { CaretRightOutlined } from '@ant-design/icons' import { CaretLeftOutlined } from '@ant-design/icons'
import { useThemeContext } from '../context/ThemeContext' import { useThemeContext } from '../context/ThemeContext'
import useCollapseState from '../hooks/useCollapseState' import useCollapseState from '../hooks/useCollapseState'
@ -53,13 +53,13 @@ const Settings = () => {
<Flex vertical gap={'large'}> <Flex vertical gap={'large'}>
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.appearance ? ['1'] : []} activeKey={collapseState.appearance ? ['1'] : []}
onChange={(keys) => onChange={(keys) =>
updateCollapseState('appearance', keys.length > 0) updateCollapseState('appearance', keys.length > 0)
} }
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined
rotate={isActive ? 90 : 0} rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }} style={{ paddingTop: '9px' }}
/> />

View File

@ -0,0 +1,381 @@
import React, { useContext, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Button,
Flex,
Space,
Dropdown,
Typography,
Checkbox,
Popover,
Input
} from 'antd'
import { ExportOutlined } 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 PersonIcon from '../../Icons/PersonIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon'
import useColumnVisibility from '../hooks/useColumnVisibility'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import GridIcon from '../../Icons/GridIcon'
import ListIcon from '../../Icons/ListIcon'
import useViewMode from '../hooks/useViewMode'
import config from '../../../config'
const { Link, Text } = Typography
const Users = () => {
const navigate = useNavigate()
const tableRef = useRef()
const { authenticated } = useContext(AuthContext)
const [viewMode, setViewMode] = useViewMode('Users')
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 getUserActionItems = (id) => {
return {
items: [
{
label: 'Info',
key: 'info',
icon: <InfoCircleIcon />
}
],
onClick: ({ key }) => {
if (key === 'info') {
navigate(`/dashboard/management/users/info?userId=${id}`)
}
}
}
}
const columns = [
{
title: <PersonIcon />,
key: 'icon',
width: 40,
fixed: 'left',
render: () => <PersonIcon />
},
{
title: 'Name',
dataIndex: 'name',
key: 'name',
width: 200,
render: (text) => (text ? text : 'n/a'),
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'name'
}),
onFilter: (value, record) =>
record.name?.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'Username',
dataIndex: 'username',
key: 'username',
width: 150,
fixed: 'left',
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'username'
}),
onFilter: (value, record) =>
record.username.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'First Name',
dataIndex: 'firstName',
key: 'firstName',
width: 150,
render: (text) => (text ? text : 'n/a'),
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'first name'
}),
onFilter: (value, record) =>
record.firstName?.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'Last Name',
dataIndex: 'lastName',
key: 'lastName',
width: 150,
render: (text) => (text ? text : 'n/a'),
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'last name'
}),
onFilter: (value, record) =>
record.lastName?.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'Email',
dataIndex: 'email',
key: 'email',
width: 250,
render: (email) =>
email ? (
<Link href={`mailto:${email}`}>
{email} <ExportOutlined />
</Link>
) : (
<Text>n/a</Text>
),
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'email'
}),
onFilter: (value, record) =>
record.email?.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 180,
render: (text) => <IdText id={text} type={'user'} 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: '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: (record) => {
return (
<Space gap='small'>
<Button
icon={<InfoCircleIcon />}
onClick={() =>
navigate(
`/dashboard/management/users/info?userId=${record._id}`
)
}
/>
<Dropdown menu={getUserActionItems(record._id)}>
<Button>Actions</Button>
</Dropdown>
</Space>
)
}
}
]
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
'Users',
columns
)
const visibleColumns = columns.filter(
(col) => !col.key || columnVisibility[col.key]
)
const actionItems = {
items: [
{
label: 'Reload List',
key: 'reloadList',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reloadList') {
tableRef.current?.reload()
}
}
}
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>
<Space>
<Button
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
onClick={() => setViewMode(viewMode === 'cards' ? 'list' : 'cards')}
/>
</Space>
</Flex>
<DashboardTable
ref={tableRef}
columns={visibleColumns}
url={`${config.backendUrl}/users`}
authenticated={authenticated}
cards={viewMode === 'cards'}
/>
</Flex>
)
}
export default Users

View File

@ -0,0 +1,264 @@
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
} from 'antd'
import config from '../../../../config'
const { Title } = Typography
const initialNewUserForm = {
username: '',
name: '',
firstName: '',
lastName: '',
email: ''
}
const NewUser = ({ onOk, reset }) => {
const [messageApi, contextHolder] = message.useMessage()
const [newUserLoading, setNewUserLoading] = useState(false)
const [currentStep, setCurrentStep] = useState(0)
const [nextEnabled, setNextEnabled] = useState(false)
const [newUserForm] = Form.useForm()
const [newUserFormValues, setNewUserFormValues] = useState(initialNewUserForm)
const isMobile = useMediaQuery({ maxWidth: 768 })
const newUserFormUpdateValues = Form.useWatch([], newUserForm)
React.useEffect(() => {
newUserForm
.validateFields({
validateOnly: true
})
.then(() => setNextEnabled(true))
.catch(() => setNextEnabled(false))
}, [newUserForm, newUserFormUpdateValues])
const summaryItems = [
{
key: 'username',
label: 'Username',
children: newUserFormValues.username
},
{
key: 'name',
label: 'Full Name',
children: newUserFormValues.name || 'n/a'
},
{
key: 'firstName',
label: 'First Name',
children: newUserFormValues.firstName || 'n/a'
},
{
key: 'lastName',
label: 'Last Name',
children: newUserFormValues.lastName || 'n/a'
},
{
key: 'email',
label: 'Email',
children: newUserFormValues.email || 'n/a'
}
]
React.useEffect(() => {
if (reset) {
newUserForm.resetFields()
}
}, [reset, newUserForm])
const handleNewUser = async () => {
setNewUserLoading(true)
try {
await axios.post(`${config.backendUrl}/users`, newUserFormValues, {
withCredentials: true
})
onOk()
} catch (error) {
messageApi.error('Error creating new user: ' + error.message)
} finally {
setNewUserLoading(false)
}
}
const steps = [
{
title: 'Details',
key: 'details',
content: (
<>
<Form.Item
label='Username'
name='username'
rules={[
{
required: true,
message: 'Please enter a username.'
},
{
min: 3,
message: 'Username must be at least 3 characters.'
}
]}
>
<Input />
</Form.Item>
<Form.Item
label='Full Name'
name='name'
rules={[
{
required: true,
message: 'Please enter a full name.'
}
]}
>
<Input />
</Form.Item>
<Form.Item
label='First Name'
name='firstName'
rules={[
{
required: true,
message: 'Please enter a first name.'
}
]}
>
<Input />
</Form.Item>
<Form.Item
label='Last Name'
name='lastName'
rules={[
{
required: true,
message: 'Please enter a last name.'
}
]}
>
<Input />
</Form.Item>
<Form.Item
label='Email'
name='email'
style={{ marginBottom: 8 }}
rules={[
{
required: true,
message: 'Please enter an email address.'
},
{
type: 'email',
message: 'Please enter a valid email address.'
}
]}
>
<Input />
</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 User
</Title>
<Form
name='basic'
autoComplete='off'
form={newUserForm}
onFinish={handleNewUser}
onValuesChange={(changedValues) =>
setNewUserFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={initialNewUserForm}
>
<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={newUserLoading}
onClick={() => {
newUserForm.submit()
}}
>
Done
</Button>
)}
</Flex>
</Flex>
</Flex>
)
}
NewUser.propTypes = {
onOk: PropTypes.func.isRequired,
reset: PropTypes.bool
}
export default NewUser

View File

@ -0,0 +1,510 @@
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,
Dropdown,
Popover,
Card,
Checkbox
} from 'antd'
import {
LoadingOutlined,
ExportOutlined,
CaretLeftOutlined
} 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 DashboardNotes from '../../common/DashboardNotes'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import config from '../../../../config.js'
const { Title, Link, Text } = Typography
const UserInfo = () => {
const [userData, setUserData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const userId = new URLSearchParams(location.search).get('userId')
const [isEditing, setIsEditing] = useState(false)
const [form] = Form.useForm()
const [fetchLoading, setFetchLoading] = useState(true)
const [collapseState, updateCollapseState] = useCollapseState('UserInfo', {
info: true,
notes: true,
auditLogs: true
})
useEffect(() => {
if (userId) {
fetchUserDetails()
}
}, [userId])
useEffect(() => {
if (userData) {
form.setFieldsValue({
username: userData.username || '',
name: userData.name || '',
firstName: userData.firstName || '',
lastName: userData.lastName || '',
email: userData.email || ''
})
}
}, [userData, form])
const fetchUserDetails = async () => {
try {
setFetchLoading(true)
const response = await axios.get(`${config.backendUrl}/users/${userId}`, {
headers: {
Accept: 'application/json'
},
withCredentials: true
})
setUserData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch user details')
messageApi.error('Failed to fetch user details')
} finally {
setFetchLoading(false)
}
}
const startEditing = () => {
updateCollapseState('info', true)
setIsEditing(true)
}
const cancelEditing = () => {
// Reset form values to original data
if (userData) {
form.setFieldsValue({
username: userData.username || '',
name: userData.name || '',
firstName: userData.firstName || '',
lastName: userData.lastName || '',
email: userData.email || ''
})
}
setIsEditing(false)
}
const updateInfo = async () => {
try {
const values = await form.validateFields()
setLoading(true)
await axios.put(`${config.backendUrl}/users/${userId}`, values, {
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
})
setUserData({ ...userData, ...values })
setIsEditing(false)
messageApi.success('User information updated successfully')
} catch (err) {
if (err.errorFields) {
return
}
console.error('Failed to update user information:', err)
messageApi.error('Failed to update user information')
} finally {
fetchUserDetails()
setLoading(false)
}
}
const actionItems = {
items: [
{
label: 'Reload User',
key: 'reload',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchUserDetails()
}
}
}
const getViewDropdownItems = () => {
const sections = [
{ key: 'info', label: 'User Information' },
{ 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>
)
}
if (error) {
return (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'User not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchUserDetails}>
Retry
</Button>
</Space>
)
}
return (
<>
{contextHolder}
<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>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateInfo}
loading={loading}
disabled={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 || 'User not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchUserDetails}>
Retry
</Button>
</Space>
) : (
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) =>
updateCollapseState('info', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined
rotate={isActive ? -90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<InfoCircleIcon />
<Title level={5} style={{ margin: 0 }}>
User Information
</Title>
</Flex>
}
key='1'
>
<Form form={form} layout='vertical'>
<Spin
indicator={<LoadingOutlined />}
spinning={fetchLoading}
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID'>
{userData?._id ? (
<IdText id={userData._id} type='user' />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{userData?.createdAt ? (
<TimeDisplay
dateTime={userData.createdAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='name'
rules={[
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : userData?.name ? (
<Text>{userData.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{userData?.updatedAt ? (
<TimeDisplay
dateTime={userData.updatedAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Username'>
{isEditing ? (
<Form.Item
name='username'
rules={[
{
required: true,
message: 'Please enter a username'
},
{
max: 50,
message:
'Username cannot exceed 50 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : userData?.username ? (
<Text>{userData.username}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='First Name'>
{isEditing ? (
<Form.Item
name='firstName'
rules={[
{
max: 50,
message:
'First name cannot exceed 50 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : userData?.firstName ? (
<Text>{userData.firstName}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Email'>
{isEditing ? (
<Form.Item
name='email'
rules={[
{
type: 'email',
message: 'Please enter a valid email'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : userData?.email ? (
<Link href={`mailto:${userData.email}`}>
{userData.email + ' '}
<ExportOutlined />
</Link>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Last Name'>
{isEditing ? (
<Form.Item
name='lastName'
rules={[
{
max: 50,
message:
'Last name cannot exceed 50 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : userData?.lastName ? (
<Text>{userData.lastName}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</Descriptions>
</Spin>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.notes ? ['notes'] : []}
onChange={(keys) =>
updateCollapseState('notes', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<NoteIcon />
<Title level={5} style={{ margin: 0 }}>
Notes
</Title>
</Flex>
}
key='notes'
>
<Card>
<DashboardNotes _id={userId} />
</Card>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
onChange={(keys) =>
updateCollapseState('auditLogs', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<AuditLogIcon />
<Title level={5} style={{ margin: 0 }}>
Audit Logs
</Title>
</Flex>
}
key='auditLogs'
>
<AuditLogTable
items={userData?.auditLogs || []}
loading={fetchLoading}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</Flex>
</div>
)}
</Flex>
</>
)
}
export default UserInfo

View File

@ -26,6 +26,9 @@ import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon' import CheckIcon from '../../Icons/CheckIcon'
import useColumnVisibility from '../hooks/useColumnVisibility' import useColumnVisibility from '../hooks/useColumnVisibility'
import InfoCircleIcon from '../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import GridIcon from '../../Icons/GridIcon'
import ListIcon from '../../Icons/ListIcon'
import useViewMode from '../hooks/useViewMode'
import config from '../../../config' import config from '../../../config'
@ -37,6 +40,7 @@ const Vendors = () => {
const [newVendorOpen, setNewVendorOpen] = useState(false) const [newVendorOpen, setNewVendorOpen] = useState(false)
const tableRef = useRef() const tableRef = useRef()
const { authenticated } = useContext(AuthContext) const { authenticated } = useContext(AuthContext)
const [viewMode, setViewMode] = useViewMode('Vendors')
const getFilterDropdown = ({ const getFilterDropdown = ({
setSelectedKeys, setSelectedKeys,
@ -117,9 +121,8 @@ const Vendors = () => {
const columns = [ const columns = [
{ {
title: '', title: <VendorIcon />,
dataIndex: '', key: 'icon',
key: '',
width: 40, width: 40,
fixed: 'left', fixed: 'left',
render: () => <VendorIcon /> render: () => <VendorIcon />
@ -282,7 +285,7 @@ const Vendors = () => {
key: 'actions', key: 'actions',
fixed: 'right', fixed: 'right',
width: 150, width: 150,
render: (text, record) => { render: (record) => {
return ( return (
<Space gap='small'> <Space gap='small'>
<Button <Button
@ -351,12 +354,21 @@ const Vendors = () => {
<Button>View</Button> <Button>View</Button>
</Popover> </Popover>
</Space> </Space>
<Space>
<Button
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
onClick={() =>
setViewMode(viewMode === 'cards' ? 'list' : 'cards')
}
/>
</Space>
</Flex> </Flex>
<DashboardTable <DashboardTable
ref={tableRef} ref={tableRef}
columns={visibleColumns} columns={visibleColumns}
url={`${config.backendUrl}/vendors`} url={`${config.backendUrl}/vendors`}
authenticated={authenticated} authenticated={authenticated}
cards={viewMode === 'cards'}
/> />
</Flex> </Flex>
<Modal <Modal

View File

@ -11,12 +11,16 @@ import {
Flex, Flex,
Form, Form,
Input, Input,
Collapse Collapse,
Dropdown,
Popover,
Card,
Checkbox
} from 'antd' } from 'antd'
import { import {
LoadingOutlined, LoadingOutlined,
ExportOutlined, ExportOutlined,
CaretRightOutlined CaretLeftOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import IdText from '../../common/IdText' import IdText from '../../common/IdText'
import CountrySelect from '../../common/CountrySelect' import CountrySelect from '../../common/CountrySelect'
@ -27,10 +31,15 @@ import EditIcon from '../../../Icons/EditIcon.jsx'
import XMarkIcon from '../../../Icons/XMarkIcon.jsx' import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
import CheckIcon from '../../../Icons/CheckIcon.jsx' import CheckIcon from '../../../Icons/CheckIcon.jsx'
import useCollapseState from '../../hooks/useCollapseState' import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import DashboardNotes from '../../common/DashboardNotes'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import config from '../../../../config.js' import config from '../../../../config.js'
const { Title, Link } = Typography const { Title, Link, Text } = Typography
const VendorInfo = () => { const VendorInfo = () => {
const [vendorData, setVendorData] = useState(null) const [vendorData, setVendorData] = useState(null)
@ -43,7 +52,9 @@ const VendorInfo = () => {
const [form] = Form.useForm() const [form] = Form.useForm()
const [fetchLoading, setFetchLoading] = useState(true) const [fetchLoading, setFetchLoading] = useState(true)
const [collapseState, updateCollapseState] = useCollapseState('VendorInfo', { const [collapseState, updateCollapseState] = useCollapseState('VendorInfo', {
info: true info: true,
notes: true,
auditLogs: true
}) })
useEffect(() => { useEffect(() => {
@ -93,14 +104,17 @@ const VendorInfo = () => {
} }
const cancelEditing = () => { const cancelEditing = () => {
// Reset form values to original data
if (vendorData) {
form.setFieldsValue({ form.setFieldsValue({
name: vendorData?.name || '', name: vendorData.name || '',
website: vendorData?.website || '', website: vendorData.website || '',
contact: vendorData?.contact || '', contact: vendorData.contact || '',
country: vendorData?.country || '', country: vendorData.country || '',
phone: vendorData?.phone || '', phone: vendorData.phone || '',
email: vendorData?.email || '' email: vendorData.email || ''
}) })
}
setIsEditing(false) setIsEditing(false)
} }
@ -131,15 +145,48 @@ const VendorInfo = () => {
} }
} }
if (fetchLoading) { const actionItems = {
items: [
{
label: 'Reload Vendor',
key: 'reload',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchVendorDetails()
}
}
}
const getViewDropdownItems = () => {
const sections = [
{ key: 'info', label: 'Vendor Information' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]
return ( return (
<div style={{ textAlign: 'center', padding: '20px' }}> <Flex vertical>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} /> <Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
</div> {sections.map((section) => (
<Checkbox
checked={collapseState[section.key]}
key={section.key}
onChange={(e) => {
updateCollapseState(section.key, e.target.checked)
}}
>
{section.label}
</Checkbox>
))}
</Flex>
</Flex>
) )
} }
if (error || !vendorData) { if (error) {
return ( return (
<Space <Space
direction='vertical' direction='vertical'
@ -154,32 +201,26 @@ const VendorInfo = () => {
} }
return ( return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}> <>
{contextHolder} {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 <Flex
align='center' gap='large'
justify='space-between' vertical='true'
style={{ width: '100%' }} style={{ height: '100%', minHeight: 0 }}
> >
<Title level={5} style={{ margin: 0 }}> <Flex justify={'space-between'}>
Vendor Information <Space size='small'>
</Title> <Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button>View</Button>
</Popover>
</Space>
<Space> <Space>
{isEditing ? ( {isEditing ? (
<> <>
@ -188,6 +229,7 @@ const VendorInfo = () => {
type='primary' type='primary'
onClick={updateInfo} onClick={updateInfo}
loading={loading} loading={loading}
disabled={loading}
/> />
<Button <Button
icon={<XMarkIcon />} icon={<XMarkIcon />}
@ -200,10 +242,51 @@ const VendorInfo = () => {
)} )}
</Space> </Space>
</Flex> </Flex>
{error ? (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Vendor not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchVendorDetails}>
Retry
</Button>
</Space>
) : (
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) =>
updateCollapseState('info', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined
rotate={isActive ? -90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<InfoCircleIcon />
<Title level={5} style={{ margin: 0 }}>
Vendor Information
</Title>
</Flex>
} }
key='1' key='1'
> >
<Form form={form} layout='vertical'> <Form form={form} layout='vertical'>
<Spin
indicator={<LoadingOutlined />}
spinning={fetchLoading}
>
<Descriptions <Descriptions
bordered bordered
column={{ column={{
@ -216,13 +299,21 @@ const VendorInfo = () => {
}} }}
> >
<Descriptions.Item label='ID'> <Descriptions.Item label='ID'>
{vendorData?._id ? (
<IdText id={vendorData._id} type='vendor' /> <IdText id={vendorData._id} type='vendor' />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Created At'> <Descriptions.Item label='Created At'>
{vendorData?.createdAt ? (
<TimeDisplay <TimeDisplay
dateTime={vendorData.createdAt} dateTime={vendorData.createdAt}
showSince={true} showSince={true}
/> />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Name'> <Descriptions.Item label='Name'>
@ -243,16 +334,22 @@ const VendorInfo = () => {
> >
<Input /> <Input />
</Form.Item> </Form.Item>
) : vendorData?.name ? (
<Text>{vendorData.name}</Text>
) : ( ) : (
vendorData.name <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Updated At'> <Descriptions.Item label='Updated At'>
{vendorData?.updatedAt ? (
<TimeDisplay <TimeDisplay
dateTime={vendorData.updatedAt} dateTime={vendorData.updatedAt}
showSince={true} showSince={true}
/> />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Website'> <Descriptions.Item label='Website'>
@ -260,17 +357,21 @@ const VendorInfo = () => {
<Form.Item <Form.Item
name='website' name='website'
rules={[ rules={[
{ type: 'url', message: 'Please enter a valid URL' }, {
type: 'url',
message: 'Please enter a valid URL'
},
{ {
max: 200, max: 200,
message: 'Website URL cannot exceed 200 characters' message:
'Website URL cannot exceed 200 characters'
} }
]} ]}
style={{ margin: 0 }} style={{ margin: 0 }}
> >
<Input /> <Input />
</Form.Item> </Form.Item>
) : vendorData.website ? ( ) : vendorData?.website ? (
<Link <Link
href={vendorData.website} href={vendorData.website}
target='_blank' target='_blank'
@ -280,19 +381,21 @@ const VendorInfo = () => {
<ExportOutlined /> <ExportOutlined />
</Link> </Link>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Country'> <Descriptions.Item label='Country'>
{isEditing ? ( {isEditing ? (
<Form.Item name='country' style={{ margin: 0 }}> <Form.Item name='country' style={{ margin: 0 }}>
<CountrySelect countryCode={vendorData.country} /> <CountrySelect
countryCode={vendorData?.country}
/>
</Form.Item> </Form.Item>
) : vendorData.country ? ( ) : vendorData?.country ? (
<CountryDisplay countryCode={vendorData.country} /> <CountryDisplay countryCode={vendorData.country} />
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
@ -303,17 +406,18 @@ const VendorInfo = () => {
rules={[ rules={[
{ {
max: 200, max: 200,
message: 'Contact info cannot exceed 200 characters' message:
'Contact info cannot exceed 200 characters'
} }
]} ]}
style={{ margin: 0 }} style={{ margin: 0 }}
> >
<Input /> <Input />
</Form.Item> </Form.Item>
) : vendorData.contact ? ( ) : vendorData?.contact ? (
vendorData.contact <Text>{vendorData.contact}</Text>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
@ -331,10 +435,10 @@ const VendorInfo = () => {
> >
<Input /> <Input />
</Form.Item> </Form.Item>
) : vendorData.phone ? ( ) : vendorData?.phone ? (
vendorData.phone <Text>{vendorData.phone}</Text>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
@ -352,21 +456,85 @@ const VendorInfo = () => {
> >
<Input /> <Input />
</Form.Item> </Form.Item>
) : vendorData.email ? ( ) : vendorData?.email ? (
<Link href={`mailto:${vendorData.email}`}> <Link href={`mailto:${vendorData.email}`}>
{vendorData.email + ' '} {vendorData.email + ' '}
<ExportOutlined /> <ExportOutlined />
</Link> </Link>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
</Spin>
</Form> </Form>
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.notes ? ['notes'] : []}
onChange={(keys) =>
updateCollapseState('notes', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<NoteIcon />
<Title level={5} style={{ margin: 0 }}>
Notes
</Title>
</Flex>
}
key='notes'
>
<Card>
<DashboardNotes _id={vendorId} />
</Card>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
onChange={(keys) =>
updateCollapseState('auditLogs', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<AuditLogIcon />
<Title level={5} style={{ margin: 0 }}>
Audit Logs
</Title>
</Flex>
}
key='auditLogs'
>
<AuditLogTable
items={vendorData?.auditLogs || []}
loading={fetchLoading}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</Flex> </Flex>
</div> </div>
)}
</Flex>
</>
) )
} }

View File

@ -31,6 +31,9 @@ import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon' import CheckIcon from '../../Icons/CheckIcon'
import TimeDisplay from '../common/TimeDisplay' import TimeDisplay from '../common/TimeDisplay'
import DashboardTable from '../common/DashboardTable' import DashboardTable from '../common/DashboardTable'
import ListIcon from '../../Icons/ListIcon'
import GridIcon from '../../Icons/GridIcon'
import useViewMode from '../hooks/useViewMode'
import config from '../../../config' import config from '../../../config'
@ -42,6 +45,7 @@ const GCodeFiles = () => {
const [newGCodeFileOpen, setNewGCodeFileOpen] = useState(false) const [newGCodeFileOpen, setNewGCodeFileOpen] = useState(false)
const [showDeleted, setShowDeleted] = useState(false) const [showDeleted, setShowDeleted] = useState(false)
const tableRef = useRef() const tableRef = useRef()
const [viewMode, setViewMode] = useViewMode('GCodeFiles')
const getFilterDropdown = ({ const getFilterDropdown = ({
setSelectedKeys, setSelectedKeys,
@ -82,12 +86,11 @@ const GCodeFiles = () => {
// Column definitions // Column definitions
const columns = [ const columns = [
{ {
title: '', title: <GCodeFileIcon />,
dataIndex: '', key: 'icon',
key: '',
width: 40, width: 40,
fixed: 'left', fixed: 'left',
render: () => <GCodeFileIcon></GCodeFileIcon> render: () => <GCodeFileIcon />
}, },
{ {
title: 'Name', title: 'Name',
@ -121,12 +124,14 @@ const GCodeFiles = () => {
}, },
{ {
title: 'Filament', title: 'Filament',
dataIndex: ['filament', 'name'],
key: 'filament', key: 'filament',
width: 200, width: 200,
render: (text, record) => { render: (record) => {
return ( return (
<Badge color={record.filament.color} text={record.filament.name} /> <Badge
color={record?.filament?.color}
text={record?.filament?.name}
/>
) )
}, },
filterDropdown: ({ filterDropdown: ({
@ -151,17 +156,16 @@ const GCodeFiles = () => {
key: 'cost', key: 'cost',
width: 120, width: 120,
render: (cost) => { render: (cost) => {
return '£' + cost.toFixed(2) return '£' + cost?.toFixed(2)
}, },
sorter: true sorter: true
}, },
{ {
title: 'Print Time', title: 'Print Time',
key: 'estimatedPrintingTimeNormalMode', key: 'estimatedPrintingTimeNormalMode',
dataIndex: ['gcodeFileInfo', 'estimatedPrintingTimeNormalMode'],
width: 140, width: 140,
render: (text, record) => { render: (record) => {
return `${record.gcodeFileInfo.estimatedPrintingTimeNormalMode}` return `${record?.gcodeFileInfo?.estimatedPrintingTimeNormalMode}`
}, },
sorter: true sorter: true
}, },
@ -198,7 +202,7 @@ const GCodeFiles = () => {
key: 'actions', key: 'actions',
fixed: 'right', fixed: 'right',
width: 150, width: 150,
render: (text, record) => { render: (record) => {
return ( return (
<Space gap='small'> <Space gap='small'>
<Button <Button
@ -353,6 +357,7 @@ const GCodeFiles = () => {
<> <>
<Flex vertical={'true'} gap='large'> <Flex vertical={'true'} gap='large'>
{contextHolder} {contextHolder}
<Flex justify={'space-between'}>
<Space> <Space>
<Dropdown menu={actionItems}> <Dropdown menu={actionItems}>
<Button>Actions</Button> <Button>Actions</Button>
@ -365,12 +370,21 @@ const GCodeFiles = () => {
<Button>View</Button> <Button>View</Button>
</Popover> </Popover>
</Space> </Space>
<Space>
<Button
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
onClick={() =>
setViewMode(viewMode === 'cards' ? 'list' : 'cards')
}
/>
</Space>
</Flex>
<DashboardTable <DashboardTable
ref={tableRef} ref={tableRef}
columns={visibleColumns} columns={visibleColumns}
url={`${config.backendUrl}/gcodefiles`} url={`${config.backendUrl}/gcodefiles`}
authenticated={authenticated} authenticated={authenticated}
cards={viewMode === 'cards'}
/> />
</Flex> </Flex>
<Modal <Modal

View File

@ -13,9 +13,12 @@ import {
Flex, Flex,
Input, Input,
Card, Card,
Collapse Collapse,
Dropdown,
Popover,
Checkbox
} from 'antd' } from 'antd'
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons' import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import IdText from '../../common/IdText.jsx' import IdText from '../../common/IdText.jsx'
import { capitalizeFirstLetter } from '../../utils/Utils.js' import { capitalizeFirstLetter } from '../../utils/Utils.js'
import FilamentSelect from '../../common/FilamentSelect' import FilamentSelect from '../../common/FilamentSelect'
@ -28,12 +31,19 @@ import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
import CheckIcon from '../../../Icons/CheckIcon.jsx' import CheckIcon from '../../../Icons/CheckIcon.jsx'
import config from '../../../../config.js' import config from '../../../../config.js'
import AuditLogTable from '../../common/AuditLogTable.jsx'
import DashboardNotes from '../../common/DashboardNotes.jsx'
import BinIcon from '../../../Icons/BinIcon.jsx'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import GCodeFileIcon from '../../../Icons/GCodeFileIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
const { Title } = Typography const { Title, Text } = Typography
const GCodeFileInfo = () => { const GCodeFileInfo = () => {
const [gcodeFileData, setGCodeFileData] = useState(null) const [gcodeFileData, setGCodeFileData] = useState(null)
const [loading, setLoading] = useState(false) const [editLoading, setLoading] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const location = useLocation() const location = useLocation()
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
@ -99,7 +109,7 @@ const GCodeFileInfo = () => {
setIsEditing(false) setIsEditing(false)
} }
const updateInfo = async () => { const updateGCodeFileInfo = async () => {
try { try {
const values = await form.validateFields() const values = await form.validateFields()
setLoading(true) setLoading(true)
@ -130,68 +140,95 @@ const GCodeFileInfo = () => {
} }
} }
if (error || !gcodeFileData) { const actionItems = {
items: [
{
label: 'Edit GCode File',
key: 'edit',
icon: <EditIcon />
},
{
label: 'Delete GCode File',
key: 'delete',
icon: <BinIcon />,
danger: true
},
{ type: 'divider' },
{
label: 'Reload GCode File',
key: 'reload',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchGCodeFileDetails()
}
}
}
const getViewDropdownItems = () => {
const sections = [
{ key: 'info', label: 'GCode File Information' },
{ key: 'preview', label: 'GCode File Preview' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]
return ( return (
<Space <Flex vertical>
direction='vertical' <Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
style={{ width: '100%', textAlign: 'center' }} {sections.map((section) => (
<Checkbox
checked={collapseState[section.key]}
key={section.key}
onChange={(e) => {
updateCollapseState(section.key, e.target.checked)
}}
> >
<p>{error || 'GCodeFile not found'}</p> {section.label}
<Button icon={<ReloadIcon />} onClick={fetchGCodeFileDetails}> </Checkbox>
Retry ))}
</Button> </Flex>
</Space> </Flex>
)
}
if (fetchLoading) {
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
</div>
) )
} }
return ( return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}> <>
{contextHolder} {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 <Flex
align='center' gap='large'
justify='space-between' vertical='true'
style={{ width: '100%' }} style={{ height: '100%', minHeight: 0 }}
> >
<Title level={5} style={{ margin: 0 }}> <Flex justify={'space-between'}>
GCode File Information <Space size='small'>
</Title> <Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button>View</Button>
</Popover>
</Space>
<Space> <Space>
{isEditing ? ( {isEditing ? (
<> <>
<Button <Button
icon={<CheckIcon />} icon={<CheckIcon />}
type='primary' type='primary'
onClick={updateInfo} onClick={updateGCodeFileInfo}
loading={loading} loading={editLoading}
disabled={editLoading}
/> />
<Button <Button
icon={<XMarkIcon />} icon={<XMarkIcon />}
onClick={cancelEditing} onClick={cancelEditing}
disabled={loading} disabled={editLoading}
/> />
</> </>
) : ( ) : (
@ -199,10 +236,48 @@ const GCodeFileInfo = () => {
)} )}
</Space> </Space>
</Flex> </Flex>
{error ? (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Print job not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchGCodeFileDetails}>
Retry
</Button>
</Space>
) : (
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.info ? ['info'] : []}
onChange={(keys) =>
updateCollapseState('info', keys.length > 0)
} }
key='1' expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<InfoCircleIcon />
<Title level={5} style={{ margin: 0 }}>
GCode File Information
</Title>
</Flex>
}
key='info'
> >
<Form form={form} layout='vertical'> <Form form={form} layout='vertical'>
<Spin
spinning={fetchLoading}
indicator={<LoadingOutlined />}
>
<Descriptions <Descriptions
bordered bordered
column={{ column={{
@ -215,17 +290,24 @@ const GCodeFileInfo = () => {
}} }}
> >
<Descriptions.Item label='ID' span={1}> <Descriptions.Item label='ID' span={1}>
{gcodeFileData.id ? ( {gcodeFileData?._id ? (
<IdText id={gcodeFileData.id} type='gcodeFile'></IdText> <IdText
id={gcodeFileData._id}
type='gcodefile'
></IdText>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Created At'> <Descriptions.Item label='Created At'>
{gcodeFileData?.createdAt ? (
<TimeDisplay <TimeDisplay
dateTime={gcodeFileData.createdAt} dateTime={gcodeFileData.createdAt}
showSince={true} showSince={true}
/> />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Name'> <Descriptions.Item label='Name'>
@ -246,29 +328,38 @@ const GCodeFileInfo = () => {
> >
<Input /> <Input />
</Form.Item> </Form.Item>
) : gcodeFileData?.name ? (
<Text>{gcodeFileData.name}</Text>
) : ( ) : (
gcodeFileData.name <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Updated At'> <Descriptions.Item label='Updated At'>
{gcodeFileData?.updatedAt ? (
<TimeDisplay <TimeDisplay
dateTime={gcodeFileData.updatedAt} dateTime={gcodeFileData.updatedAt}
showSince={true} showSince={true}
/> />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Filament Name'> <Descriptions.Item label='Filament Name'>
{isEditing ? ( {isEditing ? (
<Form.Item <Form.Item
name='filament' name='filament'
rules={[ rules={[
{ required: true, message: 'Please enter a filament' } {
required: true,
message: 'Please enter a filament'
}
]} ]}
style={{ margin: 0 }} style={{ margin: 0 }}
> >
<FilamentSelect /> <FilamentSelect />
</Form.Item> </Form.Item>
) : gcodeFileData.filament ? ( ) : gcodeFileData?.filament ? (
<Space> <Space>
<FilamentIcon /> <FilamentIcon />
<Badge <Badge
@ -277,142 +368,229 @@ const GCodeFileInfo = () => {
/> />
</Space> </Space>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Filament ID'> <Descriptions.Item label='Filament ID'>
{gcodeFileData.filament ? ( {gcodeFileData?.filament ? (
<IdText <IdText
id={gcodeFileData.filament.id} id={gcodeFileData.filament.id}
type={'filament'} type={'filament'}
showHyperlink={true} showHyperlink={true}
/> />
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Est Print Time'> <Descriptions.Item label='Est Print Time'>
{gcodeFileData.gcodeFileInfo {gcodeFileData?.gcodeFileInfo
.estimatedPrintingTimeNormalMode || 'n/a'} ?.estimatedPrintingTimeNormalMode ? (
<Text>
{
gcodeFileData.gcodeFileInfo
.estimatedPrintingTimeNormalMode
}
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Cost'> <Descriptions.Item label='Cost'>
{'£' + gcodeFileData.cost.toFixed(2) || 'n/a'} {gcodeFileData?.cost ? (
<Text>{'£' + gcodeFileData.cost.toFixed(2)}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Infill Density'> <Descriptions.Item label='Infill Density'>
{(() => { {gcodeFileData?.gcodeFileInfo?.sparseInfillDensity ? (
if (gcodeFileData.gcodeFileInfo.sparseInfillDensity) { <Text>
return gcodeFileData.gcodeFileInfo.sparseInfillDensity {gcodeFileData.gcodeFileInfo.sparseInfillDensity}
} else { </Text>
return 'n/a' ) : (
} <Text>n/a</Text>
})()} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Infill Pattern'> <Descriptions.Item label='Infill Pattern'>
{(() => { {gcodeFileData?.gcodeFileInfo?.sparseInfillPattern ? (
if (gcodeFileData.gcodeFileInfo.sparseInfillPattern) { <Text>
return capitalizeFirstLetter( {capitalizeFirstLetter(
gcodeFileData.gcodeFileInfo.sparseInfillPattern gcodeFileData.gcodeFileInfo.sparseInfillPattern
) )}
} else { </Text>
return 'n/a' ) : (
} <Text>n/a</Text>
})()} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Filament Used (mm)'> <Descriptions.Item label='Filament Used (mm)'>
{(() => { {gcodeFileData?.gcodeFileInfo?.filamentUsedMm ? (
if (gcodeFileData.gcodeFileInfo.filamentUsedMm) { <Text>
return `${gcodeFileData.gcodeFileInfo.filamentUsedMm}mm` {gcodeFileData.gcodeFileInfo.filamentUsedMm}mm
} else { </Text>
return 'n/a' ) : (
} <Text>n/a</Text>
})()} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Filament Used (g)'> <Descriptions.Item label='Filament Used (g)'>
{(() => { {gcodeFileData?.gcodeFileInfo?.filamentUsedG ? (
if (gcodeFileData.gcodeFileInfo.filamentUsedG) { <Text>
return `${gcodeFileData.gcodeFileInfo.filamentUsedG}g` {gcodeFileData.gcodeFileInfo.filamentUsedG}g
} else { </Text>
return 'n/a' ) : (
} <Text>n/a</Text>
})()} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Hotend Temperature'> <Descriptions.Item label='Hotend Temperature'>
{(() => { {gcodeFileData?.gcodeFileInfo?.nozzleTemperature ? (
if (gcodeFileData.gcodeFileInfo.nozzleTemperature) { <Text>
return `${gcodeFileData.gcodeFileInfo.nozzleTemperature}°` {gcodeFileData.gcodeFileInfo.nozzleTemperature}°
} else { </Text>
return 'n/a' ) : (
} <Text>n/a</Text>
})()} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Bed Temperature'> <Descriptions.Item label='Bed Temperature'>
{(() => { {gcodeFileData?.gcodeFileInfo?.hotPlateTemp ? (
if (gcodeFileData.gcodeFileInfo.hotPlateTemp) { <Text>
return `${gcodeFileData.gcodeFileInfo.hotPlateTemp}°` {gcodeFileData.gcodeFileInfo.hotPlateTemp}°
} else { </Text>
return 'n/a' ) : (
} <Text>n/a</Text>
})()} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Filament Profile'> <Descriptions.Item label='Filament Profile'>
{(() => { {gcodeFileData?.gcodeFileInfo?.filamentSettingsId ? (
if (gcodeFileData.gcodeFileInfo.filamentSettingsId) { <Text>
return `${gcodeFileData.gcodeFileInfo.filamentSettingsId.replaceAll('"', '')}` {gcodeFileData.gcodeFileInfo.filamentSettingsId.replaceAll(
} else { '"',
return 'n/a' ''
} )}
})()} </Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Print Profile'> <Descriptions.Item label='Print Profile'>
{(() => { {gcodeFileData?.gcodeFileInfo?.printSettingsId ? (
if (gcodeFileData.gcodeFileInfo.printSettingsId) { <Text>
return `${gcodeFileData.gcodeFileInfo.printSettingsId.replaceAll('"', '')}` {gcodeFileData.gcodeFileInfo.printSettingsId.replaceAll(
} else { '"',
return 'n/a' ''
} )}
})()} </Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
</Spin>
</Form> </Form>
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.preview ? ['2'] : []} activeKey={collapseState.preview ? ['preview'] : []}
onChange={(keys) => updateCollapseState('preview', keys.length > 0)} onChange={(keys) =>
updateCollapseState('preview', keys.length > 0)
}
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined rotate={isActive ? -90 : 0} />
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)} )}
className='no-h-padding-collapse' className='no-h-padding-collapse'
> >
<Collapse.Panel <Collapse.Panel
header={ header={
<Flex align='center' gap={'middle'}>
<GCodeFileIcon />
<Title level={5} style={{ margin: 0 }}> <Title level={5} style={{ margin: 0 }}>
GCode File Preview GCode File Preview
</Title> </Title>
</Flex>
} }
key='2' key='preview'
> >
<Spin spinning={fetchLoading} indicator={<LoadingOutlined />}>
<Card styles={{ body: { padding: '10px' } }}> <Card styles={{ body: { padding: '10px' } }}>
{gcodeFileData.gcodeFileInfo.thumbnail ? ( {gcodeFileData?.gcodeFileInfo?.thumbnail ? (
<img <img
src={`data:image/png;base64,${gcodeFileData.gcodeFileInfo.thumbnail.data}`} src={`data:image/png;base64,${gcodeFileData.gcodeFileInfo.thumbnail.data}`}
alt='GCodeFile' alt='GCodeFile'
style={{ maxWidth: '100%' }} style={{ maxWidth: '100%' }}
/> />
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Card> </Card>
</Spin>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.notes ? ['notes'] : []}
onChange={(keys) =>
updateCollapseState('notes', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<NoteIcon />
<Title level={5} style={{ margin: 0 }}>
Notes
</Title>
</Flex>
}
key='notes'
>
<Card>
<DashboardNotes _id={gcodeFileId} />
</Card>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
onChange={(keys) =>
updateCollapseState('auditLogs', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<AuditLogIcon />
<Title level={5} style={{ margin: 0 }}>
Audit Log
</Title>
</Flex>
}
key='auditLogs'
>
<AuditLogTable
items={gcodeFileData?.auditLogs || []}
loading={fetchLoading}
showTargetColumn={false}
/>
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
</Flex> </Flex>
</div> </div>
)}
</Flex>
</>
) )
} }

View File

@ -37,6 +37,9 @@ import PauseCircleIcon from '../../Icons/PauseCircleIcon.jsx'
import XMarkCircleIcon from '../../Icons/XMarkCircleIcon.jsx' import XMarkCircleIcon from '../../Icons/XMarkCircleIcon.jsx'
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon.jsx' import QuestionCircleIcon from '../../Icons/QuestionCircleIcon.jsx'
import DashboardTable from '../common/DashboardTable' import DashboardTable from '../common/DashboardTable'
import ListIcon from '../../Icons/ListIcon.jsx'
import GridIcon from '../../Icons/GridIcon.jsx'
import useViewMode from '../hooks/useViewMode.js'
import config from '../../../config.js' import config from '../../../config.js'
@ -49,6 +52,7 @@ const Jobs = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [newJobOpen, setNewJobOpen] = useState(false) const [newJobOpen, setNewJobOpen] = useState(false)
const tableRef = useRef() const tableRef = useRef()
const [viewMode, setViewMode] = useViewMode('Jobs')
const getFilterDropdown = ({ const getFilterDropdown = ({
setSelectedKeys, setSelectedKeys,
@ -89,20 +93,18 @@ const Jobs = () => {
// Column definitions // Column definitions
const columns = [ const columns = [
{ {
title: '', title: <JobIcon />,
dataIndex: '', key: 'icon',
key: '',
width: 40, width: 40,
fixed: 'left', fixed: 'left',
render: () => <JobIcon /> render: () => <JobIcon />
}, },
{ {
title: 'GCode File Name', title: 'GCode File Name',
dataIndex: 'gcodeFile',
key: 'gcodeFileName', key: 'gcodeFileName',
width: 200, width: 200,
fixed: 'left', fixed: 'left',
render: (gcodeFile) => <Text ellipsis>{gcodeFile.name}</Text>, render: (record) => <Text ellipsis>{record?.gcodeFile?.name}</Text>,
filterDropdown: ({ filterDropdown: ({
setSelectedKeys, setSelectedKeys,
selectedKeys, selectedKeys,
@ -162,7 +164,7 @@ const Jobs = () => {
propertyName: 'state' propertyName: 'state'
}), }),
onFilter: (value, record) => onFilter: (value, record) =>
record.state.type.toLowerCase().includes(value.toLowerCase()) record?.state?.type?.toLowerCase().includes(value.toLowerCase())
}, },
{ {
title: <CheckCircleIcon />, title: <CheckCircleIcon />,
@ -226,13 +228,13 @@ const Jobs = () => {
}, },
{ {
title: 'Actions', title: 'Actions',
key: 'operation', key: 'actions',
fixed: 'right', fixed: 'right',
width: 150, width: 150,
render: (record) => { render: (record) => {
return ( return (
<Space size='small'> <Space size='small'>
{record.state.type === 'draft' ? ( {record?.state?.type === 'draft' ? (
<Button <Button
icon={<PlayCircleIcon />} icon={<PlayCircleIcon />}
onClick={() => handleDeployJob(record.id)} onClick={() => handleDeployJob(record.id)}
@ -368,6 +370,7 @@ const Jobs = () => {
{notificationContextHolder} {notificationContextHolder}
<Flex vertical={'true'} gap='large' style={{ height: '100%' }}> <Flex vertical={'true'} gap='large' style={{ height: '100%' }}>
{contextHolder} {contextHolder}
<Flex justify={'space-between'}>
<Space size='small'> <Space size='small'>
<Dropdown menu={actionItems}> <Dropdown menu={actionItems}>
<Button>Actions</Button> <Button>Actions</Button>
@ -380,18 +383,29 @@ const Jobs = () => {
<Button>View</Button> <Button>View</Button>
</Popover> </Popover>
</Space> </Space>
<Space>
<Button
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
onClick={() =>
setViewMode(viewMode === 'cards' ? 'list' : 'cards')
}
/>
</Space>
</Flex>
<DashboardTable <DashboardTable
ref={tableRef} ref={tableRef}
columns={visibleColumns} columns={visibleColumns}
url={`${config.backendUrl}/jobs`} url={`${config.backendUrl}/jobs`}
authenticated={authenticated} authenticated={authenticated}
cards={viewMode === 'cards'}
/> />
</Flex> </Flex>
<Modal <Modal
open={newJobOpen} open={newJobOpen}
footer={null} footer={null}
width={700} width={'auto'}
height={'auto'}
onCancel={() => { onCancel={() => {
setNewJobOpen(false) setNewJobOpen(false)
}} }}

View File

@ -16,7 +16,7 @@ import {
Checkbox, Checkbox,
Card Card
} from 'antd' } from 'antd'
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons' import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import TimeDisplay from '../../common/TimeDisplay' import TimeDisplay from '../../common/TimeDisplay'
import JobState from '../../common/JobState' import JobState from '../../common/JobState'
import IdText from '../../common/IdText' import IdText from '../../common/IdText'
@ -28,12 +28,16 @@ import useCollapseState from '../../hooks/useCollapseState'
import config from '../../../../config' import config from '../../../../config'
import AuditLogTable from '../../common/AuditLogTable' import AuditLogTable from '../../common/AuditLogTable'
import DashboardNotes from '../../common/DashboardNotes' import DashboardNotes from '../../common/DashboardNotes'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon'
import JobIcon from '../../../Icons/JobIcon'
import AuditLogIcon from '../../../Icons/AuditLogIcon'
import NoteIcon from '../../../Icons/NoteIcon'
const { Title, Text } = Typography const { Title, Text } = Typography
const JobInfo = () => { const JobInfo = () => {
const [jobData, setJobData] = useState(null) const [jobData, setJobData] = useState(null)
const [loading, setLoading] = useState(true) const [fetchLoading, setFetchLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const location = useLocation() const location = useLocation()
const [messageApi] = message.useMessage() const [messageApi] = message.useMessage()
@ -77,7 +81,7 @@ const JobInfo = () => {
const fetchJobDetails = async () => { const fetchJobDetails = async () => {
try { try {
setLoading(true) setFetchLoading(true)
const response = await axios.get(`${config.backendUrl}/jobs/${jobId}`, { const response = await axios.get(`${config.backendUrl}/jobs/${jobId}`, {
headers: { headers: {
Accept: 'application/json' Accept: 'application/json'
@ -90,7 +94,7 @@ const JobInfo = () => {
setError('Failed to fetch print job details') setError('Failed to fetch print job details')
messageApi.error('Failed to fetch print job details') messageApi.error('Failed to fetch print job details')
} finally { } finally {
setLoading(false) setFetchLoading(false)
} }
} }
@ -176,28 +180,28 @@ const JobInfo = () => {
<Flex vertical style={{ flexGrow: 1 }} gap={'large'}> <Flex vertical style={{ flexGrow: 1 }} gap={'large'}>
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.info ? ['info'] : []} activeKey={collapseState.info ? ['info'] : []}
onChange={(keys) => onChange={(keys) =>
updateCollapseState('info', keys.length > 0) updateCollapseState('info', keys.length > 0)
} }
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined rotate={isActive ? -90 : 0} />
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)} )}
className='no-h-padding-collapse no-t-padding-collapse' className='no-h-padding-collapse no-t-padding-collapse'
> >
<Collapse.Panel <Collapse.Panel
header={ header={
<Flex align='center' gap={'middle'}>
<InfoCircleIcon />
<Title level={5} style={{ margin: 0 }}> <Title level={5} style={{ margin: 0 }}>
Job Information Job Information
</Title> </Title>
</Flex>
} }
key='info' key='info'
> >
<Spin spinning={loading} indicator={<LoadingOutlined />}> <Spin spinning={fetchLoading} indicator={<LoadingOutlined />}>
<Descriptions <Descriptions
bordered bordered
column={{ column={{
@ -303,86 +307,86 @@ const JobInfo = () => {
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.subJobs ? ['2'] : []} activeKey={collapseState.subJobs ? ['2'] : []}
onChange={(keys) => onChange={(keys) =>
updateCollapseState('subJobs', keys.length > 0) updateCollapseState('subJobs', keys.length > 0)
} }
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined rotate={isActive ? -90 : 0} />
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)} )}
className='no-h-padding-collapse' className='no-h-padding-collapse'
> >
<Collapse.Panel <Collapse.Panel
header={ header={
<Flex align='center' gap={'middle'}>
<JobIcon />
<Title level={5} style={{ margin: 0 }}> <Title level={5} style={{ margin: 0 }}>
Sub Job Information Sub Job Information
</Title> </Title>
</Flex>
} }
key='2' key='2'
> >
<SubJobsTree jobData={jobData} loading={loading} /> <SubJobsTree jobData={jobData} loading={fetchLoading} />
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.notes ? ['notes'] : []} activeKey={collapseState.notes ? ['notes'] : []}
onChange={(keys) => onChange={(keys) =>
updateCollapseState('notes', keys.length > 0) updateCollapseState('notes', keys.length > 0)
} }
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined rotate={isActive ? -90 : 0} />
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)} )}
className='no-h-padding-collapse' className='no-h-padding-collapse'
> >
<Collapse.Panel <Collapse.Panel
header={ header={
<Flex align='center' gap={'middle'}>
<NoteIcon />
<Title level={5} style={{ margin: 0 }}> <Title level={5} style={{ margin: 0 }}>
Notes Notes
</Title> </Title>
</Flex>
} }
key='notes' key='notes'
> >
<Card> <Card>
<DashboardNotes /> <DashboardNotes _id={jobId} />
</Card> </Card>
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.auditLogs ? ['auditLogs'] : []} activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
onChange={(keys) => onChange={(keys) =>
updateCollapseState('auditLogs', keys.length > 0) updateCollapseState('auditLogs', keys.length > 0)
} }
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined rotate={isActive ? -90 : 0} />
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)} )}
className='no-h-padding-collapse' className='no-h-padding-collapse'
> >
<Collapse.Panel <Collapse.Panel
header={ header={
<Flex align='center' gap={'middle'}>
<AuditLogIcon />
<Title level={5} style={{ margin: 0 }}> <Title level={5} style={{ margin: 0 }}>
Audit Logs Audit Logs
</Title> </Title>
</Flex>
} }
key='auditLogs' key='auditLogs'
> >
<AuditLogTable <AuditLogTable
items={jobData?.auditLogs || []} items={jobData?.auditLogs || []}
loading={loading} loading={fetchLoading}
showTargetColumn={false} showTargetColumn={false}
/> />
</Collapse.Panel> </Collapse.Panel>

View File

@ -29,6 +29,9 @@ import CheckIcon from '../../Icons/CheckIcon'
import DashboardTable from '../common/DashboardTable' import DashboardTable from '../common/DashboardTable'
import config from '../../../config' import config from '../../../config'
import GridIcon from '../../Icons/GridIcon'
import ListIcon from '../../Icons/ListIcon'
import useViewMode from '../hooks/useViewMode'
const Printers = () => { const Printers = () => {
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
@ -37,12 +40,14 @@ const Printers = () => {
const navigate = useNavigate() const navigate = useNavigate()
const tableRef = useRef() const tableRef = useRef()
// View mode state (cards/list), persisted in sessionStorage via custom hook
const [viewMode, setViewMode] = useViewMode('Printers')
// Column definitions // Column definitions
const columns = [ const columns = [
{ {
title: '', title: <PrinterIcon />,
dataIndex: '', key: 'icon',
key: '',
width: 40, width: 40,
fixed: 'left', fixed: 'left',
render: () => <PrinterIcon /> render: () => <PrinterIcon />
@ -71,7 +76,7 @@ const Printers = () => {
}, },
{ {
title: 'ID', title: 'ID',
dataIndex: 'id', dataIndex: '_id',
key: 'id', key: 'id',
width: 180, width: 180,
render: (text) => <IdText id={text} type='printer' longId={false} /> render: (text) => <IdText id={text} type='printer' longId={false} />
@ -129,7 +134,7 @@ const Printers = () => {
}, },
{ {
title: 'Actions', title: 'Actions',
key: 'operation', key: 'actions',
fixed: 'right', fixed: 'right',
width: 150, width: 150,
render: (record) => { render: (record) => {
@ -139,11 +144,11 @@ const Printers = () => {
icon={<ControlIcon />} icon={<ControlIcon />}
onClick={() => onClick={() =>
navigate( navigate(
`/dashboard/production/printers/control?printerId=${record.id}` `/dashboard/production/printers/control?printerId=${record._id}`
) )
} }
/> />
<Dropdown menu={getPrinterActionItems(record.id)}> <Dropdown menu={getPrinterActionItems(record._id)}>
<Button>Actions</Button> <Button>Actions</Button>
</Dropdown> </Dropdown>
</Space> </Space>
@ -284,6 +289,7 @@ const Printers = () => {
<> <>
<Flex vertical={'true'} gap='large'> <Flex vertical={'true'} gap='large'>
{contextHolder} {contextHolder}
<Flex justify={'space-between'}>
<Space> <Space>
<Dropdown menu={actionItems}> <Dropdown menu={actionItems}>
<Button>Actions</Button> <Button>Actions</Button>
@ -296,12 +302,22 @@ const Printers = () => {
<Button>View</Button> <Button>View</Button>
</Popover> </Popover>
</Space> </Space>
<Space>
<Button
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
onClick={() =>
setViewMode(viewMode === 'cards' ? 'list' : 'cards')
}
/>
</Space>
</Flex>
<DashboardTable <DashboardTable
ref={tableRef} ref={tableRef}
columns={visibleColumns} columns={visibleColumns}
url={`${config.backendUrl}/printers`} url={`${config.backendUrl}/printers`}
authenticated={authenticated} authenticated={authenticated}
cards={viewMode === 'cards'}
/> />
<Modal <Modal

View File

@ -20,7 +20,7 @@ import {
Checkbox, Checkbox,
Collapse Collapse
} from 'antd' } from 'antd'
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons' import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import { SocketContext } from '../../context/SocketContext' import { SocketContext } from '../../context/SocketContext'
@ -65,6 +65,7 @@ const ControlPrinter = () => {
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
const [printerData, setPrinterData] = useState(null) const [printerData, setPrinterData] = useState(null)
const [fetchLoading, setFetchLoading] = useState(true)
const [initialized, setInitialized] = useState(false) const [initialized, setInitialized] = useState(false)
const [loadFilamentStockModalOpen, setLoadFilamentStockModalOpen] = const [loadFilamentStockModalOpen, setLoadFilamentStockModalOpen] =
useState(false) useState(false)
@ -111,6 +112,7 @@ const ControlPrinter = () => {
// Fetch printer details when the component mounts // Fetch printer details when the component mounts
const fetchPrinterDetails = useCallback(async () => { const fetchPrinterDetails = useCallback(async () => {
if (printerId) { if (printerId) {
setFetchLoading(true)
try { try {
const response = await axios.get( const response = await axios.get(
`${config.backendUrl}/printers/${printerId}`, `${config.backendUrl}/printers/${printerId}`,
@ -121,9 +123,10 @@ const ControlPrinter = () => {
withCredentials: true // Important for including cookies withCredentials: true // Important for including cookies
} }
) )
setFetchLoading(false)
setPrinterData(response.data) setPrinterData(response.data)
} catch (error) { } catch (error) {
setFetchLoading(false)
if (error.response) { if (error.response) {
messageApi.error( messageApi.error(
'Error fetching printer data:', 'Error fetching printer data:',
@ -485,7 +488,6 @@ const ControlPrinter = () => {
</Space> </Space>
</Flex> </Flex>
<div style={{ height: '100%', overflow: 'auto' }}> <div style={{ height: '100%', overflow: 'auto' }}>
{printerData ? (
<Flex gap={'large'} wrap> <Flex gap={'large'} wrap>
<Flex vertical style={{ flexGrow: 1 }} gap={'large'}> <Flex vertical style={{ flexGrow: 1 }} gap={'large'}>
<Flex gap={16} vertical style={{ flexGrow: 1 }}> <Flex gap={16} vertical style={{ flexGrow: 1 }}>
@ -498,16 +500,11 @@ const ControlPrinter = () => {
</Flex> </Flex>
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.job ? ['1'] : []} activeKey={collapseState.job ? ['1'] : []}
onChange={(keys) => onChange={(keys) => updateCollapseState('job', keys.length > 0)}
updateCollapseState('job', keys.length > 0)
}
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined rotate={isActive ? -90 : 0} />
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)} )}
style={{ padding: 0 }} style={{ padding: 0 }}
className='no-h-padding-collapse no-t-padding-collapse' className='no-h-padding-collapse no-t-padding-collapse'
@ -525,6 +522,10 @@ const ControlPrinter = () => {
</Flex> </Flex>
} }
key='1' key='1'
>
<Spin
indicator={<LoadingOutlined spin />}
spinning={fetchLoading}
> >
<Descriptions <Descriptions
bordered bordered
@ -538,11 +539,15 @@ const ControlPrinter = () => {
}} }}
> >
<Descriptions.Item label='Printer Name'> <Descriptions.Item label='Printer Name'>
{printerData.name} {printerData?.name ? (
<Text>{printerData.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Printer ID'> <Descriptions.Item label='Printer ID'>
{printerData._id ? ( {printerData?._id ? (
<IdText <IdText
id={printerData._id} id={printerData._id}
type='printer' type='printer'
@ -550,12 +555,12 @@ const ControlPrinter = () => {
showHyperlink={true} showHyperlink={true}
/> />
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='GCode File Name'> <Descriptions.Item label='GCode File Name'>
{printerData.currentJob?.gcodeFile?.name ? ( {printerData?.currentJob?.gcodeFile?.name ? (
<Space> <Space>
<GCodeFileIcon /> <GCodeFileIcon />
<Text ellipsis style={{ maxWidth: 200 }}> <Text ellipsis style={{ maxWidth: 200 }}>
@ -563,11 +568,11 @@ const ControlPrinter = () => {
</Text> </Text>
</Space> </Space>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='GCode File ID'> <Descriptions.Item label='GCode File ID'>
{printerData.currentJob?.gcodeFile ? ( {printerData?.currentJob?.gcodeFile ? (
<IdText <IdText
id={printerData.currentJob.gcodeFile.id} id={printerData.currentJob.gcodeFile.id}
type='gcodeFile' type='gcodeFile'
@ -575,12 +580,12 @@ const ControlPrinter = () => {
showHyperlink={true} showHyperlink={true}
/> />
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Print Job ID'> <Descriptions.Item label='Print Job ID'>
{printerData.currentJob?.id ? ( {printerData?.currentJob?.id ? (
<IdText <IdText
id={printerData.currentJob.id} id={printerData.currentJob.id}
type='job' type='job'
@ -588,12 +593,12 @@ const ControlPrinter = () => {
showHyperlink={true} showHyperlink={true}
/> />
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Sub Job ID'> <Descriptions.Item label='Sub Job ID'>
{printerData.currentSubJob?.id ? ( {printerData?.currentSubJob?.id ? (
<IdText <IdText
id={printerData.currentSubJob.number id={printerData.currentSubJob.number
.toString() .toString()
@ -603,11 +608,11 @@ const ControlPrinter = () => {
showHyperlink={false} showHyperlink={false}
/> />
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
{printerData?.state.type === 'printing' && ( {printerData?.state?.type === 'printing' && (
<> <>
<Descriptions.Item label='Progress' span={1}> <Descriptions.Item label='Progress' span={1}>
<Progress <Progress
@ -618,71 +623,58 @@ const ControlPrinter = () => {
/> />
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Started At' span={1}> <Descriptions.Item label='Started At' span={1}>
{printerData.currentSubJob?.startedAt ? ( {printerData?.currentSubJob?.startedAt ? (
<TimeDisplay <TimeDisplay
dateTime={printerData.currentSubJob.startedAt} dateTime={printerData.currentSubJob.startedAt}
showSince={true} showSince={true}
/> />
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
</> </>
)} )}
<Descriptions.Item label='Print Profile'> <Descriptions.Item label='Print Profile'>
{(() => { {printerData?.currentJob?.gcodeFile?.gcodeFileInfo
if ( ?.printSettingsId ? (
printerData?.currentJob?.gcodeFile.gcodeFileInfo
.printSettingsId
) {
return (
<Text ellipsis style={{ maxWidth: 200 }}> <Text ellipsis style={{ maxWidth: 200 }}>
{printerData.currentJob.gcodeFile.gcodeFileInfo.printSettingsId.replaceAll( {printerData.currentJob.gcodeFile.gcodeFileInfo.printSettingsId.replaceAll(
'"', '"',
'' ''
)} )}
</Text> </Text>
) ) : (
} else { <Text>n/a</Text>
return 'n/a' )}
}
})()}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Est. Print Time'> <Descriptions.Item label='Est. Print Time'>
{(() => { {printerData?.currentJob?.gcodeFile?.gcodeFileInfo
if ( ?.estimatedPrintingTimeNormalMode ? (
printerData.currentJob?.gcodeFile?.gcodeFileInfo
.estimatedPrintingTimeNormalMode
) {
return (
<Text ellipsis> <Text ellipsis>
{ {
printerData.currentJob.gcodeFile.gcodeFileInfo printerData.currentJob.gcodeFile.gcodeFileInfo
.estimatedPrintingTimeNormalMode .estimatedPrintingTimeNormalMode
} }
</Text> </Text>
) ) : (
} <Text>n/a</Text>
return 'n/a' )}
})()}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
</Spin>
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.filament ? ['1'] : []} activeKey={collapseState.filament ? ['1'] : []}
onChange={(keys) => onChange={(keys) =>
updateCollapseState('filament', keys.length > 0) updateCollapseState('filament', keys.length > 0)
} }
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined rotate={isActive ? -90 : 0} />
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)} )}
style={{ padding: 0 }} style={{ padding: 0 }}
className='no-h-padding-collapse' className='no-h-padding-collapse'
@ -700,6 +692,10 @@ const ControlPrinter = () => {
</Flex> </Flex>
} }
key='1' key='1'
>
<Spin
indicator={<LoadingOutlined spin />}
spinning={fetchLoading}
> >
<Descriptions <Descriptions
bordered bordered
@ -718,11 +714,11 @@ const ControlPrinter = () => {
filamentStock={printerData?.currentFilamentStock} filamentStock={printerData?.currentFilamentStock}
/> />
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Filament Stock ID'> <Descriptions.Item label='Filament Stock ID'>
{printerData.currentFilamentStock ? ( {printerData?.currentFilamentStock?._id ? (
<IdText <IdText
id={printerData.currentFilamentStock._id} id={printerData.currentFilamentStock._id}
type='filamentstock' type='filamentstock'
@ -730,11 +726,11 @@ const ControlPrinter = () => {
showHyperlink={true} showHyperlink={true}
/> />
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Filament Name'> <Descriptions.Item label='Filament Name'>
{printerData.currentFilamentStock?.filament?.name ? ( {printerData?.currentFilamentStock?.filament?.name ? (
<Space> <Space>
<FilamentIcon /> <FilamentIcon />
<Badge <Badge
@ -747,7 +743,7 @@ const ControlPrinter = () => {
></Badge> ></Badge>
</Space> </Space>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Filament ID'> <Descriptions.Item label='Filament ID'>
@ -759,11 +755,11 @@ const ControlPrinter = () => {
showHyperlink={true} showHyperlink={true}
/> />
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Weight'> <Descriptions.Item label='Weight'>
{printerData.currentFilamentStock?.currentNetWeight ? ( {printerData?.currentFilamentStock?.currentNetWeight ? (
<div> <div>
<Descriptions <Descriptions
style={{ width: isMobile ? '100%' : '250px' }} style={{ width: isMobile ? '100%' : '250px' }}
@ -783,24 +779,22 @@ const ControlPrinter = () => {
</Descriptions> </Descriptions>
</div> </div>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
</Spin>
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.jobs ? ['1'] : []} activeKey={collapseState.jobs ? ['1'] : []}
onChange={(keys) => onChange={(keys) =>
updateCollapseState('jobs', keys.length > 0) updateCollapseState('jobs', keys.length > 0)
} }
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined rotate={isActive ? -90 : 0} />
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)} )}
style={{ padding: 0 }} style={{ padding: 0 }}
className='no-h-padding-collapse' className='no-h-padding-collapse'
@ -819,21 +813,32 @@ const ControlPrinter = () => {
} }
key='1' key='1'
> >
<PrinterSubJobsTree subJobs={printerData.subJobs} /> <PrinterSubJobsTree
subJobs={printerData?.subJobs}
loading={fetchLoading}
/>
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
</Flex> </Flex>
<Flex gap={'large'} wrap vertical> <Flex gap={'large'} wrap vertical>
{componentVisibility.temperature && ( {componentVisibility.temperature && (
<Spin
indicator={<LoadingOutlined spin />}
spinning={fetchLoading}
>
<Card> <Card>
<PrinterTemperaturePanel <PrinterTemperaturePanel
printerId={printerId} printerId={printerId}
disabled={!printerData.online}
></PrinterTemperaturePanel> ></PrinterTemperaturePanel>
</Card> </Card>
</Spin>
)} )}
{componentVisibility.position && ( {componentVisibility.position && (
<Spin
indicator={<LoadingOutlined spin />}
spinning={fetchLoading}
>
<Card> <Card>
<PrinterPositionPanel <PrinterPositionPanel
printerId={printerId} printerId={printerId}
@ -841,26 +846,34 @@ const ControlPrinter = () => {
showMoreInfo={true} showMoreInfo={true}
/> />
</Card> </Card>
</Spin>
)} )}
{componentVisibility.movement && ( {componentVisibility.movement && (
<Spin
indicator={<LoadingOutlined spin />}
spinning={fetchLoading}
>
<Card> <Card>
<PrinterMovementPanel <PrinterMovementPanel
printerId={printerId} printerId={printerId}
></PrinterMovementPanel> ></PrinterMovementPanel>
</Card> </Card>
</Spin>
)} )}
{componentVisibility.misc && ( {componentVisibility.misc && (
<Spin
indicator={<LoadingOutlined spin />}
spinning={fetchLoading}
>
<Card> <Card>
<PrinterMiscPanel printerId={printerId} /> <PrinterMiscPanel printerId={printerId} />
</Card> </Card>
</Spin>
)} )}
</Flex> </Flex>
</Flex> </Flex>
) : (
<Spin indicator={<LoadingOutlined spin />} size='large' />
)}
</div> </div>
</Flex> </Flex>
<Modal <Modal

View File

@ -14,9 +14,13 @@ import {
Input, Input,
InputNumber, InputNumber,
Select, Select,
Collapse Collapse,
Dropdown,
Popover,
Checkbox,
Card
} from 'antd' } from 'antd'
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons' import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import PrinterState from '../../common/PrinterState' import PrinterState from '../../common/PrinterState'
import TimeDisplay from '../../common/TimeDisplay' import TimeDisplay from '../../common/TimeDisplay'
import IdText from '../../common/IdText' import IdText from '../../common/IdText'
@ -32,13 +36,18 @@ import useCollapseState from '../../hooks/useCollapseState'
import config from '../../../../config.js' import config from '../../../../config.js'
import AuditLogTable from '../../common/AuditLogTable.jsx' import AuditLogTable from '../../common/AuditLogTable.jsx'
import DashboardNotes from '../../common/DashboardNotes.jsx'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import PrinterIcon from '../../../Icons/PrinterIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
const { Title } = Typography const { Title, Text } = Typography
const PrinterInfo = () => { const PrinterInfo = () => {
const [printerData, setPrinterData] = useState(null) const [printerData, setPrinterData] = useState(null)
const [fetchLoading, setFetchLoading] = useState(true) const [fetchLoading, setFetchLoading] = useState(true)
const [loading, setLoading] = useState(false) const [editLoading, setEditLoading] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const location = useLocation() const location = useLocation()
const printerId = new URLSearchParams(location.search).get('printerId') const printerId = new URLSearchParams(location.search).get('printerId')
@ -121,7 +130,7 @@ const PrinterInfo = () => {
const updatePrinterInfo = async () => { const updatePrinterInfo = async () => {
try { try {
const values = await form.validateFields() const values = await form.validateFields()
setLoading(true) setEditLoading(true)
await axios.put( await axios.put(
`${config.backendUrl}/printers/${printerId}`, `${config.backendUrl}/printers/${printerId}`,
@ -156,7 +165,7 @@ const PrinterInfo = () => {
console.error('Failed to update printer information:', err) console.error('Failed to update printer information:', err)
messageApi.error('Failed to update printer information') messageApi.error('Failed to update printer information')
} finally { } finally {
setLoading(false) setEditLoading(false)
} }
} }
@ -176,55 +185,69 @@ const PrinterInfo = () => {
} }
} }
if (fetchLoading) { const actionItems = {
return ( items: [
<div style={{ textAlign: 'center', padding: '20px' }}> {
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} /> label: 'Reload Printer',
</div> key: 'reload',
) icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchPrinterDetails()
}
}
} }
if (error || !printerData) { const getViewDropdownItems = () => {
const sections = [
{ key: 'info', label: 'Printer Information' },
{ key: 'jobs', label: 'Printer Jobs' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]
return ( return (
<Space <Flex vertical>
direction='vertical' <Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
style={{ width: '100%', textAlign: 'center' }} {sections.map((section) => (
<Checkbox
checked={collapseState[section.key]}
key={section.key}
onChange={(e) => {
updateCollapseState(section.key, e.target.checked)
}}
> >
<p>{error || 'Printer not found'}</p> {section.label}
<Button icon={<ReloadIcon />} onClick={fetchPrinterDetails}> </Checkbox>
Retry ))}
</Button> </Flex>
</Space> </Flex>
) )
} }
return ( return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}> <>
{contextHolder} {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 <Flex
align='center' gap='large'
justify='space-between' vertical='true'
style={{ width: '100%' }} style={{ height: '100%', minHeight: 0 }}
> >
<Title level={5} style={{ margin: 0 }}> <Flex justify={'space-between'}>
Printer Information <Space size='small'>
</Title> <Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button>View</Button>
</Popover>
</Space>
<Space> <Space>
{isEditing ? ( {isEditing ? (
<> <>
@ -232,12 +255,13 @@ const PrinterInfo = () => {
icon={<CheckIcon />} icon={<CheckIcon />}
type='primary' type='primary'
onClick={updatePrinterInfo} onClick={updatePrinterInfo}
loading={loading} loading={editLoading}
disabled={editLoading}
/> />
<Button <Button
icon={<XMarkIcon />} icon={<XMarkIcon />}
onClick={cancelEditing} onClick={cancelEditing}
disabled={loading} disabled={editLoading}
/> />
</> </>
) : ( ) : (
@ -245,23 +269,61 @@ const PrinterInfo = () => {
)} )}
</Space> </Space>
</Flex> </Flex>
{error ? (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Print job not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchPrinterDetails}>
Retry
</Button>
</Space>
) : (
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.info ? ['info'] : []}
onChange={(keys) =>
updateCollapseState('info', keys.length > 0)
} }
key='1' expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<InfoCircleIcon />
<Title level={5} style={{ margin: 0 }}>
Printer Information
</Title>
</Flex>
}
key='info'
> >
<Form <Form
form={form} form={form}
layout='vertical' layout='vertical'
initialValues={{ initialValues={{
name: printerData.name || '', name: printerData?.name || '',
vendor: printerData.vendor || { id: null, name: '' }, vendor: printerData?.vendor || { id: null, name: '' },
moonraker: { moonraker: {
host: printerData.moonraker?.host || '', host: printerData?.moonraker?.host || '',
port: printerData.moonraker?.port || null, port: printerData?.moonraker?.port || null,
protocol: printerData.moonraker?.protocol || 'ws', protocol: printerData?.moonraker?.protocol || 'ws',
apiKey: printerData.moonraker?.apiKey || '' apiKey: printerData?.moonraker?.apiKey || ''
}, },
tags: printerData.tags || [] tags: printerData?.tags || []
}} }}
>
<Spin
spinning={fetchLoading}
indicator={<LoadingOutlined />}
> >
<Descriptions <Descriptions
bordered bordered
@ -276,13 +338,21 @@ const PrinterInfo = () => {
> >
{/* Read-only fields */} {/* Read-only fields */}
<Descriptions.Item label='ID'> <Descriptions.Item label='ID'>
<IdText id={printerData._id} type='printer' /> {printerData?._id ? (
<IdText id={printerData._id} type={'printer'} />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Connected At'> <Descriptions.Item label='Connected At'>
{printerData?.connectedAt ? (
<TimeDisplay <TimeDisplay
dateTime={printerData.connectedAt} dateTime={printerData.connectedAt}
showSince={true} showSince={true}
/> />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
{/* Editable fields */} {/* Editable fields */}
@ -304,8 +374,10 @@ const PrinterInfo = () => {
> >
<Input placeholder='Enter printer name' /> <Input placeholder='Enter printer name' />
</Form.Item> </Form.Item>
) : printerData?.name ? (
<Text>{printerData.name}</Text>
) : ( ) : (
printerData.name || 'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
@ -314,19 +386,25 @@ const PrinterInfo = () => {
<Form.Item <Form.Item
name={['moonraker', 'host']} name={['moonraker', 'host']}
rules={[ rules={[
{ required: true, message: 'Please enter a host' }, {
required: true,
message: 'Please enter a host'
},
{ {
pattern: pattern:
/^[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$|\b(?:\d{1,3}\.){3}\d{1,3}\b/, /^[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$|\b(?:\d{1,3}\.){3}\d{1,3}\b/,
message: 'Please enter a valid hostname or IP address' message:
'Please enter a valid hostname or IP address'
} }
]} ]}
style={{ margin: 0 }} style={{ margin: 0 }}
> >
<Input placeholder='Enter host (e.g., 192.168.1.100)' /> <Input placeholder='Enter host (e.g., 192.168.1.100)' />
</Form.Item> </Form.Item>
) : printerData?.moonraker?.host ? (
<Text>{printerData.moonraker.host}</Text>
) : ( ) : (
printerData.moonraker?.host || 'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
@ -335,17 +413,22 @@ const PrinterInfo = () => {
<Form.Item <Form.Item
name='vendor' name='vendor'
rules={[ rules={[
{ required: true, message: 'Please enter a vendor' } {
required: true,
message: 'Please enter a vendor'
}
]} ]}
style={{ margin: 0 }} style={{ margin: 0 }}
> >
<VendorSelect /> <VendorSelect />
</Form.Item> </Form.Item>
) : ( ) : printerData?.vendor?.name ? (
<Space> <Space>
<VendorIcon /> <VendorIcon />
{printerData?.vendor?.name || 'n/a'} {printerData?.vendor?.name || 'n/a'}
</Space> </Space>
) : (
<Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
@ -357,7 +440,7 @@ const PrinterInfo = () => {
showHyperlink={true} showHyperlink={true}
/> />
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
@ -386,8 +469,10 @@ const PrinterInfo = () => {
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
</Form.Item> </Form.Item>
) : printerData?.moonraker?.port ? (
<Text>{printerData.moonraker.port}</Text>
) : ( ) : (
printerData.moonraker.port <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
@ -395,7 +480,9 @@ const PrinterInfo = () => {
{isEditing ? ( {isEditing ? (
<Form.Item <Form.Item
name={['moonraker', 'protocol']} name={['moonraker', 'protocol']}
rules={[{ required: true, message: 'Port is required' }]} rules={[
{ required: true, message: 'Port is required' }
]}
style={{ margin: 0 }} style={{ margin: 0 }}
> >
<Select <Select
@ -406,10 +493,12 @@ const PrinterInfo = () => {
]} ]}
/> />
</Form.Item> </Form.Item>
) : printerData.moonraker.protocol == 'ws' ? ( ) : printerData?.moonraker?.protocol == 'ws' ? (
'Websocket' <Text>Websocket</Text>
) : printerData?.moonraker?.protocol == 'wss' ? (
<Text>Websocket Secure</Text>
) : ( ) : (
'Websocket Secure' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
@ -421,20 +510,24 @@ const PrinterInfo = () => {
> >
<Input.Password placeholder='Enter API key' /> <Input.Password placeholder='Enter API key' />
</Form.Item> </Form.Item>
) : printerData.moonraker?.apiKey ? ( ) : printerData?.moonraker?.apiKey ? (
'Configured' <Text>Configured</Text>
) : ( ) : (
'Not configured' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Status'> <Descriptions.Item label='Status'>
{printerData?.state ? (
<PrinterState <PrinterState
printer={printerData} printer={printerData}
showPrinterName={false} showPrinterName={false}
showControls={false} showControls={false}
showProgress={false} showProgress={false}
/> />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Tags'> <Descriptions.Item label='Tags'>
@ -461,10 +554,13 @@ const PrinterInfo = () => {
<Form.Item name='newTag' noStyle> <Form.Item name='newTag' noStyle>
<Input placeholder='Add new tag' /> <Input placeholder='Add new tag' />
</Form.Item> </Form.Item>
<Button onClick={handleTagAdd} icon={<PlusIcon />} /> <Button
onClick={handleTagAdd}
icon={<PlusIcon />}
/>
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
) : printerData.tags?.length > 0 ? ( ) : printerData?.tags?.length > 0 ? (
<Space <Space
size={[0, 2]} size={[0, 2]}
wrap wrap
@ -477,73 +573,117 @@ const PrinterInfo = () => {
))} ))}
</Space> </Space>
) : ( ) : (
'No tags' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Firmware Version'> <Descriptions.Item label='Firmware Version'>
{printerData.firmware || 'Unknown'} {printerData?.firmware ? (
<Text>{printerData.firmware}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
</Spin>
</Form> </Form>
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.jobs ? ['2'] : []} activeKey={collapseState.jobs ? ['jobs'] : []}
onChange={(keys) => updateCollapseState('jobs', keys.length > 0)} onChange={(keys) =>
updateCollapseState('jobs', keys.length > 0)
}
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined rotate={isActive ? -90 : 0} />
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)} )}
className='no-h-padding-collapse' className='no-h-padding-collapse'
> >
<Collapse.Panel <Collapse.Panel
header={ header={
<Flex align='center' gap={'middle'}>
<PrinterIcon />
<Title level={5} style={{ margin: 0 }}> <Title level={5} style={{ margin: 0 }}>
Printer Jobs Printer Jobs
</Title> </Title>
</Flex>
} }
key='2' key='jobs'
> >
<PrinterSubJobsList subJobs={printerData.subJobs} /> <PrinterSubJobsList
subJobs={printerData?.subJobs}
loading={fetchLoading}
/>
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.auditLogs ? ['3'] : []} activeKey={collapseState.notes ? ['notes'] : []}
onChange={(keys) => updateCollapseState('auditLogs', keys.length > 0)} onChange={(keys) =>
updateCollapseState('notes', keys.length > 0)
}
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined rotate={isActive ? -90 : 0} />
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)} )}
className='no-h-padding-collapse' className='no-h-padding-collapse'
> >
<Collapse.Panel <Collapse.Panel
header={ header={
<Flex align='center' gap={'middle'}>
<NoteIcon />
<Title level={5} style={{ margin: 0 }}>
Notes
</Title>
</Flex>
}
key='notes'
>
<Card>
<DashboardNotes _id={printerId} />
</Card>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
onChange={(keys) =>
updateCollapseState('auditLogs', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<AuditLogIcon />
<Title level={5} style={{ margin: 0 }}> <Title level={5} style={{ margin: 0 }}>
Audit Log Audit Log
</Title> </Title>
</Flex>
} }
key='3' key='auditLogs'
> >
<AuditLogTable <AuditLogTable
items={printerData.auditLogs || []} items={printerData?.auditLogs || []}
loading={false} loading={fetchLoading}
showTargetColumn={false} showTargetColumn={false}
/> />
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
</Flex> </Flex>
</div> </div>
)}
</Flex>
</>
) )
} }

View File

@ -12,7 +12,7 @@ import {
Segmented, Segmented,
Card Card
} from 'antd' } from 'antd'
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons' import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import { Line } from '@ant-design/charts' import { Line } from '@ant-design/charts'
import axios from 'axios' import axios from 'axios'
import PrinterIcon from '../../Icons/PrinterIcon' import PrinterIcon from '../../Icons/PrinterIcon'
@ -151,11 +151,11 @@ const ProductionOverview = () => {
<Flex gap='large' vertical> <Flex gap='large' vertical>
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.overview ? ['1'] : []} activeKey={collapseState.overview ? ['1'] : []}
onChange={(keys) => updateCollapseState('overview', keys.length > 0)} onChange={(keys) => updateCollapseState('overview', keys.length > 0)}
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined
rotate={isActive ? 90 : 0} rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }} style={{ paddingTop: '2px' }}
/> />
@ -275,13 +275,13 @@ const ProductionOverview = () => {
<Flex flex={1} vertical> <Flex flex={1} vertical>
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.printerStats ? ['2'] : []} activeKey={collapseState.printerStats ? ['2'] : []}
onChange={(keys) => onChange={(keys) =>
updateCollapseState('printerStats', keys.length > 0) updateCollapseState('printerStats', keys.length > 0)
} }
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined
rotate={isActive ? 90 : 0} rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }} style={{ paddingTop: '2px' }}
/> />
@ -357,13 +357,13 @@ const ProductionOverview = () => {
<Flex flex={1} vertical> <Flex flex={1} vertical>
<Collapse <Collapse
ghost ghost
collapsible='icon' expandIconPosition='end'
activeKey={collapseState.jobStats ? ['3'] : []} activeKey={collapseState.jobStats ? ['3'] : []}
onChange={(keys) => onChange={(keys) =>
updateCollapseState('jobStats', keys.length > 0) updateCollapseState('jobStats', keys.length > 0)
} }
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined <CaretLeftOutlined
rotate={isActive ? 90 : 0} rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }} style={{ paddingTop: '2px' }}
/> />

View File

@ -0,0 +1,56 @@
import React from 'react'
import { useLocation } from 'react-router-dom'
import DashboardSidebar from '../common/DashboardSidebar'
import ProductionIcon from '../../Icons/ProductionIcon'
import PrinterIcon from '../../Icons/PrinterIcon'
import JobIcon from '../../Icons/JobIcon'
import GCodeFileIcon from '../../Icons/GCodeFileIcon'
const items = [
{
key: 'overview',
icon: <ProductionIcon />,
label: 'Overview',
path: '/dashboard/production/overview'
},
{ type: 'divider' },
{
key: 'printers',
icon: <PrinterIcon />,
label: 'Printers',
path: '/dashboard/production/printers'
},
{
key: 'jobs',
icon: <JobIcon />,
label: 'Jobs',
path: '/dashboard/production/jobs'
},
{
key: 'gcodefiles',
icon: <GCodeFileIcon />,
label: 'GCode Files',
path: '/dashboard/production/gcodefiles'
}
]
const routeKeyMap = {
'/dashboard/production/overview': 'overview',
'/dashboard/production/printers': 'printers',
'/dashboard/production/jobs': 'jobs',
'/dashboard/production/gcodefiles': 'gcodefiles'
}
const ProductionSidebar = (props) => {
const location = useLocation()
const selectedKey = (() => {
const match = Object.keys(routeKeyMap).find((path) =>
location.pathname.startsWith(path)
)
return match ? routeKeyMap[match] : 'overview'
})()
return <DashboardSidebar items={items} selectedKey={selectedKey} {...props} />
}
export default ProductionSidebar

View File

@ -1,9 +1,10 @@
import React, { forwardRef, useState } from 'react' import React, { forwardRef, useState } from 'react'
import { Typography, Space, Descriptions, Badge, Tag, Table } from 'antd' import { Typography, Space, Descriptions, Badge, Table } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import IdText from './IdText' import IdText from './IdText'
import { AuditOutlined, LoadingOutlined } from '@ant-design/icons' import { AuditOutlined, LoadingOutlined } from '@ant-design/icons'
import TimeDisplay from '../common/TimeDisplay' import TimeDisplay from '../common/TimeDisplay'
import BoolDisplay from './BoolDisplay'
const { Text } = Typography const { Text } = Typography
@ -19,7 +20,11 @@ const isObjectId = (value) => {
const formatValue = (value, propertyName) => { const formatValue = (value, propertyName) => {
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') {
return <Text type='secondary'>n/a</Text> return (
<div style={{ maxWidth: 20 }}>
<Text type='secondary'>n/a</Text>
</div>
)
} }
// Handle colors specifically // Handle colors specifically
@ -42,11 +47,7 @@ const formatValue = (value, propertyName) => {
} }
if (typeof value === 'boolean' || value === true || value === false) { if (typeof value === 'boolean' || value === true || value === false) {
return ( return <BoolDisplay value={value} yesNo={true} />
<Tag color={value ? 'success' : 'error'} style={{ margin: 0 }}>
{value ? 'Yes' : 'No'}
</Tag>
)
} }
if (isObjectId(value)) { if (isObjectId(value)) {

View File

@ -0,0 +1,41 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Space, Tag } from 'antd'
import CheckIcon from '../../Icons/CheckIcon'
import XMarkIcon from '../../Icons/XMarkIcon'
const BoolDisplay = ({
value,
yesNo,
showIcon = true,
showText = true,
showColor = true
}) => {
var falseText = 'False'
var trueText = 'True'
if (yesNo) {
falseText = 'No'
trueText = 'Yes'
}
return (
<Space>
<Tag
style={{ margin: 0 }}
color={showColor ? (value ? 'success' : 'error') : 'default'}
icon={showIcon ? value ? <CheckIcon /> : <XMarkIcon /> : undefined}
>
{showText ? (value === true ? trueText : falseText) : null}
</Tag>
</Space>
)
}
BoolDisplay.propTypes = {
value: PropTypes.bool.isRequired,
yesNo: PropTypes.bool,
showIcon: PropTypes.bool,
showText: PropTypes.bool,
showColor: PropTypes.bool
}
export default BoolDisplay

View File

@ -9,6 +9,7 @@ const breadcrumbNameMap = {
'/dashboard/production': 'Production', '/dashboard/production': 'Production',
'/dashboard/inventory': 'Inventory', '/dashboard/inventory': 'Inventory',
'/dashboard/management': 'Management', '/dashboard/management': 'Management',
'/dashboard/developer': 'Developer',
'/dashboard/production/overview': 'Overview', '/dashboard/production/overview': 'Overview',
'/dashboard/production/printers': 'Printers', '/dashboard/production/printers': 'Printers',
'/dashboard/production/printers/control': 'Control', '/dashboard/production/printers/control': 'Control',
@ -29,6 +30,8 @@ const breadcrumbNameMap = {
'/dashboard/management/materials/info': 'Info', '/dashboard/management/materials/info': 'Info',
'/dashboard/management/notetypes': 'Note Types', '/dashboard/management/notetypes': 'Note Types',
'/dashboard/management/notetypes/info': 'Info', '/dashboard/management/notetypes/info': 'Info',
'/dashboard/management/users': 'Users',
'/dashboard/management/users/info': 'Info',
'/dashboard/management/settings': 'Settings', '/dashboard/management/settings': 'Settings',
'/dashboard/management/auditlogs': 'Audit Logs', '/dashboard/management/auditlogs': 'Audit Logs',
'/dashboard/inventory/filamentstocks': 'Filament Stocks', '/dashboard/inventory/filamentstocks': 'Filament Stocks',
@ -40,7 +43,10 @@ const breadcrumbNameMap = {
'/dashboard/inventory/stockevents': 'Stock Events', '/dashboard/inventory/stockevents': 'Stock Events',
'/dashboard/inventory/stockevents/info': 'Info', '/dashboard/inventory/stockevents/info': 'Info',
'/dashboard/inventory/stockaudits': 'Stock Audits', '/dashboard/inventory/stockaudits': 'Stock Audits',
'/dashboard/inventory/stockaudits/info': 'Info' '/dashboard/inventory/stockaudits/info': 'Info',
'/dashboard/developer/sessionstorage': 'Session Storage',
'/dashboard/developer/authcontextdebug': 'Auth Context Debug',
'/dashboard/developer/socketcontextdebug': 'Socket Context Debug'
} }
const DashboardBreadcrumb = () => { const DashboardBreadcrumb = () => {

View File

@ -9,7 +9,8 @@ import {
Button, Button,
Tooltip, Tooltip,
Typography, Typography,
Divider Divider,
Badge
} from 'antd' } from 'antd'
import { import {
LogoutOutlined, LogoutOutlined,
@ -20,6 +21,7 @@ import {
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { SocketContext } from '../context/SocketContext' import { SocketContext } from '../context/SocketContext'
import { SpotlightContext } from '../context/SpotlightContext' import { SpotlightContext } from '../context/SpotlightContext'
import { NotificationContext } from '../context/NotificationContext'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { Header } from 'antd/es/layout/layout' import { Header } from 'antd/es/layout/layout'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
@ -33,12 +35,15 @@ import CloudIcon from '../../Icons/CloudIcon'
import BellIcon from '../../Icons/BellIcon' import BellIcon from '../../Icons/BellIcon'
import SearchIcon from '../../Icons/SearchIcon' import SearchIcon from '../../Icons/SearchIcon'
import SettingsIcon from '../../Icons/SettingsIcon' import SettingsIcon from '../../Icons/SettingsIcon'
import DeveloperIcon from '../../Icons/DeveloperIcon'
const { Text } = Typography const { Text } = Typography
const DashboardNavigation = () => { const DashboardNavigation = () => {
const { logout, userProfile } = useContext(AuthContext) const { logout, userProfile } = useContext(AuthContext)
const { showSpotlight } = useContext(SpotlightContext) const { showSpotlight } = useContext(SpotlightContext)
const { toggleNotificationCenter, unreadCount } =
useContext(NotificationContext)
const { socket } = useContext(SocketContext) const { socket } = useContext(SocketContext)
const [socketState, setSocketState] = useState('disconnected') const [socketState, setSocketState] = useState('disconnected')
const navigate = useNavigate() const navigate = useNavigate()
@ -168,12 +173,14 @@ const DashboardNavigation = () => {
onClick={() => showSpotlight()} onClick={() => showSpotlight()}
></Button> ></Button>
</Tooltip> </Tooltip>
<Badge count={unreadCount} size='small'>
<Button <Button
icon={<BellIcon />} icon={<BellIcon />}
type='text' type='text'
style={{ marginTop: '2px' }} style={{ marginTop: '2px' }}
onClick={() => showSpotlight()} onClick={toggleNotificationCenter}
></Button> ></Button>
</Badge>
</Space> </Space>
<Space> <Space>
{socketState === 'connected' ? ( {socketState === 'connected' ? (
@ -206,10 +213,15 @@ const DashboardNavigation = () => {
</Space> </Space>
{process.env.NODE_ENV === 'development' && ( {process.env.NODE_ENV === 'development' && (
<Space> <Space>
<Tooltip title='Development Environment' arrow={false}> <Tooltip title='Developer' arrow={false}>
<Tag color='yellow' style={{ marginRight: 0 }}> <Tag
Dev color='yellow'
</Tag> style={{ marginRight: 0 }}
icon={<DeveloperIcon />}
onClick={() => {
navigate('/dashboard/developer/sessionstorage')
}}
/>
</Tooltip> </Tooltip>
</Space> </Space>
)} )}

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react' import React, { useCallback, useContext, useEffect, useState } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { import {
Card, Card,
@ -9,31 +9,464 @@ import {
Modal, Modal,
Form, Form,
Input, Input,
Select, Switch,
Switch Spin,
Alert,
message,
Divider,
Tag,
Dropdown
} from 'antd' } from 'antd'
import { CaretLeftFilled, LoadingOutlined } from '@ant-design/icons'
import PlusIcon from '../../Icons/PlusIcon' import PlusIcon from '../../Icons/PlusIcon'
import BinIcon from '../../Icons/BinIcon'
import PersonIcon from '../../Icons/PersonIcon'
import TimeDisplay from './TimeDisplay' import TimeDisplay from './TimeDisplay'
import MarkdownDisplay from './MarkdownDisplay' import MarkdownDisplay from './MarkdownDisplay'
import axios from 'axios'
import config from '../../../config'
import { AuthContext } from '../context/AuthContext'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import NoteTypeSelect from './NoteTypeSelect'
import IdText from './IdText'
import ReloadIcon from '../../Icons/ReloadIcon'
import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon'
const { Text } = Typography const { Text, Title } = Typography
const { TextArea } = Input const { TextArea } = Input
const DashboardNotes = ({ notes = [], onNewNote }) => { const NoteItem = ({
const [isModalOpen, setIsModalOpen] = useState(false) note,
const [showMarkdown, setShowMarkdown] = useState(false) expandedNotes,
const [form] = Form.useForm() setExpandedNotes,
fetchData,
onNewNote,
onDeleteNote,
userProfile,
onChildNoteAdded
}) => {
const [childNotes, setChildNotes] = useState({})
const [loadingChildNotes, setLoadingChildNotes] = useState(null)
const handleNewNote = () => { const isExpanded = expandedNotes[note._id]
setIsModalOpen(true) const hasChildNotes = childNotes[note._id] && childNotes[note._id].length > 0
const isThisNoteLoading = loadingChildNotes === note._id
let transformValue = 'rotate(0deg)'
if (isExpanded) {
transformValue = 'rotate(-90deg)'
} }
const handleNoteExpand = async (noteId) => {
const newExpandedState = !expandedNotes[noteId]
setExpandedNotes((prev) => ({
...prev,
[noteId]: newExpandedState
}))
if (newExpandedState && !childNotes[noteId]) {
setLoadingChildNotes(noteId)
try {
const childNotesData = await fetchData(noteId)
setChildNotes((prev) => ({
...prev,
[noteId]: childNotesData
}))
} catch (error) {
console.error('Error fetching child notes:', error)
} finally {
setLoadingChildNotes(null)
}
}
}
const handleNewChildNote = () => {
if (onNewNote) {
onNewNote(note._id)
}
}
const handleDeleteNote = () => {
if (onDeleteNote) {
onDeleteNote(note._id)
}
}
// Reload child notes when a new child note is added
const reloadChildNotes = async () => {
// Always fetch child notes when this function is called
// This ensures child notes are loaded even if the parent wasn't expanded before
setLoadingChildNotes(note._id)
try {
const childNotesData = await fetchData(note._id)
setChildNotes((prev) => ({
...prev,
[note._id]: childNotesData
}))
} catch (error) {
console.error('Error fetching child notes:', error)
} finally {
setLoadingChildNotes(null)
}
}
// Listen for child note additions
useEffect(() => {
if (onChildNoteAdded) {
onChildNoteAdded(note._id, reloadChildNotes)
}
}, [note._id, onChildNoteAdded])
// Check if the current user can delete this note
const canDeleteNote = userProfile && userProfile._id === note.user._id
const dropdownItems = [
{
key: 'new',
icon: <PlusIcon />,
label: 'New Note',
onClick: handleNewChildNote
}
]
// Only add delete option if user owns the note
if (canDeleteNote) {
dropdownItems.push({
key: 'delete',
label: 'Delete Note',
icon: <BinIcon />,
onClick: handleDeleteNote,
danger: true
})
}
return (
<Card
key={note._id}
size='small'
style={{
backgroundColor: note.noteType.color + '26',
textAlign: 'left'
}}
>
<Flex vertical gap={'small'}>
<Flex gap={'middle'} align='start'>
<Space>
<PersonIcon />
<Text style={{ whiteSpace: 'nowrap' }}>{note.user.name}:</Text>
</Space>
<div style={{ marginBottom: '4px' }}>
<MarkdownDisplay content={note.content} />
</div>
</Flex>
<Divider style={{ margin: 0 }} />
<Flex wrap gap={'small'}>
<Dropdown
menu={{ items: dropdownItems }}
trigger={['hover']}
placement='bottomLeft'
>
<Button size='small'>Actions</Button>
</Dropdown>
<Space size={'small'} style={{ marginRight: 8 }}>
<Text type='secondary'>Type:</Text>
<Tag color={note.noteType.color} style={{ margin: 0 }}>
{note.noteType.name}
</Tag>
</Space>
<Space size={'small'} style={{ marginRight: 8 }}>
<Text type='secondary'>User ID:</Text>
<IdText
longId={false}
id={note.user._id}
type={'user'}
showHyperlink={true}
/>
</Space>
<Space size={'small'} style={{ marginRight: 8 }}>
<Text type='secondary'>Created At:</Text>
<TimeDisplay dateTime={note.createdAt} showSince={true} />
</Space>
<Flex style={{ flexGrow: 1 }} justify='end'>
<Space size={'small'}>
<Button
icon={
isThisNoteLoading ? <LoadingOutlined /> : <CaretLeftFilled />
}
size='small'
type='text'
loading={isThisNoteLoading}
disabled={isThisNoteLoading}
style={{
transform: transformValue,
transition: 'transform 0.2s ease'
}}
onClick={() => handleNoteExpand(note._id)}
/>
</Space>
</Flex>
</Flex>
{isExpanded && (
<>
<Flex vertical gap={'small'} style={{ flexGrow: 1 }}>
{hasChildNotes ? (
childNotes[note._id].map((childNote) => (
<NoteItem
key={childNote._id}
note={childNote}
expandedNotes={expandedNotes}
setExpandedNotes={setExpandedNotes}
fetchData={fetchData}
onNewNote={onNewNote}
onDeleteNote={onDeleteNote}
userProfile={userProfile}
onChildNoteAdded={onChildNoteAdded}
/>
))
) : !isThisNoteLoading ? (
<Card size='small'>
<Flex
justify='center'
gap={'small'}
style={{ height: '100%' }}
align='center'
>
<Text type='secondary'>
<InfoCircleIcon />
</Text>
<Text type='secondary'>No child notes.</Text>
</Flex>
</Card>
) : null}
</Flex>
</>
)}
<Flex vertical gap={'middle'}></Flex>
</Flex>
</Card>
)
}
NoteItem.propTypes = {
note: PropTypes.object.isRequired,
expandedNotes: PropTypes.object.isRequired,
setExpandedNotes: PropTypes.func.isRequired,
fetchData: PropTypes.func.isRequired,
onNewNote: PropTypes.func,
onDeleteNote: PropTypes.func,
userProfile: PropTypes.object,
onChildNoteAdded: PropTypes.func
}
const DashboardNotes = ({ _id, onNewNote }) => {
const [newNoteOpen, setNewNoteOpen] = useState(false)
const [showMarkdown, setShowMarkdown] = useState(false)
const [loading, setLoading] = useState(true)
const [messageApi, contextHolder] = message.useMessage()
const [newNoteFormLoading, setNewNoteFormLoading] = useState(false)
const [newNoteFormValues, setNewNoteFormValues] = useState({})
const [deleteNoteLoading, setDeleteNoteLoading] = useState(false)
const [doneEnabled, setDoneEnabled] = useState(false)
const [error, setError] = useState(null)
const [notes, setNotes] = useState(null)
const [expandedNotes, setExpandedNotes] = useState({})
const [newNoteForm] = Form.useForm()
const [selectedParentId, setSelectedParentId] = useState(null)
const [childNoteCallbacks, setChildNoteCallbacks] = useState({})
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [noteToDelete, setNoteToDelete] = useState(null)
const newNoteFormUpdateValues = Form.useWatch([], newNoteForm)
React.useEffect(() => {
newNoteForm
.validateFields({
validateOnly: true
})
.then(() => setDoneEnabled(true))
.catch(() => setDoneEnabled(false))
}, [newNoteForm, newNoteFormUpdateValues])
const { authenticated, userProfile } = useContext(AuthContext)
const fetchData = useCallback(async (id) => {
try {
const response = await axios.get(`${config.backendUrl}/notes`, {
params: {
parent: id,
sort: 'createdAt',
order: 'ascend'
},
headers: {
Accept: 'application/json'
},
withCredentials: true
})
const newData = response.data
setLoading(false)
return newData
} catch (error) {
setNotes([])
setError(error)
setLoading(false)
}
}, [])
const generateNotes = useCallback(
async (id) => {
const notesData = await fetchData(id)
if (notesData.length <= 0) {
return (
<Spin indicator={<LoadingOutlined />} spinning={loading}>
<Card>
<Flex
justify='center'
gap={'small'}
style={{ height: '100%' }}
align='center'
>
<Text type='secondary'>
<InfoCircleIcon />
</Text>
<Text type='secondary'>No notes added.</Text>
</Flex>
</Card>
</Spin>
)
}
return notesData.map((note) => (
<NoteItem
key={note._id}
note={note}
expandedNotes={expandedNotes}
setExpandedNotes={setExpandedNotes}
fetchData={fetchData}
onNewNote={handleNewNoteFromDropdown}
onDeleteNote={handleDeleteNote}
userProfile={userProfile}
onChildNoteAdded={(noteId, callback) => {
setChildNoteCallbacks((prev) => ({
...prev,
[noteId]: callback
}))
}}
/>
))
},
[loading, fetchData, expandedNotes, userProfile]
)
const handleNewNote = async () => {
setNewNoteFormLoading(true)
try {
await axios.post(
`${config.backendUrl}/notes`,
{ ...newNoteFormValues, parent: selectedParentId || _id },
{
withCredentials: true
}
)
setNewNoteOpen(false)
messageApi.success('Added a new note.')
// If this is a child note, expand the parent and reload child notes
if (selectedParentId) {
// Ensure parent is expanded
setExpandedNotes((prev) => ({
...prev,
[selectedParentId]: true
}))
// Add a small delay to ensure state update has taken effect
setTimeout(() => {
// Reload child notes for the parent
if (childNoteCallbacks[selectedParentId]) {
childNoteCallbacks[selectedParentId]()
}
}, 100)
} else {
// If it's a top-level note, reload all notes
setLoading(true)
handleReloadData()
}
setSelectedParentId(null)
} catch (error) {
messageApi.error('Error creating new note: ' + error.message)
} finally {
setNewNoteFormLoading(false)
}
}
const handleNewNoteFromDropdown = (parentId) => {
setSelectedParentId(parentId)
setNewNoteOpen(true)
newNoteForm.resetFields()
setNewNoteFormValues({})
}
const handleDeleteNote = async (noteId) => {
setNoteToDelete(noteId)
setDeleteConfirmOpen(true)
}
const confirmDeleteNote = async () => {
if (!noteToDelete) return
setDeleteNoteLoading(true)
try {
await axios.delete(`${config.backendUrl}/notes/${noteToDelete}`, {
withCredentials: true
})
messageApi.success('Note deleted successfully.')
// Reload all top-level notes
setLoading(true)
handleReloadData()
// Reload child notes for all expanded parents to ensure UI stays in sync
const expandedNoteIds = Object.keys(expandedNotes).filter(
(id) => expandedNotes[id]
)
for (const parentId of expandedNoteIds) {
if (childNoteCallbacks[parentId]) {
childNoteCallbacks[parentId]()
}
}
} catch (error) {
messageApi.error('Error deleting note: ' + error.message)
} finally {
setDeleteNoteLoading(false)
setDeleteConfirmOpen(false)
setNoteToDelete(null)
}
}
const cancelDeleteNote = () => {
setDeleteConfirmOpen(false)
setNoteToDelete(null)
}
const handleReloadData = useCallback(async () => {
setNotes(await generateNotes(_id))
}, [_id, generateNotes])
useEffect(() => {
if (authenticated) {
handleReloadData()
}
}, [authenticated, handleReloadData])
const handleModalOk = async () => { const handleModalOk = async () => {
try { try {
const values = await form.validateFields() const values = await newNoteForm.validateFields()
onNewNote(values) onNewNote(values)
form.resetFields() newNoteForm.resetFields()
setIsModalOpen(false) setNewNoteOpen(false)
setShowMarkdown(false) setShowMarkdown(false)
} catch (error) { } catch (error) {
console.error('Validation failed:', error) console.error('Validation failed:', error)
@ -41,87 +474,113 @@ const DashboardNotes = ({ notes = [], onNewNote }) => {
} }
const handleModalCancel = () => { const handleModalCancel = () => {
form.resetFields() newNoteForm.resetFields()
setIsModalOpen(false) setNewNoteOpen(false)
setShowMarkdown(false) setShowMarkdown(false)
setSelectedParentId(null)
}
const actionItems = {
items: [
{
label: 'New Note',
key: 'newNote',
icon: <PlusIcon />
},
{ type: 'divider' },
{
label: 'Reload Notes',
key: 'reloadNotes',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reloadNotes') {
setLoading(true)
handleReloadData()
} else if (key === 'newNote') {
setSelectedParentId(null)
setNewNoteOpen(true)
newNoteForm.resetFields()
setNewNoteFormValues({})
}
}
} }
return ( return (
<Space direction='vertical' size='large' style={{ width: '100%' }}> <Flex vertical gap='large' style={{ width: '100%' }}>
{contextHolder}
<Flex justify='space-between'> <Flex justify='space-between'>
<Space size={'small'}> <Space size={'small'}>
<Button>Actions</Button> <Dropdown menu={actionItems} disabled={loading}>
<Button disabled={loading}>Actions</Button>
</Dropdown>
</Space> </Space>
<Space size={'small'}> <Space size={'small'}>
<Button type='primary' icon={<PlusIcon />} onClick={handleNewNote} /> <Button
type='primary'
icon={<PlusIcon />}
disabled={loading}
onClick={() => {
setSelectedParentId(null)
setNewNoteOpen(true)
newNoteForm.resetFields()
setNewNoteFormValues({})
}}
/>
</Space> </Space>
</Flex> </Flex>
<Space direction='vertical' size='middle' style={{ width: '100%' }}> <Space direction='vertical' size='middle' style={{ width: '100%' }}>
{notes.map((note) => ( <Spin indicator={<LoadingOutlined />} spinning={loading}>
<Card key={note._id} size='small'> {error ? (
<Space direction='vertical' style={{ width: '100%' }}> <Alert message={error?.message} type='error' showIcon={true} />
<Flex justify='space-between' align='center'> ) : (
<Text type='secondary'> <Flex vertical gap={'middle'}>
<TimeDisplay dateTime={note.createdAt} showSince={true} /> {notes}
</Text>
</Flex> </Flex>
<Text>{note.content}</Text> )}
</Space> </Spin>
</Card>
))}
</Space> </Space>
<Modal <Modal
title='New Note' open={newNoteOpen}
open={isModalOpen}
onOk={handleModalOk} onOk={handleModalOk}
onCancel={handleModalCancel} onCancel={handleModalCancel}
width={800} width={800}
closeIcon={false}
destroyOnHidden={true}
footer={false}
> >
<Flex vertical gap='large'>
<Flex vertical gap='middle'>
<Flex align='center' justify='space-between'>
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
New Note
</Title>
<Space gap={'small'}>
<Text type='secondary'>Markdown:</Text>
<Switch onChange={setShowMarkdown} size='small' />
</Space>
</Flex>
<Form <Form
form={form} form={newNoteForm}
layout='vertical' layout='vertical'
initialValues={{ onFinish={handleNewNote}
type: 'general', initialValues={{ content: '' }}
showMarkdown: false onValuesChange={(changedValues) =>
}} setNewNoteFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
> >
<Form.Item <Flex vertical gap={'large'}>
name='type' <Flex gap='middle' wrap>
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 <Form.Item
name='content' name='content'
label='Content' rules={[{ required: true, message: '' }]}
rules={[{ required: true, message: 'Please enter note content' }]} style={{ margin: 0, flexGrow: 1, minWidth: '300px' }}
style={{ flex: 1 }}
> >
<TextArea <TextArea
rows={6} rows={6}
@ -131,47 +590,105 @@ const DashboardNotes = ({ notes = [], onNewNote }) => {
</Form.Item> </Form.Item>
{showMarkdown && ( {showMarkdown && (
<div style={{ flex: 1 }}> <Card
<Text
type='secondary'
style={{ marginBottom: 8, display: 'block' }}
>
Preview
</Text>
<div
style={{ style={{
border: '1px solid #d9d9d9', flexGrow: 1,
borderRadius: '6px', minWidth: '300px',
padding: '8px', backgroundColor: () => {
minHeight: '150px', if (newNoteFormValues?.noteType?.color) {
maxHeight: '300px', return newNoteFormValues.noteType.color + '26'
overflow: 'auto' }
}
}} }}
> >
<MarkdownDisplay <MarkdownDisplay
content={form.getFieldValue('content') || ''} content={newNoteForm.getFieldValue('content') || ''}
/> />
</div> </Card>
</div>
)} )}
</Flex> </Flex>
<Form.Item
name='noteType'
style={{ margin: 0 }}
rules={[
{ required: true, message: 'Please select a note type' }
]}
>
<NoteTypeSelect />
</Form.Item>
</Flex>
</Form> </Form>
</Flex>
<Flex justify='end'>
<Button
style={{ margin: '0 8px' }}
disabled={newNoteFormLoading}
onClick={() => {
setNewNoteOpen(false)
}}
>
Cancel
</Button>
<Button
type='primary'
loading={newNoteFormLoading}
onClick={() => {
newNoteForm.submit()
}}
disabled={newNoteFormLoading || !doneEnabled}
>
Done
</Button>
</Flex>
</Flex>
</Modal> </Modal>
<Modal
open={deleteConfirmOpen}
title={
<Space size={'middle'}>
<ExclamationOctagonIcon />
Confirm Delete
</Space> </Space>
}
onOk={confirmDeleteNote}
onCancel={cancelDeleteNote}
okText='Delete'
cancelText='Cancel'
okType='danger'
closable={false}
centered
maskClosable={false}
footer={[
<Button
key='cancel'
onClick={cancelDeleteNote}
disabled={deleteNoteLoading}
>
Cancel
</Button>,
<Button
key='delete'
type='primary'
danger
onClick={confirmDeleteNote}
loading={deleteNoteLoading}
disabled={deleteNoteLoading}
>
Delete
</Button>
]}
>
<Text>Are you sure you want to delete this note?</Text>
</Modal>
</Flex>
) )
} }
DashboardNotes.propTypes = { DashboardNotes.propTypes = {
notes: PropTypes.arrayOf(
PropTypes.shape({
_id: PropTypes.string.isRequired, _id: PropTypes.string.isRequired,
content: PropTypes.string.isRequired, onNewNote: PropTypes.func
createdAt: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
user: PropTypes.object.isRequired
})
),
onNewNote: PropTypes.func.isRequired
} }
export default DashboardNotes export default DashboardNotes

View File

@ -1,70 +1,60 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { Layout, Menu, Flex, Button } from 'antd' import { Layout, Menu, Flex, Button } from 'antd'
import { DashboardOutlined, CaretDownFilled } from '@ant-design/icons' import { 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 CollapseSidebarIcon from '../../Icons/CollapseSidebarIcon'
import ExpandSidebarIcon from '../../Icons/ExpandSidebarIcon' import ExpandSidebarIcon from '../../Icons/ExpandSidebarIcon'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import { useNavigate } from 'react-router-dom'
import PropTypes from 'prop-types'
const { Sider } = Layout const { Sider } = Layout
const SIDEBAR_COLLAPSED_KEY = 'sidebar_collapsed' const DashboardSidebar = ({
items = [],
const ProductionSidebar = () => { selectedKey = '',
const location = useLocation() onCollapse,
const [selectedKey, setSelectedKey] = useState('production') collapsed: collapsedProp,
key = 'DashboardSidebar_collapseState'
}) => {
const [collapsed, setCollapsed] = useState(() => { const [collapsed, setCollapsed] = useState(() => {
const savedState = sessionStorage.getItem(SIDEBAR_COLLAPSED_KEY) if (typeof collapsedProp === 'boolean') return collapsedProp
const savedState = sessionStorage.getItem(key)
return savedState ? JSON.parse(savedState) : false return savedState ? JSON.parse(savedState) : false
}) })
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
const navigate = useNavigate()
useEffect(() => { useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean) if (typeof collapsedProp === 'boolean') {
if (pathParts.length > 2) { setCollapsed(collapsedProp)
setSelectedKey(pathParts[2]) // Return the section (production/management)
} }
}, [location.pathname]) }, [collapsedProp])
const handleCollapse = (newCollapsed) => { const handleCollapse = (newCollapsed) => {
setCollapsed(newCollapsed) setCollapsed(newCollapsed)
sessionStorage.setItem(SIDEBAR_COLLAPSED_KEY, JSON.stringify(newCollapsed)) sessionStorage.setItem(key, JSON.stringify(newCollapsed))
if (onCollapse) onCollapse(newCollapsed)
} }
const items = [ // Add onClick to each item
{ const _items = items.map((item) => {
key: 'overview', if (item?.type == 'divider') {
label: <Link to='/dashboard/production/overview'>Overview</Link>, return item
icon: <DashboardOutlined />
},
{ type: 'divider' },
{
key: 'printers',
label: <Link to='/dashboard/production/printers'>Printers</Link>,
icon: <PrinterIcon />
},
{
key: 'jobs',
label: <Link to='/dashboard/production/jobs'>Print Jobs</Link>,
icon: <JobIcon />
},
{
key: 'gcodefiles',
label: <Link to='/dashboard/production/gcodefiles'>G Code Files</Link>,
icon: <GCodeFileIcon />
} }
] return {
key: item.key,
icon: item.icon,
label: item.label,
onClick: () => navigate(item.path)
}
})
if (isMobile) { if (isMobile) {
return ( return (
<Menu <Menu
mode='horizontal' mode='horizontal'
selectedKeys={[selectedKey]} selectedKeys={[selectedKey]}
defaultSelectedKeys={['overview']} items={_items}
items={items}
_internalDisableMenuItemTitleTooltip _internalDisableMenuItemTitleTooltip
overflowedIndicator={<Button type='text' icon={<CaretDownFilled />} />} overflowedIndicator={<Button type='text' icon={<CaretDownFilled />} />}
/> />
@ -81,8 +71,7 @@ const ProductionSidebar = () => {
<Menu <Menu
mode='inline' mode='inline'
selectedKeys={[selectedKey]} selectedKeys={[selectedKey]}
defaultSelectedKeys={['overview']} items={_items}
items={items}
style={{ flexGrow: 1, border: 'none' }} style={{ flexGrow: 1, border: 'none' }}
_internalDisableMenuItemTitleTooltip _internalDisableMenuItemTitleTooltip
/> />
@ -100,4 +89,12 @@ const ProductionSidebar = () => {
) )
} }
export default ProductionSidebar DashboardSidebar.propTypes = {
items: PropTypes.array.isRequired,
selectedKey: PropTypes.string,
onCollapse: PropTypes.func,
collapsed: PropTypes.bool,
key: PropTypes.string
}
export default DashboardSidebar

View File

@ -6,7 +6,17 @@ import React, {
useState, useState,
useCallback useCallback
} from 'react' } from 'react'
import { Table, message, Skeleton } from 'antd' import {
Table,
message,
Skeleton,
Card,
Row,
Col,
Descriptions,
Flex,
Spin
} from 'antd'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
@ -18,15 +28,22 @@ const DashboardTable = forwardRef(
columns, columns,
url, url,
pageSize = 25, pageSize = 25,
scrollHeight = 'calc(100vh - 270px)', scrollHeight = 'calc(var(--unit-100vh) - 270px)',
onDataChange, onDataChange,
authenticated, authenticated,
initialPage = 1 initialPage = 1,
cards = false
}, },
ref ref
) => { ) => {
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
const adjustedScrollHeight = isMobile ? 'calc(100vh - 316px)' : scrollHeight var adjustedScrollHeight = scrollHeight
if (isMobile) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 316px)'
}
if (cards) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 210px)'
}
const [, contextHolder] = message.useMessage() const [, contextHolder] = message.useMessage()
const tableRef = useRef(null) const tableRef = useRef(null)
const [filters, setFilters] = useState({}) const [filters, setFilters] = useState({})
@ -96,6 +113,7 @@ const DashboardTable = forwardRef(
setLoading(false) setLoading(false)
setLazyLoading(false) setLazyLoading(false)
return newData
} catch (error) { } catch (error) {
setPages((prev) => setPages((prev) =>
prev.map((page) => ({ prev.map((page) => ({
@ -223,15 +241,19 @@ const DashboardTable = forwardRef(
[fetchData, totalPages] [fetchData, totalPages]
) )
const loadInitialPage = useCallback(() => { const loadInitialPage = useCallback(async () => {
// Create initial page with skeletons // Create initial page with skeletons
setPages([ setPages([{ pageNum: initialPage, items: createSkeletonData() }])
{ pageNum: initialPage, items: createSkeletonData() },
const items = await fetchData(initialPage)
if (items.length >= 25) {
setPages((prev) => [
...prev,
{ pageNum: initialPage + 1, items: createSkeletonData() } { pageNum: initialPage + 1, items: createSkeletonData() }
]) ])
await fetchData(initialPage + 1)
// Fetch both pages }
return Promise.all([fetchData(initialPage), fetchData(initialPage + 1)])
}, [initialPage, createSkeletonData, fetchData]) }, [initialPage, createSkeletonData, fetchData])
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
@ -281,9 +303,91 @@ const DashboardTable = forwardRef(
// Flatten pages array for table display // Flatten pages array for table display
const tableData = pages.flatMap((page) => page.items) const tableData = pages.flatMap((page) => page.items)
// Card view rendering
const renderCards = () => {
return (
<Row
gutter={[16, 16]}
style={{ overflowY: 'auto', maxHeight: adjustedScrollHeight }}
>
{tableData.map((record) => {
// Special case for columns[0] if needed
let icon = null
if (columns[0].key === 'icon' && columns[0].render) {
const renderedIcon = columns[0].render()
icon = React.cloneElement(renderedIcon, {
style: {
fontSize: 32,
...(renderedIcon.props.style || {})
}
})
}
let actions = null
const endColumn = columns.length - 1
if (
columns[endColumn].key === 'actions' &&
columns[endColumn].render
) {
actions = columns[endColumn].render(record)
}
return (
<Col
xs={24}
sm={12}
md={12}
lg={8}
xl={6}
xxl={6}
key={record._id}
>
<Card
style={{ width: '100%', overflow: 'hidden' }}
loading={record.isSkeleton}
>
<Flex align={'center'} vertical gap={'middle'}>
{icon}
<Descriptions column={1} size='small' bordered={false}>
{columns
.filter(
(col) => col.key !== 'icon' && col.key !== 'actions'
)
.map((col) => {
let value
if (col.render && col.dataIndex) {
value = col.render(record[col.dataIndex], record)
} else if (col.render && !col.dataIndex) {
value = col.render(record)
} else {
value = String(record[col.dataIndex] ?? '')
}
return (
<Descriptions.Item
label={col.title}
key={col.key || col.dataIndex}
>
{value}
</Descriptions.Item>
)
})}
</Descriptions>
{actions}
</Flex>
</Card>
</Col>
)
})}
</Row>
)
}
return ( return (
<> <>
{contextHolder} {contextHolder}
{cards ? (
<Spin indicator={<LoadingOutlined />} spinning={loading}>
{renderCards()}
</Spin>
) : (
<Table <Table
ref={tableRef} ref={tableRef}
dataSource={tableData} dataSource={tableData}
@ -298,6 +402,7 @@ const DashboardTable = forwardRef(
showSorterTooltip={false} showSorterTooltip={false}
style={{ height: '100%' }} style={{ height: '100%' }}
/> />
)}
</> </>
) )
} }
@ -312,7 +417,9 @@ DashboardTable.propTypes = {
scrollHeight: PropTypes.string, scrollHeight: PropTypes.string,
onDataChange: PropTypes.func, onDataChange: PropTypes.func,
authenticated: PropTypes.bool.isRequired, authenticated: PropTypes.bool.isRequired,
initialPage: PropTypes.number initialPage: PropTypes.number,
cards: PropTypes.bool,
cardRenderer: PropTypes.func
} }
export default DashboardTable export default DashboardTable

View File

@ -1,205 +1,28 @@
// FilamentSelect.js // FilamentSelect.js
import { TreeSelect, Badge } from 'antd' import React from 'react'
import React, { useEffect, useState, useCallback } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import axios from 'axios'
import config from '../../../config' import config from '../../../config'
import ObjectSelect from './ObjectSelect'
const propertyOrder = ['diameter', 'type', 'vendor.name'] const propertyOrder = ['diameter', 'type', 'vendor.name']
const FilamentSelect = ({ onChange, filter, useFilter, value }) => { const FilamentSelect = ({ onChange, filter, useFilter, value }) => {
const [filamentsTreeData, setFilamentsTreeData] = useState([])
const [filamentsData, setFilamentsData] = useState([])
const [loading, setLoading] = useState(false)
const [defaultValue, setDefaultValue] = useState(value)
const fetchFilamentsData = async (property, filter) => {
setLoading(true)
try {
const response = await axios.get(`${config.backendUrl}/filaments`, {
params: {
...filter,
property
},
headers: {
Accept: 'application/json'
},
withCredentials: true
})
setLoading(false)
return response.data
//setPagination({ ...pagination, total: response.data.totalItems }); // Update total count
} catch (err) {
console.error(err)
}
}
function getByPath(obj, path) {
return path.split('.').reduce((acc, part) => acc && acc[part], obj)
}
const getFilter = useCallback(
(node) => {
var filter = {}
var currentId = node.id
while (currentId != 0) {
const currentNode = filamentsTreeData.filter(
(treeData) => treeData['id'] === currentId
)[0]
filter[propertyOrder[currentNode.propertyId]] = currentNode.value
currentId = currentNode.pId
}
return filter
},
[filamentsTreeData]
)
const generateFilamentTreeNodes = useCallback(
async (node = null, filter = null) => {
if (!node) {
return
}
if (filter === null) {
filter = getFilter(node)
}
const filamentData = await fetchFilamentsData(null, filter)
setFilamentsData(filamentData)
for (var i = 0; i < filamentData.length; i++) {
const filament = filamentData[i]
const newNode = {
id: filament._id,
pId: node.id,
value: filament._id,
key: filament._id,
title: <Badge color={filament.color} text={filament.name} />,
isLeaf: true
}
setFilamentsTreeData((prev) => {
const filtered = prev.filter((node) => node.id !== newNode.id)
return [...filtered, newNode]
})
}
},
[filamentsTreeData, getFilter]
)
const generateFilamentCategoryTreeNodes = useCallback(
async (node = null) => {
var filter = {}
var propertyId = 0
if (!node) {
node = {}
node.id = 0
} else {
filter = getFilter(node)
propertyId = node.propertyId + 1
}
const propertyName = propertyOrder[propertyId]
const propertyData = await fetchFilamentsData(propertyName, filter)
for (var i = 0; i < propertyData.length; i++) {
const property = getByPath(propertyData[i], propertyName)
const newNode = {
id: property,
pId: node.id,
value: property,
key: property,
propertyId: propertyId,
title: property,
isLeaf: false,
selectable: false
}
setFilamentsTreeData((prev) => {
if (prev.some((node) => node.id === newNode.id)) {
return prev // already added
}
return [...prev, newNode]
})
}
},
[getFilter]
)
const handleFilamentsTreeLoad = useCallback(
async (node) => {
if (node) {
if (node.propertyId !== propertyOrder.length - 1) {
await generateFilamentCategoryTreeNodes(node)
} else {
await generateFilamentTreeNodes(node) // End of properties
}
} else {
await generateFilamentCategoryTreeNodes(null) // First property
}
},
[generateFilamentTreeNodes, generateFilamentCategoryTreeNodes]
)
const handleOnChange = (value, selectedOptions) => {
console.log('Handle onchange')
const filamentObject = filamentsData.filter(
(filament) => filament._id == value
)[0]
onChange(filamentObject, selectedOptions)
}
useEffect(() => {
if (value?._id != null) {
console.log('Setting default value...', value)
setDefaultValue(value)
}
}, [value])
useEffect(() => {
console.log('Use Filter', useFilter)
if (defaultValue != undefined) {
const newNode = {
id: defaultValue._id,
pId: 0,
value: defaultValue._id,
key: defaultValue._id,
title: <Badge color={defaultValue.color} text={defaultValue.name} />,
isLeaf: true
}
console.log('setting new node')
setFilamentsTreeData([newNode])
} else {
setFilamentsTreeData([])
}
if (useFilter === true) {
generateFilamentTreeNodes({ id: 0 }, filter)
} else {
handleFilamentsTreeLoad(null)
}
}, [useFilter, defaultValue, filter])
return ( return (
<TreeSelect <ObjectSelect
treeDataSimpleMode endpoint={`${config.backendUrl}/filaments`}
value={defaultValue?._id} propertyOrder={propertyOrder}
loadData={handleFilamentsTreeLoad} filter={filter}
treeData={filamentsTreeData} useFilter={useFilter}
onChange={handleOnChange} value={value}
loading={loading} onChange={onChange}
placeholder='Select Filament'
type={'filament'}
/> />
) )
} }
FilamentSelect.propTypes = { FilamentSelect.propTypes = {
onChange: PropTypes.func.isRequired, onChange: PropTypes.func,
value: PropTypes.object, value: PropTypes.object,
filter: PropTypes.object, filter: PropTypes.object,
useFilter: PropTypes.bool useFilter: PropTypes.bool

View File

@ -77,7 +77,7 @@ const FilamentStockState = ({
}, [currentState]) }, [currentState])
return ( return (
<Flex gap='middle' align={'center'}> <Flex gap='small' align={'center'} wrap>
{showStatus && ( {showStatus && (
<Space> <Space>
<Tag color={badgeStatus} style={{ marginRight: 0 }}> <Tag color={badgeStatus} style={{ marginRight: 0 }}>

View File

@ -1,12 +1,8 @@
// GCodeFileSelect.js // GCodeFileSelect.js
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { TreeSelect, Badge, Flex, message, Typography } from 'antd' import React from 'react'
import React, { useEffect, useState, useContext } from 'react'
import axios from 'axios'
import GCodeFileIcon from '../../Icons/GCodeFileIcon'
import { AuthContext } from '../context/AuthContext'
import config from '../../../config' import config from '../../../config'
import ObjectSelect from './ObjectSelect'
const propertyOrder = [ const propertyOrder = [
'filament.diameter', 'filament.diameter',
@ -14,205 +10,25 @@ const propertyOrder = [
'filament.vendor.name' 'filament.vendor.name'
] ]
const { Text } = Typography
const GCodeFileSelect = ({ onChange, filter, useFilter = false, style }) => { const GCodeFileSelect = ({ onChange, filter, useFilter = false, style }) => {
const [gcodeFilesTreeData, setGCodeFilesTreeData] = useState(null)
const [loading, setLoading] = useState(true)
const [searchValue, setSearchValue] = useState('')
const [messageApi] = message.useMessage()
const { authenticated } = useContext(AuthContext)
const fetchGCodeFilesData = async (property, filter, search) => {
if (!authenticated) {
return
}
setLoading(true)
try {
const response = await axios.get(`${config.backendUrl}/gcodefiles`, {
params: {
...filter,
search,
property
},
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
})
setLoading(false)
return response.data
// setPagination({ ...pagination, total: response.data.totalItems }); // Update total count
} catch (error) {
if (error.response) {
// For other errors, show a message
messageApi.error('Error fetching GCode files:', error.response.status)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
}
}
const getFilter = (node) => {
const filter = {}
let currentId = node.id
while (currentId != 0) {
const currentNode = gcodeFilesTreeData.filter(
(treeData) => treeData['id'] === currentId
)[0]
filter[propertyOrder[currentNode.propertyId]] =
currentNode.value.split('-')[0]
currentId = currentNode.pId
}
return filter
}
const generateGCodeFileTreeNodes = async (node = null, filter = null) => {
if (!node) {
return
}
if (filter === null) {
filter = getFilter(node)
}
let search = null
if (searchValue != '') {
search = searchValue
}
const gcodeFileData = await fetchGCodeFilesData(null, filter, search)
let newNodeList = []
for (var i = 0; i < gcodeFileData.length; i++) {
const gcodeFile = gcodeFileData[i]
const random = Math.random().toString(36).substring(2, 6)
const newNode = {
id: random,
pId: node.id,
value: gcodeFile._id,
key: gcodeFile._id,
title: (
<Flex gap={'small'} align='center' style={{ width: '100%' }}>
<GCodeFileIcon />
<Badge color={gcodeFile.filament.color} />
<Text ellipsis>
{gcodeFile.name + ' (' + gcodeFile.filament.name + ')'}
</Text>
</Flex>
),
isLeaf: true
}
newNodeList.push(newNode)
}
return newNodeList
}
function getByPath(obj, path) {
return path.split('.').reduce((acc, part) => acc && acc[part], obj)
}
const generateGCodeFileCategoryTreeNodes = async (node = null) => {
var filter = {}
var propertyId = 0
if (!node) {
node = {}
node.id = 0
} else {
filter = getFilter(node)
propertyId = node.propertyId + 1
}
const propertyName = propertyOrder[propertyId]
const propertyData = await fetchGCodeFilesData(propertyName, filter)
const newNodeList = []
for (var i = 0; i < propertyData.length; i++) {
const property = getByPath(propertyData[i], propertyName)
const random = Math.random().toString(36).substring(2, 6)
const newNode = {
id: random,
pId: node.id,
value: property + '-' + random,
key: property + '-' + random,
propertyId: propertyId,
title: property,
isLeaf: false,
selectable: false
}
newNodeList.push(newNode)
}
return newNodeList
}
const handleGCodeFilesTreeLoad = async (node) => {
if (node) {
if (node.propertyId !== propertyOrder.length - 1) {
setGCodeFilesTreeData(
gcodeFilesTreeData.concat(
await generateGCodeFileCategoryTreeNodes(node)
)
)
} else {
setGCodeFilesTreeData(
gcodeFilesTreeData.concat(await generateGCodeFileTreeNodes(node))
) // End of properties
}
} else {
setGCodeFilesTreeData(await generateGCodeFileCategoryTreeNodes(null)) // First property
}
}
const handleGCodeFilesSearch = (value) => {
setSearchValue(value)
setGCodeFilesTreeData(null)
}
useEffect(() => {
setGCodeFilesTreeData([])
}, [filter, useFilter])
useEffect(() => {
if (gcodeFilesTreeData === null) {
if (useFilter === true || searchValue != '') {
setGCodeFilesTreeData(generateGCodeFileTreeNodes({ id: 0 }, filter))
} else {
handleGCodeFilesTreeLoad(null)
}
}
}, [gcodeFilesTreeData])
return ( return (
<TreeSelect <ObjectSelect
showSearch endpoint={`${config.backendUrl}/gcodefiles`}
treeDataSimpleMode propertyOrder={propertyOrder}
loadData={handleGCodeFilesTreeLoad} filter={filter}
treeData={gcodeFilesTreeData} useFilter={useFilter}
onChange={onChange} onChange={onChange}
onSearch={handleGCodeFilesSearch} showSearch={true}
loading={loading}
placeholder='Select GCode File'
style={style} style={style}
placeholder='Select GCode File'
type='gcodefile'
/> />
) )
} }
GCodeFileSelect.propTypes = { GCodeFileSelect.propTypes = {
onChange: PropTypes.func, onChange: PropTypes.func,
filter: PropTypes.string, filter: PropTypes.object,
useFilter: PropTypes.bool, useFilter: PropTypes.bool,
style: PropTypes.object style: PropTypes.object
} }

View File

@ -1,27 +1,20 @@
// PrinterSelect.js // PrinterSelect.js
import React from 'react' import React from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { Flex, Typography, Button, Tooltip, message, Space } from 'antd' import {
Flex,
Typography,
Button,
Tooltip,
message,
Space,
Popover
} from 'antd'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import CopyIcon from '../../Icons/CopyIcon' import CopyIcon from '../../Icons/CopyIcon'
import PrinterIcon from '../../Icons/PrinterIcon' import SpotlightTooltip from './SpotlightTooltip'
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon' import { getTypeMeta } from '../utils/Utils'
import FilamentIcon from '../../Icons/FilamentIcon'
import FilamentStockIcon from '../../Icons/FilamentStockIcon'
import GCodeFileIcon from '../../Icons/GCodeFileIcon'
import JobIcon from '../../Icons/JobIcon'
import PartIcon from '../../Icons/PartIcon'
import ProductIcon from '../../Icons/ProductIcon'
import VendorIcon from '../../Icons/VendorIcon'
import SubJobIcon from '../../Icons/SubJobIcon'
import StockEventIcon from '../../Icons/StockEventIcon'
import StockAuditIcon from '../../Icons/StockAuditIcon'
import PartStockIcon from '../../Icons/PartStockIcon'
import ProductStockIcon from '../../Icons/ProductStockIcon'
import AuditLogIcon from '../../Icons/AuditLogIcon'
import PersonIcon from '../../Icons/PersonIcon'
import NoteTypeIcon from '../../Icons/NoteTypeIcon'
const { Text, Link } = Typography const { Text, Link } = Typography
@ -30,107 +23,18 @@ const IdText = ({
type, type,
showCopy = true, showCopy = true,
longId = true, longId = true,
showHyperlink = false showHyperlink = false,
showSpotlight = true
}) => { }) => {
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate() const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
var prefix = 'UNK' const meta = getTypeMeta(type)
var hyperlink = '#' const prefix = meta.prefix
var icon = <QuestionCircleIcon style={{ paddingTop: '4px' }} /> const hyperlink = meta.url(id)
const IconComponent = meta.icon
switch (type) { const icon = <IconComponent style={{ paddingTop: '4px' }} />
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':
prefix = 'GCF'
hyperlink = `/dashboard/production/gcodefiles/info?gcodeFileId=${id}`
icon = <GCodeFileIcon style={{ paddingTop: '4px' }} />
break
case 'job':
prefix = 'JOB'
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 = typeof id === 'string' ? id.toString().toUpperCase() : 'XXXXXX' id = typeof id === 'string' ? id.toString().toUpperCase() : 'XXXXXX'
var displayId = prefix + ':' + id var displayId = prefix + ':' + id
@ -141,10 +45,22 @@ const IdText = ({
} }
return ( return (
<Flex align={'center'} gap={'small'}> <Flex align={'center'} gap={'small'} className='idtext'>
{contextHolder} {contextHolder}
{showHyperlink && ( {showHyperlink &&
(showSpotlight ? (
<Popover
content={
id && type ? (
<SpotlightTooltip query={prefix + ':' + id} type={type} />
) : null
}
trigger={['hover', 'click']}
placement='topLeft'
arrow={false}
style={{ padding: 0 }}
>
<Link <Link
onClick={() => { onClick={() => {
if (showHyperlink) { if (showHyperlink) {
@ -159,16 +75,51 @@ const IdText = ({
</Space> </Space>
</Text> </Text>
</Link> </Link>
)} </Popover>
) : (
{!showHyperlink && ( <Link
onClick={() => {
if (showHyperlink) {
navigate(hyperlink)
}
}}
>
<Text code ellipsis> <Text code ellipsis>
<Space size={4}> <Space size={4}>
{icon} {icon}
{displayId} {displayId}
</Space> </Space>
</Text> </Text>
)} </Link>
))}
{!showHyperlink &&
(showSpotlight ? (
<Popover
content={
id && type ? (
<SpotlightTooltip query={prefix + ':' + id} type={type} />
) : null
}
trigger={['hover', 'click']}
placement='topLeft'
arrow={false}
>
<Text code ellipsis>
<Space size={4}>
{icon}
{displayId}
</Space>
</Text>
</Popover>
) : (
<Text code ellipsis>
<Space size={4}>
{icon}
{displayId}
</Space>
</Text>
))}
{showCopy && ( {showCopy && (
<Tooltip title='Copy ID' arrow={false}> <Tooltip title='Copy ID' arrow={false}>
<Button <Button
@ -176,14 +127,44 @@ const IdText = ({
type='text' type='text'
style={{ height: '22px' }} style={{ height: '22px' }}
onClick={() => { onClick={() => {
const doCopy = (text) => {
if (
navigator &&
navigator.clipboard &&
navigator.clipboard.writeText
) {
navigator.clipboard navigator.clipboard
.writeText(copyId) .writeText(text)
.then(() => { .then(() => {
messageApi.success('ID copied to clipboard') messageApi.success('ID copied to clipboard')
}) })
.catch(() => { .catch(() => {
messageApi.error('Failed to copy ID') messageApi.error('Failed to copy ID')
}) })
} else if (
document.queryCommandSupported &&
document.queryCommandSupported('copy')
) {
// Legacy fallback
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'absolute'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand('copy')
messageApi.success('ID copied to clipboard')
} catch (err) {
messageApi.error('Failed to copy ID')
}
document.body.removeChild(textarea)
} else {
messageApi.error('Copy not supported in this browser')
}
}
doCopy(copyId)
}} }}
/> />
</Tooltip> </Tooltip>
@ -197,7 +178,8 @@ IdText.propTypes = {
type: PropTypes.string, type: PropTypes.string,
showCopy: PropTypes.bool, showCopy: PropTypes.bool,
longId: PropTypes.bool, longId: PropTypes.bool,
showHyperlink: PropTypes.bool showHyperlink: PropTypes.bool,
showSpotlight: PropTypes.bool
} }
export default IdText export default IdText

View File

@ -1,41 +1,33 @@
import React from 'react' import React from 'react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import { Typography, List, Space } from 'antd' import { Typography, Space } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
const { Title, Paragraph, Text } = Typography const { Title, Paragraph, Text } = Typography
const UlComponent = ({ children }) => ( const UlComponent = ({ children }) => (
<List <ul style={{ paddingLeft: '20px', margin: 0 }}>{children}</ul>
size='small'
dataSource={children}
renderItem={(item) => <List.Item>{item}</List.Item>}
/>
) )
UlComponent.propTypes = { children: PropTypes.node } UlComponent.propTypes = {
children: PropTypes.node
}
const OlComponent = ({ children }) => ( const OlComponent = ({ children }) => (
<List <ol style={{ paddingLeft: '20px', margin: 0 }}>{children}</ol>
size='small'
dataSource={children}
renderItem={(item) => <List.Item>{item}</List.Item>}
/>
) )
OlComponent.propTypes = { children: PropTypes.node } OlComponent.propTypes = {
children: PropTypes.node
}
const LiComponent = ({ children }) => <List.Item>{children}</List.Item> const LiComponent = ({ children }) => <li>{children}</li>
LiComponent.propTypes = { children: PropTypes.node } LiComponent.propTypes = {
children: PropTypes.node
}
const BlockquoteComponent = ({ children }) => ( const BlockquoteComponent = ({ children }) => (
<Paragraph <Paragraph>
style={{ <blockquote>{children}</blockquote>
borderLeft: '4px solid #f0f0f0',
paddingLeft: '16px',
margin: '16px 0'
}}
>
{children}
</Paragraph> </Paragraph>
) )
BlockquoteComponent.propTypes = { children: PropTypes.node } BlockquoteComponent.propTypes = { children: PropTypes.node }
@ -59,22 +51,18 @@ const MarkdownDisplay = ({ content }) => {
<Text code {...props} /> <Text code {...props} />
) : ( ) : (
<Paragraph> <Paragraph>
<pre <pre {...props} />
style={{
background: '#f5f5f5',
padding: '16px',
borderRadius: '4px'
}}
>
<code {...props} />
</pre>
</Paragraph> </Paragraph>
), ),
blockquote: BlockquoteComponent blockquote: BlockquoteComponent
} }
return ( return (
<Space direction='vertical' style={{ width: '100%' }}> <Space
direction='vertical'
style={{ width: '100%' }}
className={'markdown-display'}
>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{content} {content}
</ReactMarkdown> </ReactMarkdown>

View File

@ -0,0 +1,92 @@
// NoteTypeSelect.js
import PropTypes from 'prop-types'
import { message, Tag, Select, Space } from 'antd'
import React, { useEffect, useState, useContext, useCallback } from 'react'
import axios from 'axios'
import { AuthContext } from '../context/AuthContext'
import config from '../../../config'
const NoteTypeSelect = ({ onChange, disabled, value = null }) => {
const [noteTypeOptions, setNoteTypeOptions] = useState([])
const [loading, setLoading] = useState(true)
const [messageApi] = message.useMessage()
const { authenticated } = useContext(AuthContext)
const fetchNoteTypesData = useCallback(async () => {
if (!authenticated) {
return
}
setLoading(true)
try {
const response = await axios.get(
`${config.backendUrl}/notetypes?active=true`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
}
)
const data = response.data
setNoteTypeOptions(() => {
var options = []
data.map((noteType) => {
var newNoteTypeOption = {
label: (
<Space>
<Tag color={noteType?.color}>{noteType.name}</Tag>
</Space>
),
value: noteType._id
}
options.push(newNoteTypeOption)
})
return options
})
setLoading(false)
} catch (error) {
if (error.response) {
// For other errors, show a message
messageApi.error(
'Error fetching note types data:',
error.response.status
)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
}
}, [authenticated, messageApi])
useEffect(() => {
if (authenticated) {
fetchNoteTypesData()
}
}, [authenticated, fetchNoteTypesData])
return (
<Select
options={noteTypeOptions}
onChange={onChange}
loading={loading}
disabled={disabled}
placeholder='Select note type'
style={{ width: '100%' }}
value={value}
/>
)
}
NoteTypeSelect.propTypes = {
onChange: PropTypes.func,
disabled: PropTypes.bool,
checkable: PropTypes.bool,
value: PropTypes.object
}
export default NoteTypeSelect

View File

@ -0,0 +1,114 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Typography, Space, Tag, Badge, Flex, Card } from 'antd'
import {
BellOutlined,
InfoCircleOutlined,
ExclamationCircleOutlined,
CheckCircleOutlined
} from '@ant-design/icons'
import TimeDisplay from './TimeDisplay'
const { Text, Paragraph } = Typography
const Notification = ({ notification, onMarkAsRead }) => {
const getNotificationIcon = (type) => {
switch (type) {
case 'info':
return <InfoCircleOutlined style={{ color: '#1890ff' }} />
case 'warning':
return <ExclamationCircleOutlined style={{ color: '#faad14' }} />
case 'error':
return <ExclamationCircleOutlined style={{ color: '#ff4d4f' }} />
case 'success':
return <CheckCircleOutlined style={{ color: '#52c41a' }} />
default:
return <BellOutlined style={{ color: '#8c8c8c' }} />
}
}
const getNotificationColor = (type) => {
switch (type) {
case 'info':
return 'blue'
case 'warning':
return 'orange'
case 'error':
return 'red'
case 'success':
return 'green'
default:
return 'default'
}
}
const handleMarkAsRead = () => {
if (onMarkAsRead && !notification.read) {
onMarkAsRead(notification._id)
}
}
return (
<Card
size='small'
style={{
backgroundColor: notification.read ? '#fafafa' : '#ffffff',
cursor: 'pointer',
transition: 'background-color 0.2s',
border: notification.read ? '1px solid #f0f0f0' : '1px solid #d9d9d9'
}}
onClick={handleMarkAsRead}
bodyStyle={{ padding: '12px' }}
>
<Flex align='start' gap='small'>
<Badge dot={!notification.read} offset={[-5, 5]}>
{getNotificationIcon(notification.type)}
</Badge>
<Flex vertical style={{ flex: 1 }} gap='small'>
<Flex justify='space-between' align='center'>
<Space>
<Text strong={!notification.read}>{notification.title}</Text>
<Tag color={getNotificationColor(notification.type)} size='small'>
{notification.type}
</Tag>
</Space>
<Text type='secondary' style={{ fontSize: '12px' }}>
<TimeDisplay dateTime={notification.createdAt} showSince={true} />
</Text>
</Flex>
<Paragraph
ellipsis={{
rows: 2,
expandable: true,
symbol: 'Show more'
}}
style={{ margin: 0 }}
>
{notification.message}
</Paragraph>
{notification.metadata && (
<Text type='secondary' style={{ fontSize: '12px' }}>
{JSON.stringify(notification.metadata)}
</Text>
)}
</Flex>
</Flex>
</Card>
)
}
Notification.propTypes = {
notification: PropTypes.shape({
_id: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
message: PropTypes.string.isRequired,
type: PropTypes.oneOf(['info', 'warning', 'error', 'success', 'default'])
.isRequired,
read: PropTypes.bool.isRequired,
createdAt: PropTypes.string.isRequired,
metadata: PropTypes.object
}).isRequired,
onMarkAsRead: PropTypes.func
}
export default Notification

View File

@ -0,0 +1,241 @@
import React, { useState, useEffect, useContext, useCallback } from 'react'
import {
Typography,
Space,
Button,
Empty,
Spin,
message,
Popconfirm,
Flex,
Badge,
Dropdown
} from 'antd'
import {
BellOutlined,
DeleteOutlined,
CheckOutlined,
ReloadOutlined
} from '@ant-design/icons'
import axios from 'axios'
import PropTypes from 'prop-types'
import { AuthContext } from '../context/AuthContext'
import config from '../../../config'
import Notification from './Notification'
const { Text } = Typography
const NotificationCenter = ({ visible }) => {
const [notifications, setNotifications] = useState([])
const [loading, setLoading] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [messageApi, contextHolder] = message.useMessage()
const { authenticated } = useContext(AuthContext)
const fetchNotifications = useCallback(async () => {
if (!authenticated) return
setLoading(true)
try {
const response = await axios.get(`${config.backendUrl}/notifications`, {
headers: {
Accept: 'application/json'
},
withCredentials: true
})
setNotifications(response.data)
} catch (error) {
console.error('Error fetching notifications:', error)
messageApi.error('Failed to fetch notifications')
} finally {
setLoading(false)
}
}, [authenticated, messageApi])
const markAsRead = useCallback(
async (notificationId) => {
try {
await axios.put(
`${config.backendUrl}/notifications/${notificationId}/read`,
{},
{
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
// Update local state
setNotifications((prev) =>
prev.map((notification) => {
if (notification._id === notificationId) {
return { ...notification, read: true }
}
return notification
})
)
messageApi.success('Notification marked as read')
} catch (error) {
console.error('Error marking notification as read:', error)
messageApi.error('Failed to mark notification as read')
}
},
[messageApi]
)
const markAllAsRead = useCallback(async () => {
try {
await axios.put(
`${config.backendUrl}/notifications/read-all`,
{},
{
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
// Update local state
setNotifications((prev) =>
prev.map((notification) => ({ ...notification, read: true }))
)
messageApi.success('All notifications marked as read')
} catch (error) {
console.error('Error marking all notifications as read:', error)
messageApi.error('Failed to mark all notifications as read')
}
}, [messageApi])
const deleteAllNotifications = useCallback(async () => {
try {
await axios.delete(`${config.backendUrl}/notifications`, {
headers: {
Accept: 'application/json'
},
withCredentials: true
})
setNotifications([])
messageApi.success('All notifications deleted')
} catch (error) {
console.error('Error deleting all notifications:', error)
messageApi.error('Failed to delete all notifications')
} finally {
setShowDeleteConfirm(false)
}
}, [messageApi])
useEffect(() => {
if (visible && authenticated) {
fetchNotifications()
}
}, [visible, authenticated, fetchNotifications])
const unreadCount = notifications.filter(
(notification) => !notification.read
).length
const actionItems = {
items: [
{
label: 'Mark All Read',
key: 'markAllRead',
icon: <CheckOutlined />,
disabled: unreadCount === 0
},
{
label: 'Reload Notifications',
key: 'reloadNotifications',
icon: <ReloadOutlined />
},
{ type: 'divider' },
{
label: 'Delete All',
key: 'deleteAll',
icon: <DeleteOutlined />,
danger: true,
disabled: notifications.length === 0
}
],
onClick: ({ key }) => {
if (key === 'markAllRead') {
markAllAsRead()
} else if (key === 'reloadNotifications') {
fetchNotifications()
} else if (key === 'deleteAll') {
setShowDeleteConfirm(true)
}
}
}
if (!visible) {
return null
}
return (
<>
{contextHolder}
<Flex justify='space-between' align='center'>
<Space size='middle'>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Badge count={unreadCount} size='small'>
<BellOutlined style={{ fontSize: '18px' }} />
</Badge>
</Space>
<Space>
<Button icon={<DeleteOutlined />} danger />
</Space>
</Flex>
<div style={{ maxHeight: 500, overflow: 'auto' }}>
{loading ? (
<div style={{ padding: '40px', textAlign: 'center' }}>
<Spin size='large' />
<div style={{ marginTop: 16 }}>
<Text type='secondary'>Loading notifications...</Text>
</div>
</div>
) : notifications.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description='No notifications'
style={{ padding: '40px 20px' }}
/>
) : (
<Flex vertical gap='small' style={{ padding: '16px' }}>
{notifications.map((notification) => (
<Notification
key={notification._id}
notification={notification}
onMarkAsRead={markAsRead}
/>
))}
</Flex>
)}
</div>
<Popconfirm
title='Delete all notifications?'
description='This action cannot be undone.'
open={showDeleteConfirm}
onConfirm={deleteAllNotifications}
onCancel={() => setShowDeleteConfirm(false)}
okText='Yes'
cancelText='No'
/>
</>
)
}
NotificationCenter.propTypes = {
visible: PropTypes.bool.isRequired
}
export default NotificationCenter

View File

@ -0,0 +1,476 @@
import React, { useEffect, useState, useCallback } from 'react'
import PropTypes from 'prop-types'
import { TreeSelect, Typography, Flex, Badge } from 'antd'
import axios from 'axios'
import { getTypeMeta } from '../utils/Utils'
import IdText from './IdText'
const { Text } = Typography
/**
* ObjectSelect - a generic, reusable async TreeSelect for hierarchical object selection.
*
* Props:
* - endpoint: API endpoint to fetch data from (required)
* - propertyOrder: array of property names for category levels (required)
* - filter: object for filtering (optional)
* - useFilter: bool (optional)
* - value: selected value (optional) - can be an object with _id or a simple value
* - onChange: function (optional)
* - showSearch: bool (optional, default false)
* - treeSelectProps: any other TreeSelect props (optional)
*/
const ObjectSelect = ({
endpoint,
propertyOrder,
filter = {},
useFilter = false,
value,
onChange,
showSearch = false,
treeSelectProps = {},
type = 'unknown',
...rest
}) => {
const [treeData, setTreeData] = useState([])
const [loading, setLoading] = useState(false)
const [defaultValue, setDefaultValue] = useState(value)
const [searchValue, setSearchValue] = useState('')
const [error, setError] = useState(false)
// Helper to get filter object for a node
const getFilter = useCallback(
(node) => {
let filterObj = {}
let currentId = node.id
while (currentId !== 0) {
const currentNode = treeData.find((d) => d.id === currentId)
if (!currentNode) break
filterObj[propertyOrder[currentNode.propertyId]] = currentNode.value
currentId = currentNode.pId
}
return filterObj
},
[treeData, propertyOrder]
)
// Fetch data from API
const fetchData = useCallback(
async (property, filter, search) => {
setLoading(true)
setError(false)
try {
const params = { ...filter, property }
if (search) params.search = search
const response = await axios.get(endpoint, {
params,
withCredentials: true
})
setLoading(false)
return response.data
} catch (err) {
setLoading(false)
setError(true)
// Optionally handle error
return []
}
},
[endpoint]
)
// Fetch single object by ID
const fetchObjectById = useCallback(
async (objectId) => {
setLoading(true)
setError(false)
try {
const response = await axios.get(`${endpoint}/${objectId}`, {
withCredentials: true
})
setLoading(false)
return response.data
} catch (err) {
setLoading(false)
setError(true)
console.error('Failed to fetch object by ID:', err)
return null
}
},
[endpoint]
)
// Helper to render the title for a node
const renderTitle = useCallback(
(item, isLeaf) => {
if (!isLeaf) {
// For category nodes, just show the value
return <Text>{item[propertyOrder[item.propertyId]] || item.value}</Text>
}
// For leaf nodes, show icon, name, and id
const meta = getTypeMeta(type)
const Icon = meta.icon
return (
<Flex gap={'small'} align='center' style={{ width: '100%' }}>
{Icon && <Icon />}
{item?.color && <Badge color={item.color}></Badge>}
<Text ellipsis>{item.name || type.title}</Text>
<IdText id={item._id} longId={false} type={type} />
</Flex>
)
},
[propertyOrder, type]
)
// Build tree path for a default object
const buildTreePathForObject = useCallback(
async (object) => {
if (!object || !propertyOrder || propertyOrder.length === 0) return
const newNodes = []
let currentPId = 0
// Build category nodes for each property level and load all available options
for (let i = 0; i < propertyOrder.length - 1; i++) {
const propertyName = propertyOrder[i]
let propertyValue
// Handle nested property access (e.g., 'filament.diameter')
if (propertyName.includes('.')) {
const propertyPath = propertyName.split('.')
let currentValue = object
for (const prop of propertyPath) {
if (currentValue && typeof currentValue === 'object') {
currentValue = currentValue[prop]
} else {
currentValue = undefined
break
}
}
propertyValue = currentValue
} else {
propertyValue = object[propertyName]
}
// Build filter for this level
let filterObj = {}
for (let j = 0; j < i; j++) {
const prevPropertyName = propertyOrder[j]
let prevPropertyValue
if (prevPropertyName.includes('.')) {
const propertyPath = prevPropertyName.split('.')
let currentValue = object
for (const prop of propertyPath) {
if (currentValue && typeof currentValue === 'object') {
currentValue = currentValue[prop]
} else {
currentValue = undefined
break
}
}
prevPropertyValue = currentValue
} else {
prevPropertyValue = object[prevPropertyName]
}
if (prevPropertyValue !== undefined && prevPropertyValue !== null) {
filterObj[prevPropertyName] = prevPropertyValue
}
}
// Fetch all available options for this property level
const data = await fetchData(propertyName, filterObj, '')
// Create nodes for all available options at this level
const levelNodes = data.map((item) => {
let value
if (typeof item === 'object' && item !== null) {
if (propertyName.includes('.')) {
const propertyPath = propertyName.split('.')
let currentValue = item
for (const prop of propertyPath) {
if (currentValue && typeof currentValue === 'object') {
currentValue = currentValue[prop]
} else {
currentValue = undefined
break
}
}
value = currentValue
} else {
value = item[propertyName]
}
} else {
value = item
}
return {
id: value,
pId: currentPId,
value: value,
key: value,
propertyId: i,
title: renderTitle({ ...item, value }, false),
isLeaf: false,
selectable: false,
raw: item
}
})
newNodes.push(...levelNodes)
// Update currentPId to the object's property value for the next level
if (propertyValue !== undefined && propertyValue !== null) {
currentPId = propertyValue
}
}
// Load all leaf nodes at the final level
let finalFilterObj = {}
for (let j = 0; j < propertyOrder.length - 1; j++) {
const prevPropertyName = propertyOrder[j]
let prevPropertyValue
if (prevPropertyName.includes('.')) {
const propertyPath = prevPropertyName.split('.')
let currentValue = object
for (const prop of propertyPath) {
if (currentValue && typeof currentValue === 'object') {
currentValue = currentValue[prop]
} else {
currentValue = undefined
break
}
}
prevPropertyValue = currentValue
} else {
prevPropertyValue = object[prevPropertyName]
}
if (prevPropertyValue !== undefined && prevPropertyValue !== null) {
finalFilterObj[prevPropertyName] = prevPropertyValue
}
}
const leafData = await fetchData(null, finalFilterObj, '')
const leafNodes = leafData.map((item) => ({
id: item._id || item.id || item.value,
pId: currentPId,
value: item._id || item.id || item.value,
key: item._id || item.id || item.value,
title: renderTitle(item, true),
isLeaf: true,
raw: item
}))
newNodes.push(...leafNodes)
setTreeData(newNodes)
setDefaultValue(object._id || object.id)
},
[propertyOrder, renderTitle, fetchData]
)
// Generate leaf nodes
const generateLeafNodes = useCallback(
async (node = null, filterArg = null, search = '') => {
if (!node) return
const actualFilter = filterArg === null ? getFilter(node) : filterArg
const data = await fetchData(null, actualFilter, search)
const newNodes = data.map((item) => {
const isLeaf = true
return {
id: item._id || item.id || item.value,
pId: node.id,
value: item._id || item.id || item.value,
key: item._id || item.id || item.value,
title: renderTitle(item, isLeaf),
isLeaf: true,
raw: item
}
})
setTreeData((prev) => [...prev, ...newNodes])
},
[fetchData, getFilter, renderTitle]
)
// Generate category nodes
const generateCategoryNodes = useCallback(
async (node = null, search = '') => {
let filterObj = {}
let propertyId = 0
if (!node) {
node = { id: 0 }
} else {
filterObj = getFilter(node)
propertyId = node.propertyId + 1
}
const propertyName = propertyOrder[propertyId]
const data = await fetchData(propertyName, filterObj, search)
const newNodes = data.map((item) => {
const isLeaf = false
// Handle both cases: when item is a simple value or when it's an object
let value
if (typeof item === 'object' && item !== null) {
// Handle nested property access (e.g., 'filament.diameter')
if (propertyName.includes('.')) {
const propertyPath = propertyName.split('.')
let currentValue = item
for (const prop of propertyPath) {
if (currentValue && typeof currentValue === 'object') {
currentValue = currentValue[prop]
} else {
currentValue = undefined
break
}
}
value = currentValue
} else {
// If item is an object, try to get the property value
value = item[propertyName]
}
} else {
// If item is a simple value (string, number, etc.), use it directly
value = item
}
const title = renderTitle({ ...item, value }, isLeaf)
console.log('propname', propertyName)
console.log('value', value)
console.log(item)
return {
id: value,
pId: node.id,
value: value,
key: value,
propertyId: propertyId,
title: title,
isLeaf: false,
selectable: false,
raw: item
}
})
setTreeData((prev) => [...prev, ...newNodes])
},
[fetchData, getFilter, propertyOrder, renderTitle]
)
// Tree loader
const handleTreeLoad = useCallback(
async (node) => {
if (node) {
if (node.propertyId !== propertyOrder.length - 1) {
await generateCategoryNodes(node, searchValue)
} else {
await generateLeafNodes(node, null, searchValue)
}
} else {
await generateCategoryNodes(null, searchValue)
}
},
[propertyOrder, generateCategoryNodes, generateLeafNodes, searchValue]
)
// OnChange handler
const handleOnChange = (val, selectedOptions) => {
if (onChange) {
// Find the raw object for the selected value
const node = treeData.find((n) => n.value === val)
onChange(node ? node.raw : val, selectedOptions)
}
console.log('val', val)
setDefaultValue(val)
}
// Search handler
const handleSearch = (val) => {
setSearchValue(val)
setTreeData([])
}
// Keep defaultValue in sync and handle object values
useEffect(() => {
if (value?._id) {
setDefaultValue(value._id)
}
// Check if value is an object with _id (default object case)
if (value && typeof value === 'object' && value._id) {
// If we already have this object loaded, don't fetch again
const existingNode = treeData.find((node) => node.value === value._id)
if (!existingNode) {
fetchObjectById(value._id).then((object) => {
if (object) {
buildTreePathForObject(object)
}
})
}
}
}, [value, treeData, fetchObjectById, buildTreePathForObject])
// Initial load
useEffect(() => {
if (treeData.length === 0 && !error && !loading) {
// If we have a default object value, don't load the regular tree
if (value && typeof value === 'object' && value._id) {
return
}
if (useFilter || searchValue) {
generateLeafNodes({ id: 0 }, filter, searchValue)
} else {
handleTreeLoad(null)
}
}
}, [
treeData,
useFilter,
filter,
searchValue,
generateLeafNodes,
handleTreeLoad,
error,
loading,
value
])
return error ? (
<div style={{ color: 'red', padding: 8 }}>
Failed to load data.{' '}
<button
onClick={() => {
setError(false)
setTreeData([])
}}
>
Retry
</button>
</div>
) : (
<TreeSelect
treeDataSimpleMode
treeDefaultExpandAll={true}
loadData={handleTreeLoad}
treeData={treeData}
onChange={handleOnChange}
loading={loading}
value={defaultValue}
showSearch={showSearch}
onSearch={showSearch ? handleSearch : undefined}
{...treeSelectProps}
{...rest}
/>
)
}
ObjectSelect.propTypes = {
endpoint: PropTypes.string.isRequired,
propertyOrder: PropTypes.arrayOf(PropTypes.string).isRequired,
filter: PropTypes.object,
useFilter: PropTypes.bool,
value: PropTypes.any,
onChange: PropTypes.func,
showSearch: PropTypes.bool,
treeSelectProps: PropTypes.object,
type: PropTypes.string.isRequired
}
export default ObjectSelect

View File

@ -1,157 +1,26 @@
// PartSelect.js // PartSelect.js
import { TreeSelect, Badge } from 'antd' import React from 'react'
import React, { useEffect, useState, useContext, useRef } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import axios from 'axios' import { Badge } from 'antd'
import { AuthContext } from '../../Auth/AuthContext' import config from '../../../config'
import ObjectSelect from './ObjectSelect'
const propertyOrder = ['diameter', 'type', 'brand'] const propertyOrder = ['diameter', 'type', 'brand']
const PartSelect = ({ onChange, filter, useFilter }) => { const PartSelect = ({ onChange, filter, useFilter }) => {
const [partsTreeData, setPartsTreeData] = useState([])
const { token } = useContext(AuthContext)
const tokenRef = useRef(token)
const [loading, setLoading] = useState(true)
const fetchPartsData = async (property, filter) => {
setLoading(true)
try {
const response = await axios.get(`${config.backendUrl}/parts', {
params: {
...filter,
property
},
headers: {
Authorization: `Bearer ${tokenRef.current}`
}
})
setLoading(false)
return response.data
//setPagination({ ...pagination, total: response.data.totalItems }); // Update total count
} catch (err) {
console.error(err)
}
}
const getFilter = (node) => {
var filter = {}
var currentId = node.id
while (currentId != 0) {
const currentNode = partsTreeData.filter(
(treeData) => treeData['id'] === currentId
)[0]
filter[propertyOrder[currentNode.propertyId]] =
currentNode.value.split('-')[0]
currentId = currentNode.pId
}
return filter
}
const generatePartTreeNodes = async (node = null, filter = null) => {
if (!node) {
return
}
if (filter === null) {
filter = getFilter(node)
}
const partData = await fetchPartsData(null, filter)
let newNodeList = []
for (var i = 0; i < partData.length; i++) {
const part = partData[i]
const random = Math.random().toString(36).substring(2, 6)
const newNode = {
id: random,
pId: node.id,
value: part._id,
key: part._id,
title: <Badge color={part.color} text={part.name} />,
isLeaf: true
}
newNodeList.push(newNode)
}
setPartsTreeData(partsTreeData.concat(newNodeList))
}
const generatePartCategoryTreeNodes = async (node = null) => {
var filter = {}
var propertyId = 0
if (!node) {
node = {}
node.id = 0
} else {
filter = getFilter(node)
propertyId = node.propertyId + 1
}
const propertyName = propertyOrder[propertyId]
const propertyData = await fetchPartsData(propertyName, filter)
const newNodeList = []
for (var i = 0; i < propertyData.length; i++) {
const property = propertyData[i][propertyName]
const random = Math.random().toString(36).substring(2, 6)
const newNode = {
id: random,
pId: node.id,
value: property + '-' + random,
key: property + '-' + random,
propertyId: propertyId,
title: property,
isLeaf: false,
selectable: false
}
newNodeList.push(newNode)
}
setPartsTreeData(partsTreeData.concat(newNodeList))
}
const handlePartsTreeLoad = async (node) => {
if (node) {
if (node.propertyId !== propertyOrder.length - 1) {
await generatePartCategoryTreeNodes(node)
} else {
await generatePartTreeNodes(node) // End of properties
}
} else {
await generatePartCategoryTreeNodes(null) // First property
}
}
useEffect(() => {
setPartsTreeData([])
}, [token, filter, useFilter])
useEffect(() => {
if (partsTreeData.length === 0) {
if (useFilter === true) {
generatePartTreeNodes({ id: 0 }, filter)
} else {
handlePartsTreeLoad(null)
}
}
}, [partsTreeData])
return ( return (
<TreeSelect <ObjectSelect
treeDataSimpleMode endpoint={`${config.backendUrl}/parts`}
loadData={handlePartsTreeLoad} propertyOrder={propertyOrder}
treeData={partsTreeData} getTitle={(item, isLeaf) =>
isLeaf ? <Badge color={item.color} text={item.name} /> : item
}
getValue={(item, isLeaf) => (isLeaf ? item._id : item)}
getKey={(item, isLeaf) => (isLeaf ? item._id : item)}
filter={filter}
useFilter={useFilter}
onChange={onChange} onChange={onChange}
loading={loading} placeholder='Select Part'
/> />
) )
} }

View File

@ -7,13 +7,13 @@ import {
Collapse, Collapse,
InputNumber, InputNumber,
Descriptions, Descriptions,
Button, Button
Tag
} from 'antd' } from 'antd'
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons' import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import { SocketContext } from '../context/SocketContext' import { SocketContext } from '../context/SocketContext'
import styled from 'styled-components' import styled from 'styled-components'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import BoolDisplay from './BoolDisplay'
const { Text } = Typography const { Text } = Typography
@ -76,7 +76,7 @@ const PrinterPositionPanel = ({
} }
} }
if (!initialized && socket.connected) { if (!initialized && socket?.connected) {
setInitialized(true) setInitialized(true)
socket.on('connect', () => { socket.on('connect', () => {
@ -93,7 +93,7 @@ const PrinterPositionPanel = ({
setExtrudeFactor(positionData.extrude_factor) setExtrudeFactor(positionData.extrude_factor)
return () => { return () => {
if (socket.connected && initialized && shouldUnsubscribe) { if (socket?.connected && initialized && shouldUnsubscribe) {
socket.off('notify_status_update', notifyPositionStatusUpdate) socket.off('notify_status_update', notifyPositionStatusUpdate)
socket.emit('printer.objects.unsubscribe', params) socket.emit('printer.objects.unsubscribe', params)
} }
@ -241,10 +241,13 @@ const PrinterPositionPanel = ({
{positionData.speed.toFixed(2)}mm/s {positionData.speed.toFixed(2)}mm/s
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Absolute Coordinates'> <Descriptions.Item label='Absolute Coordinates'>
{positionData.absolute_coordinates ? ( {positionData ? (
<Tag color='green'>Yes</Tag> <BoolDisplay
value={positionData.absolute_coordinates}
yesNo={true}
/>
) : ( ) : (
<Tag color='red'>No</Tag> <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
@ -256,7 +259,7 @@ const PrinterPositionPanel = ({
size='small' size='small'
items={moreInfoItems} items={moreInfoItems}
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined rotate={isActive ? 90 : 0} /> <CaretLeftOutlined rotate={isActive ? 90 : 0} />
)} )}
/> />
)} )}

View File

@ -1,136 +1,36 @@
// PrinterSelect.js // PrinterSelect.js
import React from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { TreeSelect, message, Tag } from 'antd' import { Tag } from 'antd'
import React, { useEffect, useState, useContext, useCallback } from 'react'
import axios from 'axios'
import PrinterState from './PrinterState'
import { AuthContext } from '../context/AuthContext'
import config from '../../../config' import config from '../../../config'
import ObjectSelect from './ObjectSelect'
import PrinterState from './PrinterState'
const PrinterSelect = ({ onChange, disabled, checkable, value = [] }) => { const PrinterSelect = ({ onChange, disabled }) => {
const [printersTreeData, setPrintersTreeData] = useState([]) // getTitle: if isLeaf, render PrinterState, else render Tag or 'Untagged'
const [printersData, setPrintersData] = useState([]) const getTitle = (item, isLeaf) =>
const [loading, setLoading] = useState(true) isLeaf ? (
const [messageApi] = message.useMessage() <PrinterState printer={item} showProgress={false} showControls={false} />
const [defaultValue, setDefaultValue] = useState(value) ) : item === 'Untagged' ? (
'Untagged'
const { authenticated } = useContext(AuthContext) ) : (
<Tag color='blue'>{item}</Tag>
const fetchPrintersTreeData = useCallback(async () => {
if (!authenticated) {
return
}
setLoading(true)
try {
const response = await axios.get(`${config.backendUrl}/printers`, {
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
})
setLoading(false)
return response.data
} catch (error) {
if (error.response) {
// For other errors, show a message
messageApi.error('Error fetching printers data:', error.response.status)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
) )
}
}
}, [authenticated, messageApi])
const generatePrinterItems = useCallback(async () => { // getValue/getKey: for leaf, use _id; for tag, use tag string
const printerData = await fetchPrintersTreeData() const getValue = (item, isLeaf) => (isLeaf ? item._id : item)
setPrintersData(printerData) const getKey = (item, isLeaf) => (isLeaf ? item._id : item)
// Create a map to store tags and their printers
const tagMap = new Map()
// Add printers to their respective tag groups
printerData.forEach((printer) => {
if (printer.tags && printer.tags.length > 0) {
printer.tags.forEach((tag) => {
if (!tagMap.has(tag)) {
tagMap.set(tag, [])
}
tagMap.get(tag).push(printer)
})
} else {
// If no tags, add to "Untagged" group
if (!tagMap.has('Untagged')) {
tagMap.set('Untagged', [])
}
tagMap.get('Untagged').push(printer)
}
})
// Convert the map to tree data structure
Array.from(tagMap.entries()).map(([tag, printers]) => {
const newNode = {
title: tag === 'Untagged' ? tag : <Tag color='blue'>{tag}</Tag>,
value: `tag-${tag}`,
key: `tag-${tag}`,
children: printers.map((printer) => ({
title: (
<PrinterState
printer={printer}
showProgress={false}
showControls={false}
/>
),
value: printer._id,
key: printer._id
}))
}
setPrintersTreeData((prev) => {
const filtered = prev.filter((node) => node.key !== newNode.key)
return [...filtered, newNode]
})
})
}, [fetchPrintersTreeData])
const handleOnChange = (value, selectedOptions) => {
if (checkable) {
// Multiple selection mode
const newValue = printersData.filter((printer) =>
value.includes(printer._id)
)
setDefaultValue(newValue)
onChange(newValue, selectedOptions)
} else {
// Single selection mode
const selectedPrinter = printersData.find(
(printer) => printer._id === value
)
setDefaultValue(selectedPrinter ? [selectedPrinter] : [])
onChange(selectedPrinter, selectedOptions)
}
}
useEffect(() => {
if (authenticated) {
generatePrinterItems()
}
}, [authenticated, generatePrinterItems])
return ( return (
<TreeSelect <ObjectSelect
treeData={printersTreeData} endpoint={`${config.backendUrl}/printers`}
onChange={handleOnChange} propertyOrder={['tags']}
loading={loading} getTitle={getTitle}
getValue={getValue}
getKey={getKey}
onChange={onChange}
disabled={disabled} disabled={disabled}
treeDefaultExpandAll
treeCheckable={checkable}
treeNodeFilterProp='title'
placeholder='Select Printer' placeholder='Select Printer'
style={{ width: '100%' }}
value={
checkable ? defaultValue.map((item) => item._id) : defaultValue?._id
}
/> />
) )
} }

View File

@ -12,7 +12,7 @@ import {
} from 'antd' } from 'antd'
import React, { useState, useContext, useEffect } from 'react' import React, { useState, useContext, useEffect } from 'react'
import { SocketContext } from '../context/SocketContext' import { SocketContext } from '../context/SocketContext'
import { CaretRightOutlined } from '@ant-design/icons' import { CaretLeftOutlined } from '@ant-design/icons'
import XMarkIcon from '../../Icons/XMarkIcon' import XMarkIcon from '../../Icons/XMarkIcon'
import PauseIcon from '../../Icons/PauseIcon' import PauseIcon from '../../Icons/PauseIcon'
@ -163,7 +163,7 @@ const PrinterState = ({
style={{ fontSize: '12px', marginBottom: '3px' }} style={{ fontSize: '12px', marginBottom: '3px' }}
/> />
) : ( ) : (
<CaretRightOutlined <CaretLeftOutlined
style={{ fontSize: '10px', marginBottom: '3px' }} style={{ fontSize: '10px', marginBottom: '3px' }}
/> />
) )

View File

@ -10,7 +10,7 @@ import {
InputNumber, InputNumber,
Button Button
} from 'antd' } from 'antd'
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons' import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import { SocketContext } from '../context/SocketContext' import { SocketContext } from '../context/SocketContext'
import styled from 'styled-components' import styled from 'styled-components'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
@ -96,7 +96,7 @@ const PrinterTemperaturePanel = ({
heater_bed: null // eslint-disable-line heater_bed: null // eslint-disable-line
} }
} }
if (socket.connected == true) { if (socket?.connected == true) {
console.log('Printer Temperature Panel is subscribing...') console.log('Printer Temperature Panel is subscribing...')
socket.emit('printer.objects.subscribe', params) socket.emit('printer.objects.subscribe', params)
socket.emit('printer.objects.query', params) socket.emit('printer.objects.query', params)
@ -109,13 +109,7 @@ const PrinterTemperaturePanel = ({
socket.emit('printer.objects.unsubscribe', params) socket.emit('printer.objects.unsubscribe', params)
} }
} }
}, [ }, [socket, printerId, notifyTemperatureStatusUpdate, shouldUnsubscribe])
socket,
socket.connected,
printerId,
notifyTemperatureStatusUpdate,
shouldUnsubscribe
])
const handleSetTemperatureClick = (target, value) => { const handleSetTemperatureClick = (target, value) => {
if (socket) { if (socket) {
@ -290,7 +284,7 @@ const PrinterTemperaturePanel = ({
size='small' size='small'
items={moreInfoItems} items={moreInfoItems}
expandIcon={({ isActive }) => ( expandIcon={({ isActive }) => (
<CaretRightOutlined rotate={isActive ? 90 : 0} /> <CaretLeftOutlined rotate={isActive ? 90 : 0} />
)} )}
/> />
)} )}

View File

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

View File

@ -0,0 +1,256 @@
// SpotlightTooltip.js
import PropTypes from 'prop-types'
import {
message,
Descriptions,
Card,
Flex,
Typography,
Skeleton,
Spin,
Badge
} from 'antd'
import { LoadingOutlined, ExportOutlined } from '@ant-design/icons'
import React, { useEffect, useState, useContext, useCallback } from 'react'
import axios from 'axios'
import { AuthContext } from '../context/AuthContext'
import config from '../../../config'
import IdText from './IdText'
import TimeDisplay from './TimeDisplay'
import { Tag } from 'antd'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import PrinterState from './PrinterState'
import JobState from './JobState'
import FilamentStockState from './FilamentStockState'
import SubJobState from './SubJobState'
const { Text, Link } = Typography
const SpotlightTooltip = ({ query, type }) => {
const [spotlightData, setSpotlightData] = useState(null)
const [loading, setLoading] = useState(true)
const [messageApi] = message.useMessage()
const { authenticated } = useContext(AuthContext)
const fetchSpotlightData = useCallback(async () => {
if (!authenticated) {
return
}
setLoading(true)
try {
const response = await axios.get(
`${config.backendUrl}/spotlight/${query}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
}
)
setSpotlightData(response.data)
setLoading(false)
} catch (error) {
setLoading(false)
if (error.response) {
messageApi.error(
`Error fetching spotlight data: ${error.response.status}`
)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
}
}, [authenticated, messageApi, query])
useEffect(() => {
if (authenticated) {
fetchSpotlightData()
}
}, [authenticated, fetchSpotlightData])
if (!spotlightData && !loading) {
return (
<Card className='spotlight-tooltip'>
<Flex
justify='center'
gap={'small'}
style={{ height: '100%', minWidth: '270px' }}
align='center'
>
<Text type='secondary'>
<InfoCircleIcon />
</Text>
<Text type='secondary'>No spotlight data.</Text>
</Flex>
</Card>
)
}
// Helper to render value nicely
const renderValue = (key, value) => {
if (key === '_id' || key === 'id') {
return (
<IdText
id={value}
type={type}
showCopy={true}
longId={false}
showSpotlight={false}
/>
)
}
if (key === 'state') {
if (type === 'printer') {
return (
<PrinterState
printer={spotlightData}
showControls={false}
showPrinterName={false}
/>
)
}
if (type === 'job') {
return (
<JobState job={spotlightData} showId={false} showQuantity={false} />
)
}
if (type === 'subjob') {
return (
<SubJobState
subJob={spotlightData}
showId={false}
showQuantity={false}
/>
)
}
if (type === 'filamentstock') {
return (
<FilamentStockState
filamentStock={spotlightData}
showProgress={false}
/>
)
}
}
if (key === 'tags' && Array.isArray(value)) {
if (value.length == 0) {
return <Text>n/a</Text>
}
return value.map((tag) => (
<Tag key={tag} color='blue'>
{tag}
</Tag>
))
}
if (key === 'email') {
return (
<Link href={`mailto:${value}`}>
{value + ' '}
<ExportOutlined />
</Link>
)
}
if (key === 'color') {
return <Badge color={value} text={value} />
}
if (
typeof value === 'string' &&
value.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}/)
) {
// Format ISO date strings
return (
<TimeDisplay
dateTime={value}
showDate={true}
showTime={true}
showSince={true}
/>
)
}
if (Array.isArray(value)) {
return value.join(', ')
}
if (typeof value === 'object' && value !== null) {
// For nested objects, show JSON string
return JSON.stringify(value)
}
if (value == '' || value.length == 0) {
return <Text>n/a</Text>
}
if (value != null) {
return <Text>{value.toString()}</Text>
}
return <Text>n/a</Text>
}
// Map of property names to user-friendly labels
const LABEL_MAP = {
name: 'Name',
state: 'State',
tags: 'Tags',
email: 'Email',
updatedAt: 'Updated At',
_id: 'ID'
// Add more mappings as needed
}
return (
<div className='spotlight-tooltip'>
<Spin indicator={<LoadingOutlined />} spinning={loading}>
<Descriptions bordered column={1} size='small'>
{loading ? (
<>
<Descriptions.Item
label={
<Skeleton.Input active size='small' style={{ width: 80 }} />
}
>
<Skeleton.Input active size='small' style={{ width: 120 }} />
</Descriptions.Item>
<Descriptions.Item
label={
<Skeleton.Input active size='small' style={{ width: 80 }} />
}
>
<Skeleton.Input active size='small' style={{ width: 120 }} />
</Descriptions.Item>
<Descriptions.Item
label={
<Skeleton.Input active size='small' style={{ width: 80 }} />
}
>
<Skeleton.Input active size='small' style={{ width: 120 }} />
</Descriptions.Item>
</>
) : (
Object.entries(spotlightData).map(([key, value]) =>
value !== undefined && value !== null && value !== '' ? (
<Descriptions.Item
key={key}
label={
LABEL_MAP[key] || key.charAt(0).toUpperCase() + key.slice(1)
}
>
{renderValue(
key,
key === 'state' && value.type ? value.type : value
)}
</Descriptions.Item>
) : null
)
)}
</Descriptions>
</Spin>
</div>
)
}
SpotlightTooltip.propTypes = {
query: PropTypes.string.isRequired,
type: PropTypes.string.isRequired
}
export default SpotlightTooltip

View File

@ -48,6 +48,10 @@ const StockEventTable = ({ stockEvents }) => {
} }
}, [socket, initialized]) }, [socket, initialized])
useEffect(() => {
setStockEventsData(stockEvents)
}, [stockEvents])
const getTypeFilterProps = () => { const getTypeFilterProps = () => {
// Get unique types from the data // Get unique types from the data
const uniqueTypes = [ const uniqueTypes = [

View File

@ -1,6 +1,6 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { Badge, Progress, Flex, Button, Space, Tag, Tooltip } from 'antd' // eslint-disable-line import { Badge, Progress, Flex, Button, Space, Tag, Tooltip } from 'antd' // eslint-disable-line
import { CaretRightOutlined } from '@ant-design/icons' // eslint-disable-line import { CaretLeftOutlined } from '@ant-design/icons' // eslint-disable-line
import React, { useState, useContext, useEffect } from 'react' import React, { useState, useContext, useEffect } from 'react'
import { SocketContext } from '../context/SocketContext' import { SocketContext } from '../context/SocketContext'
import IdText from './IdText' import IdText from './IdText'
@ -84,11 +84,7 @@ const SubJobState = ({
return ( return (
<Flex gap='small' align={'center'}> <Flex gap='small' align={'center'}>
{showId && ( {showId && (
<IdText <IdText id={subJob._id} showCopy={false} type='subjob' longId={false} />
id={subJob.number.toString().padStart(6, '0')}
showCopy={false}
type='subjob'
/>
)} )}
{showStatus && ( {showStatus && (
<Space> <Space>
@ -136,7 +132,7 @@ const SubJobState = ({
style={{ fontSize: '12px', marginBottom: '3px' }} style={{ fontSize: '12px', marginBottom: '3px' }}
/> />
) : ( ) : (
<CaretRightOutlined <CaretLeftOutlined
style={{ fontSize: '10px', marginBottom: '3px' }} style={{ fontSize: '10px', marginBottom: '3px' }}
/> />
) )

View File

@ -58,7 +58,7 @@ const SubJobsTree = ({ jobData, loading }) => {
title: ( title: (
<Space> <Space>
<SubJobIcon /> <SubJobIcon />
{'Sub Job'} {'Sub Job #' + subJob?.number.toString().padStart(2, '0')}
<SubJobState subJob={subJob} showProgress={true} /> <SubJobState subJob={subJob} showProgress={true} />
</Space> </Space>
), ),

View File

@ -40,7 +40,8 @@ const TimeDisplay = ({
dateTime, dateTime,
showDate = true, showDate = true,
showTime = true, showTime = true,
showSince = false showSince = false,
type = 'primary'
}) => { }) => {
const [timeAgo, setTimeAgo] = useState(formatTimeDifference(dateTime)) const [timeAgo, setTimeAgo] = useState(formatTimeDifference(dateTime))
@ -66,8 +67,8 @@ const TimeDisplay = ({
return ( return (
<Flex align={'center'} gap={'small'}> <Flex align={'center'} gap={'small'}>
<Text>{formattedDate}</Text> <Text type={type}>{formattedDate}</Text>
{showSince ? <Tag>{timeAgo + ' ago'}</Tag> : null} {showSince ? <Tag style={{ margin: 0 }}>{timeAgo + ' ago'}</Tag> : null}
</Flex> </Flex>
) )
} }
@ -76,7 +77,8 @@ TimeDisplay.propTypes = {
dateTime: PropTypes.string, dateTime: PropTypes.string,
showDate: PropTypes.bool, showDate: PropTypes.bool,
showTime: PropTypes.bool, showTime: PropTypes.bool,
showSince: PropTypes.bool showSince: PropTypes.bool,
type: PropTypes.string
} }
export default TimeDisplay export default TimeDisplay

View File

@ -158,6 +158,7 @@ const VendorSelect = ({ onChange, filter = {}, useFilter = false, value }) => {
}, [vendorsTreeData]) }, [vendorsTreeData])
useEffect(() => { useEffect(() => {
console.log('value', value)
if (value?.name) { if (value?.name) {
setDefaultValue(value.name) setDefaultValue(value.name)
} }

View File

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

View File

@ -0,0 +1,212 @@
// src/contexts/NotificationContext.js
import React, {
createContext,
useEffect,
useState,
useContext,
useRef
} from 'react'
import io from 'socket.io-client'
import { message, notification, Drawer } from 'antd'
import PropTypes from 'prop-types'
import { AuthContext } from './AuthContext'
import config from '../../../config'
import NotificationCenter from '../common/NotificationCenter'
const NotificationContext = createContext()
const NotificationProvider = ({ children }) => {
const { token } = useContext(AuthContext)
const socketRef = useRef(null)
const [connecting, setConnecting] = useState(false)
const [error, setError] = useState(null)
const [notificationVisible, setNotificationVisible] = useState(false)
const [notifications, setNotifications] = useState([])
const [messageApi, contextHolder] = message.useMessage()
const [notificationApi] = notification.useNotification()
useEffect(() => {
if (token) {
console.log(
'Token is available, connecting to notification web socket server...'
)
const newSocket = io(
config.notificationWsUrl || `${config.wsUrl}/notifications`,
{
reconnectionAttempts: 3,
timeout: 3000,
auth: { token: token }
}
)
setConnecting(true)
newSocket.on('connect', () => {
console.log('Notification socket connected')
setConnecting(false)
setError(null)
})
newSocket.on('disconnect', () => {
console.log('Notification socket disconnected')
setError('Notification socket disconnected')
})
newSocket.on('connect_error', (err) => {
console.error('Notification socket connection error:', err)
messageApi.error('Notification socket connection error: ' + err.message)
setError('Notification socket connection error')
})
newSocket.on('notification.new', (data) => {
console.log('New notification received:', data)
// Add new notification to state
setNotifications((prev) => [data, ...prev])
// Show toast notification
notificationApi.info({
message: data.title || 'New Notification',
description: data.message,
placement: 'topRight',
duration: 4.5
})
})
newSocket.on('notification.update', (data) => {
console.log('Notification updated:', data)
setNotifications((prev) =>
prev.map((notification) => {
if (notification._id === data._id) {
return { ...notification, ...data }
}
return notification
})
)
})
newSocket.on('notification.delete', (data) => {
console.log('Notification deleted:', data)
setNotifications((prev) =>
prev.filter((notification) => notification._id !== data._id)
)
})
newSocket.on('error', (err) => {
console.error('Notification socket error:', err)
setError('Notification socket error')
})
socketRef.current = newSocket
// Clean up function
return () => {
if (socketRef.current) {
console.log('Cleaning up notification socket connection...')
socketRef.current.disconnect()
socketRef.current = null
}
}
} else if (!token && socketRef.current) {
console.log('Token not available, disconnecting notification socket...')
socketRef.current.disconnect()
socketRef.current = null
}
}, [token, messageApi, notificationApi])
const showNotificationCenter = () => {
setNotificationVisible(true)
}
const hideNotificationCenter = () => {
setNotificationVisible(false)
}
const toggleNotificationCenter = () => {
setNotificationVisible((prev) => !prev)
}
const updateNotifications = (newNotifications) => {
setNotifications(newNotifications)
}
const addNotification = (notification) => {
setNotifications((prev) => [notification, ...prev])
}
const removeNotification = (notificationId) => {
setNotifications((prev) =>
prev.filter((notification) => notification._id !== notificationId)
)
}
const markNotificationAsRead = (notificationId) => {
setNotifications((prev) =>
prev.map((notification) => {
if (notification._id === notificationId) {
return { ...notification, read: true }
}
return notification
})
)
}
const markAllNotificationsAsRead = () => {
setNotifications((prev) =>
prev.map((notification) => ({ ...notification, read: true }))
)
}
const clearAllNotifications = () => {
setNotifications([])
}
const getUnreadCount = () => {
return notifications.filter((notification) => !notification.read).length
}
return (
<NotificationContext.Provider
value={{
socket: socketRef.current,
error,
connecting,
notifications,
notificationVisible,
unreadCount: getUnreadCount(),
showNotificationCenter,
hideNotificationCenter,
toggleNotificationCenter,
updateNotifications,
addNotification,
removeNotification,
markNotificationAsRead,
markAllNotificationsAsRead,
clearAllNotifications
}}
>
{contextHolder}
{children}
{/* Notification Drawer */}
<Drawer
title='Notifications'
placement='right'
width={400}
onClose={hideNotificationCenter}
open={notificationVisible}
style={{
zIndex: 1001
}}
>
<NotificationCenter visible={notificationVisible} />
</Drawer>
</NotificationContext.Provider>
)
}
NotificationProvider.propTypes = {
children: PropTypes.node.isRequired
}
export { NotificationContext, NotificationProvider }

View File

@ -1,26 +1,40 @@
import { Input, Flex, List, Typography, Modal, Spin, message, Form } from 'antd' import {
Input,
Flex,
List,
Typography,
Modal,
Spin,
message,
Form,
Button
} from 'antd'
import React, { createContext, useEffect, useState, useRef } from 'react' import React, { createContext, useEffect, useState, useRef } from 'react'
import axios from 'axios' import axios from 'axios'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useNavigate } from 'react-router-dom'
import PrinterState from '../common/PrinterState' import PrinterState from '../common/PrinterState'
import JobState from '../common/JobState' import JobState from '../common/JobState'
import IdText from '../common/IdText' import IdText from '../common/IdText'
import config from '../../../config' import config from '../../../config'
import JobIcon from '../../Icons/JobIcon' import { getTypeMeta, getPrefixMeta } from '../utils/Utils'
import PrinterIcon from '../../Icons/PrinterIcon' import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import FilamentStockState from '../common/FilamentStockState'
import SubJobState from '../common/SubJobState'
const SpotlightContext = createContext() const SpotlightContext = createContext()
const SpotlightProvider = ({ children }) => { const SpotlightProvider = ({ children }) => {
const { Text } = Typography const { Text } = Typography
const navigate = useNavigate()
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [listData, setListData] = useState([]) const [listData, setListData] = useState([])
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const [inputPrefix, setInputPrefix] = useState('') const [inputPrefix, setInputPrefix] = useState({ prefix: '', mode: null })
// Refs for throttling/debouncing // Refs for throttling/debouncing
const lastFetchTime = useRef(0) const lastFetchTime = useRef(0)
@ -35,15 +49,55 @@ const SpotlightProvider = ({ children }) => {
// Set prefix based on default query if provided // Set prefix based on default query if provided
if (defaultQuery) { if (defaultQuery) {
detectAndSetPrefix(defaultQuery) // Check if the default query contains a prefix
const upperQuery = defaultQuery.toUpperCase()
const prefixInfo = parsePrefix(upperQuery)
if (prefixInfo) {
setInputPrefix(prefixInfo)
// Set the query to only the part after the prefix and mode character
const remainingValue = defaultQuery.substring(
prefixInfo.prefix.length + 1
)
setQuery(remainingValue)
checkAndFetchData(defaultQuery) checkAndFetchData(defaultQuery)
} else { } else {
setInputPrefix('') setInputPrefix(null)
checkAndFetchData(defaultQuery)
}
} else {
setInputPrefix(null)
// Only clear data if we're opening with an empty query and no existing data
if (listData.length === 0) {
setListData([])
}
} }
// Focus will be handled in useEffect for proper timing after modal renders // Focus will be handled in useEffect for proper timing after modal renders
} }
// Helper function to parse prefix and mode from query
const parsePrefix = (query) => {
// Check for prefix format: XXX: or XXX? or XXX^
if (query.length >= 4) {
const potentialPrefix = query.substring(0, 3)
const modeChar = query[3]
// Check if it's a valid mode character
if ([':', '?', '^'].includes(modeChar)) {
const prefixMeta = getPrefixMeta(potentialPrefix)
if (prefixMeta.prefix === potentialPrefix) {
return {
...prefixMeta,
mode: modeChar
}
}
}
}
return null
}
const fetchData = async (searchQuery) => { const fetchData = async (searchQuery) => {
if (!searchQuery || !searchQuery.trim()) return if (!searchQuery || !searchQuery.trim()) return
@ -55,7 +109,29 @@ const SpotlightProvider = ({ children }) => {
setLoading(true) setLoading(true)
setListData([]) setListData([])
const response = await axios.get(
let response
// Check if we have a prefix with ? mode (filter mode)
if (inputPrefix && inputPrefix.mode === '?') {
// For filter mode, parse the searchQuery which is in format like "VEN?name=Tom"
const queryParts = searchQuery.split('?')
const prefix = queryParts[0]
const queryParams = queryParts[1] || ''
// Parse the query parameters
const params = new URLSearchParams(queryParams)
response = await axios.get(`${config.backendUrl}/spotlight/${prefix}`, {
params: params,
headers: {
Accept: 'application/json'
},
withCredentials: true
})
} else {
// For other modes (:, ^), use the original behavior
response = await axios.get(
`${config.backendUrl}/spotlight/${encodeURIComponent(searchQuery.trim())}`, `${config.backendUrl}/spotlight/${encodeURIComponent(searchQuery.trim())}`,
{ {
headers: { headers: {
@ -64,8 +140,20 @@ const SpotlightProvider = ({ children }) => {
withCredentials: true withCredentials: true
} }
) )
}
setLoading(false) setLoading(false)
// If the query contains a prefix mode character, and the response is an object, wrap it in an array
if (
/[:?^]/.test(searchQuery) &&
response.data &&
!Array.isArray(response.data) &&
typeof response.data === 'object'
) {
setListData([response.data])
} else {
setListData(response.data) setListData(response.data)
}
// Check if there's a pending query after this fetch completes // Check if there's a pending query after this fetch completes
if (pendingQuery.current !== null) { if (pendingQuery.current !== null) {
@ -121,44 +209,18 @@ const SpotlightProvider = ({ children }) => {
}, delay) }, delay)
} }
// Detect and set the appropriate prefix based on input
const detectAndSetPrefix = (text) => {
if (!text || text.trim() === '') {
setInputPrefix('')
return
}
console.log('Detecting prefix')
const upperText = text.toUpperCase()
if (upperText.startsWith('JOB:')) {
setInputPrefix('JOB:')
return true
} else if (upperText.startsWith('PRN:')) {
setInputPrefix('PRN:')
return true
} else if (upperText.startsWith('FIL:')) {
setInputPrefix('FIL')
return true
} else if (upperText.startsWith('GCF:')) {
setInputPrefix('GCF:')
return true
}
// Default behavior if no match
setInputPrefix('')
return false
}
const handleSpotlightChange = (formData) => { const handleSpotlightChange = (formData) => {
const newQuery = formData.query || '' const newQuery = formData.query || ''
setQuery(newQuery) setQuery(newQuery)
// Detect and set the appropriate prefix // Build the full search query with prefix if available
detectAndSetPrefix(inputPrefix + newQuery) let fullQuery = newQuery
if (inputPrefix) {
fullQuery = inputPrefix.prefix + inputPrefix.mode + newQuery
}
// Check if we need to fetch data // Check if we need to fetch data
checkAndFetchData(inputPrefix + newQuery) checkAndFetchData(fullQuery)
} }
// Focus the input element // Focus the input element
@ -181,46 +243,105 @@ const SpotlightProvider = ({ children }) => {
if (!value || value.trim() === '') { if (!value || value.trim() === '') {
// Only clear the prefix if the input is completely empty // Only clear the prefix if the input is completely empty
if (value === '') { if (value === '') {
console.log('Clearning prefix') console.log('Clearing prefix')
setInputPrefix('') setInputPrefix(null)
} }
if (formRef.current) { if (formRef.current) {
formRef.current.setFieldsValue({ query: value }) formRef.current.setFieldsValue({ query: value })
} }
return
} }
// If the user is typing and it doesn't have a prefix yet
else if (!inputPrefix) { // Check if the input contains a prefix (format: XXX:, XXX?, or XXX^)
console.log('No prefix')
// Check for prefixes at the beginning of the input
const upperValue = value.toUpperCase() const upperValue = value.toUpperCase()
const prefixInfo = parsePrefix(upperValue)
if (upperValue.startsWith('JOB:') || upperValue.startsWith('PRN:')) { // If it's a valid prefix
const parts = upperValue.split(':') if (prefixInfo) {
const prefix = parts[0] + ':' setInputPrefix(prefixInfo)
const restOfInput = value.substring(prefix.length) // Remove the prefix from the input value, keeping only what comes after the mode character
const remainingValue = value.substring(4)
// Set the prefix and update the input without the prefix
setInputPrefix(prefix)
if (formRef.current) { if (formRef.current) {
formRef.current.setFieldsValue({ query: restOfInput }) formRef.current.setFieldsValue({ query: remainingValue })
// Ensure input gets focus after prefix is set
focusInput()
} }
setQuery(remainingValue)
return
}
// Update the form value normally
if (formRef.current) {
formRef.current.setFieldsValue({ query: value })
}
}
// Handle key down events for backspace behavior and navigation
const handleKeyDown = (e) => {
// If backspace is pressed and there's a prefix but the input is empty
if (e.key === 'Backspace' && inputPrefix && query === '') {
console.log('Clearing prefix on backspace')
// Clear the prefix
setInputPrefix(null)
// Prevent the default backspace behavior in this case
e.preventDefault()
return
}
// Handle navigation shortcuts
if (listData.length > 0) {
// Enter key - navigate to first item
if (e.key === 'Enter') {
e.preventDefault()
navigateToItem(listData[0])
return
}
// Number keys 0-9 - navigate to corresponding item
if (/^[0-9]$/.test(e.key)) {
e.preventDefault()
const index = parseInt(e.key)
// 0-9 keys map to items 1-9 (indices 0-8)
const itemIndex = index + 1
if (itemIndex < listData.length) {
navigateToItem(listData[itemIndex])
}
return return
} }
} }
} }
// Handle key down events for backspace behavior // Function to navigate to item URL
const handleKeyDown = (e) => { const navigateToItem = (item) => {
// If backspace is pressed and there's a prefix but the input is empty // Determine type for meta lookup
let type = item.type || inputPrefix?.type
// Fallback: try to infer type from known keys
if (!type) {
if (item.printer) type = 'printer'
else if (item.job) type = 'job'
// Add more inference as needed
}
if (e.key === 'Backspace' && inputPrefix && query == inputPrefix) { const meta = getTypeMeta(type)
console.log('Query', query)
// Clear the prefix // Get the appropriate ID for the item
setInputPrefix('') let itemId = item._id || item.id
// Prevent the default backspace behavior in this case
e.preventDefault() // For printers, use the printer's _id
if (type === 'printer' && item._id) {
itemId = item._id
}
// For jobs, use the job's id
if (type === 'job' && item.job && item.job.id) {
itemId = item.job.id
}
if (itemId && meta.url) {
const url = meta.url(itemId)
if (url && url !== '#') {
navigate(url)
setShowModal(false)
}
} }
} }
@ -259,10 +380,21 @@ const SpotlightProvider = ({ children }) => {
// Focus input when inputPrefix changes // Focus input when inputPrefix changes
useEffect(() => { useEffect(() => {
if (showModal) { if (showModal) {
// Only clear data if there's no existing data and no current query
if (listData.length === 0 && !query) {
setListData([])
}
focusInput() focusInput()
} }
}, [inputPrefix, showModal]) }, [inputPrefix, showModal])
// Update form value when query changes
useEffect(() => {
if (showModal && formRef.current) {
formRef.current.setFieldsValue({ query: query })
}
}, [query, showModal])
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -272,6 +404,20 @@ const SpotlightProvider = ({ children }) => {
} }
}, []) }, [])
// Helper function to get mode description
const getModeDescription = (mode) => {
switch (mode) {
case ':':
return 'ID lookup'
case '?':
return 'Filter'
case '^':
return 'Search'
default:
return ''
}
}
return ( return (
<SpotlightContext.Provider value={{ showSpotlight }}> <SpotlightContext.Provider value={{ showSpotlight }}>
{contextHolder} {contextHolder}
@ -280,22 +426,39 @@ const SpotlightProvider = ({ children }) => {
onCancel={() => setShowModal(false)} onCancel={() => setShowModal(false)}
closeIcon={null} closeIcon={null}
footer={null} footer={null}
styles={{ content: { backgroundColor: 'transparent' } }} styles={{ content: { padding: 0 } }}
destroyOnHidden={true}
> >
<Flex vertical> <Flex vertical>
<Form ref={formRef} onValuesChange={handleSpotlightChange}> <Form ref={formRef} onValuesChange={handleSpotlightChange}>
<Form.Item name='query' initialValue={query}> <Form.Item name='query' initialValue={query} style={{ margin: 0 }}>
<Input <Input
ref={inputRef} ref={inputRef}
placeholder='Enter a query or scan a barcode...' placeholder='Enter a query or scan a barcode...'
size='large' size='large'
addonBefore={inputPrefix || undefined} addonBefore={
inputPrefix ? (
<Flex align='center' gap='small'>
<Text style={{ fontSize: 20 }}>{inputPrefix.prefix}</Text>
<Text style={{ fontSize: 20 }} type='secondary'>
{inputPrefix.mode}
</Text>
</Flex>
) : undefined
}
suffix={ suffix={
<Flex align='center' gap='small'>
{inputPrefix?.mode && (
<Text type='secondary' style={{ fontSize: '12px' }}>
{getModeDescription(inputPrefix.mode)}
</Text>
)}
<Spin <Spin
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
spinning={loading} spinning={loading}
size='small' size='small'
/> />
</Flex>
} }
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@ -304,63 +467,100 @@ const SpotlightProvider = ({ children }) => {
</Form> </Form>
{listData.length > 0 && ( {listData.length > 0 && (
<div style={{ marginLeft: '18px', marginRight: '14px' }}>
<List <List
bordered
dataSource={listData} dataSource={listData}
renderItem={(item) => ( renderItem={(item, index) => {
// Determine type for meta lookup
let type = item.type || inputPrefix?.type
// Fallback: try to infer type from known keys
if (!type) {
if (item.printer) type = 'printer'
else if (item.job) type = 'job'
// Add more inference as needed
}
const meta = getTypeMeta(type)
console.log('meta', inputPrefix?.type)
const Icon = meta.icon
// Determine shortcut text
let shortcutText = ''
if (index === 0) {
shortcutText = 'ENTER'
} else if (index <= 10) {
shortcutText = (index - 1).toString()
}
return (
<List.Item> <List.Item>
<List.Item.Meta <List.Item.Meta
description={ description={
<Flex gap={'middle'} align='center'> <Flex gap={'middle'} align='center'>
<Text> <Text>
{item.printer ? ( {Icon ? (
<PrinterIcon style={{ fontSize: '20px' }} /> <Icon style={{ fontSize: '20px' }} />
) : null}
{item.job ? (
<JobIcon style={{ fontSize: '20px' }} />
) : null} ) : null}
</Text> </Text>
<Flex <Flex gap={'small'} style={{ marginBottom: '2px' }}>
vertical {item.name ? <Text>{item.name}</Text> : null}
gap={'6px'}
style={{ marginBottom: '2px' }}
>
<Text>{item.name}</Text>
{item.printer ? ( {meta.type == 'printer' ? (
<Flex gap={'small'}>
<PrinterState <PrinterState
printer={item.printer} printer={item}
showPrinterName={false} showPrinterName={false}
showProgress={false}
showId={false}
/> />
<IdText
id={item.id}
longId={false}
type='printer'
/>
</Flex>
) : null} ) : null}
{item.job ? ( {meta.type == 'job' ? (
<Flex gap={'small'}>
{item.job.state.type ? (
<JobState <JobState
job={item.job} job={item}
showQuantity={false} showQuantity={false}
showProgress={false}
showId={false} showId={false}
/> />
) : null} ) : null}
<IdText id={item.id} longId={false} type='job' /> {meta.type == 'subjob' ? (
<SubJobState
subJob={item}
showProgress={false}
showId={false}
/>
) : null}
{meta.type == 'filamentstock' ? (
<Flex gap={'small'}>
<FilamentStockState
filamentStock={item}
showId={false}
showProgress={false}
/>
</Flex> </Flex>
) : null} ) : null}
<IdText
id={item._id}
type={meta.type}
longId={false}
/>
</Flex> </Flex>
</Flex> </Flex>
} }
/> />
<Text keyboard>ENTER</Text> <Flex gap={'small'}>
{shortcutText && <Text keyboard>{shortcutText}</Text>}
<Button
icon={<InfoCircleIcon />}
size={'small'}
type={'text'}
onClick={() => navigateToItem(item)}
/>
</Flex>
</List.Item> </List.Item>
)} )
}}
></List> ></List>
</div>
)} )}
</Flex> </Flex>
</Modal> </Modal>

View File

@ -0,0 +1,18 @@
import { useState, useEffect } from 'react'
const useViewMode = (componentName, defaultMode = 'cards') => {
const getInitialViewMode = () => {
const stored = sessionStorage.getItem(`${componentName}_viewMode`)
return stored ? stored : defaultMode
}
const [viewMode, setViewMode] = useState(getInitialViewMode)
useEffect(() => {
sessionStorage.setItem(`${componentName}_viewMode`, viewMode)
}, [viewMode, componentName])
return [viewMode, setViewMode]
}
export default useViewMode

View File

@ -1,3 +1,22 @@
import PrinterIcon from '../../Icons/PrinterIcon'
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'
import NoteIcon from '../../Icons/NoteIcon'
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
export function capitalizeFirstLetter(string) { export function capitalizeFirstLetter(string) {
try { try {
return string[0].toUpperCase() + string.slice(1) return string[0].toUpperCase() + string.slice(1)
@ -29,3 +48,162 @@ export function timeStringToMinutes(timeString) {
// Return the integer value of total minutes // Return the integer value of total minutes
return Math.floor(totalMinutes) return Math.floor(totalMinutes)
} }
export const TYPE_META = [
{
type: 'printer',
title: 'Printer',
prefix: 'PRN',
icon: PrinterIcon,
url: (id) => `/dashboard/production/printers/info?printerId=${id}`
},
{
type: 'filament',
title: 'Filament',
prefix: 'FIL',
icon: FilamentIcon,
url: (id) => `/dashboard/management/filaments/info?filamentId=${id}`
},
{
type: 'spool',
title: 'Spool',
prefix: 'SPL',
icon: FilamentIcon,
url: (id) => `/dashboard/inventory/spool/info?spoolId=${id}`
},
{
type: 'gcodefile',
title: 'GCode File',
prefix: 'GCF',
icon: GCodeFileIcon,
url: (id) => `/dashboard/production/gcodefiles/info?gcodeFileId=${id}`
},
{
type: 'job',
title: 'Job',
prefix: 'JOB',
icon: JobIcon,
url: (id) => `/dashboard/production/jobs/info?jobId=${id}`
},
{
type: 'part',
title: 'Part',
prefix: 'PRT',
icon: PartIcon,
url: (id) => `/dashboard/management/parts/info?partId=${id}`
},
{
type: 'product',
title: 'Product',
prefix: 'PRD',
icon: ProductIcon,
url: (id) => `/dashboard/management/products/info?productId=${id}`
},
{
type: 'vendor',
title: 'Vendor',
prefix: 'VEN',
icon: VendorIcon,
url: (id) => `/dashboard/management/vendors/info?vendorId=${id}`
},
{
type: 'subjob',
title: 'Sub Job',
prefix: 'SJB',
icon: SubJobIcon,
url: () => `#`
},
{
type: 'initial',
title: 'Initial',
prefix: 'INT',
icon: QuestionCircleIcon,
url: () => `#`
},
{
type: 'filamentstock',
title: 'Filament Stock',
prefix: 'FLS',
icon: FilamentStockIcon,
url: (id) =>
`/dashboard/inventory/filamentstocks/info?filamentStockId=${id}`
},
{
type: 'stockevent',
title: 'Stock Event',
prefix: 'SEV',
icon: StockEventIcon,
url: () => `#`
},
{
type: 'stockaudit',
title: 'Stock Audit',
prefix: 'SAU',
icon: StockAuditIcon,
url: (id) => `/dashboard/inventory/stockaudits/info?stockAuditId=${id}`
},
{
type: 'partstock',
title: 'Part Stock',
prefix: 'PTS',
icon: PartStockIcon,
url: (id) => `/dashboard/management/partstocks/info?partStockId=${id}`
},
{
type: 'productstock',
title: 'Product Stock',
prefix: 'PDS',
icon: ProductStockIcon,
url: (id) => `/dashboard/management/productstocks/info?productStockId=${id}`
},
{
type: 'auditlog',
title: 'Audit Log',
prefix: 'ADL',
icon: AuditLogIcon,
url: () => `#`
},
{
type: 'user',
title: 'User',
prefix: 'USR',
icon: PersonIcon,
url: (id) => `/dashboard/management/users/info?userId=${id}`
},
{
type: 'notetype',
title: 'Note Type',
prefix: 'NTY',
icon: NoteTypeIcon,
url: (id) => `/dashboard/management/notetypes/info?noteTypeId=${id}`
},
{
type: 'note',
title: 'Note',
prefix: 'NTE',
icon: NoteIcon,
url: () => `#`
}
]
export function getTypeMeta(type) {
return (
TYPE_META.find((meta) => meta.type === type) || {
type: 'unknown',
prefix: 'UNK',
icon: QuestionCircleIcon,
url: () => '#'
}
)
}
export function getPrefixMeta(prefix) {
return (
TYPE_META.find((meta) => meta.prefix === prefix) || {
type: 'unknown',
prefix: 'UNK',
icon: QuestionCircleIcon,
url: () => '#'
}
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,3 +5,7 @@ body {
.ant-modal-mask { .ant-modal-mask {
backdrop-filter: blur(3px); backdrop-filter: blur(3px);
} }
.ant-spin-blur {
filter: blur(3px);
}