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,133 +73,164 @@ const AppContent = () => {
<ConfigProvider theme={themeConfig}> <ConfigProvider theme={themeConfig}>
<App> <App>
<AuthProvider> <AuthProvider>
<SocketProvider> <Router>
<SpotlightProvider> <SocketProvider>
<Router> <NotificationProvider>
<Routes> <SpotlightProvider>
<Route <Routes>
path='/' <Route
element={ path='/'
<PrivateRoute element={
component={() => ( <PrivateRoute
<Navigate component={() => (
to='/dashboard/production/overview' <Navigate
replace to='/dashboard/production/overview'
/> replace
)} />
)}
/>
}
/>
<Route
path='/dashboard'
element={<PrivateRoute component={() => <Dashboard />} />}
>
{/* Production Routes */}
<Route
path='production/overview'
element={<ProductionOverview />}
/> />
} <Route
/> path='production/printers'
<Route element={<Printers />}
path='/dashboard'
element={<PrivateRoute component={() => <Dashboard />} />}
>
{/* Production Routes */}
<Route
path='production/overview'
element={<ProductionOverview />}
/>
<Route path='production/printers' element={<Printers />} />
<Route
path='production/printers/control'
element={<ControlPrinter />}
/>
<Route
path='production/printers/info'
element={<PrinterInfo />}
/>
<Route path='production/jobs' element={<Jobs />} />
<Route path='production/jobs/info' element={<JobInfo />} />
<Route
path='production/gcodefiles'
element={<GCodeFiles />}
/>
<Route
path='production/gcodefiles/info'
element={<GCodeFileInfo />}
/>
{/* Inventory Routes */}
<Route
path='inventory/filamentstocks'
element={<FilamentStocks />}
/>
<Route
path='inventory/filamentstocks/info'
element={<FilamentStockInfo />}
/>
<Route
path='inventory/partstocks'
element={<PartStocks />}
/>
<Route
path='inventory/stockevents'
element={<StockEvents />}
/>
<Route
path='inventory/stockaudits'
element={<StockAudits />}
/>
<Route
path='inventory/stockaudits/info'
element={<StockAuditInfo />}
/>
{/* Management Routes */}
<Route
path='management/filaments'
element={<Filaments />}
/>
<Route
path='management/filaments/info'
element={<FilamentInfo />}
/>
<Route path='management/parts' element={<Parts />} />
<Route
path='management/parts/info'
element={<PartInfo />}
/>
<Route path='management/products' element={<Products />} />
<Route
path='management/products/info'
element={<ProductInfo />}
/>
<Route path='management/vendors' element={<Vendors />} />
<Route
path='management/vendors/info'
element={<VendorInfo />}
/>
<Route
path='management/materials'
element={<Materials />}
/>
<Route
path='management/notetypes'
element={<NoteTypes />}
/>
<Route
path='management/notetypes/info'
element={<NoteTypeInfo />}
/>
<Route path='management/settings' element={<Settings />} />
<Route
path='management/auditlogs'
element={<AuditLogs />}
/>
</Route>
<Route
path='*'
element={
<AppError
message='Error 404. Page not found.'
showRefresh={false}
/> />
} <Route
/> path='production/printers/control'
</Routes> element={<ControlPrinter />}
</Router> />
</SpotlightProvider> <Route
</SocketProvider> path='production/printers/info'
element={<PrinterInfo />}
/>
<Route path='production/jobs' element={<Jobs />} />
<Route
path='production/jobs/info'
element={<JobInfo />}
/>
<Route
path='production/gcodefiles'
element={<GCodeFiles />}
/>
<Route
path='production/gcodefiles/info'
element={<GCodeFileInfo />}
/>
{/* Inventory Routes */}
<Route
path='inventory/filamentstocks'
element={<FilamentStocks />}
/>
<Route
path='inventory/filamentstocks/info'
element={<FilamentStockInfo />}
/>
<Route
path='inventory/partstocks'
element={<PartStocks />}
/>
<Route
path='inventory/stockevents'
element={<StockEvents />}
/>
<Route
path='inventory/stockaudits'
element={<StockAudits />}
/>
<Route
path='inventory/stockaudits/info'
element={<StockAuditInfo />}
/>
{/* Management Routes */}
<Route
path='management/filaments'
element={<Filaments />}
/>
<Route
path='management/filaments/info'
element={<FilamentInfo />}
/>
<Route path='management/parts' element={<Parts />} />
<Route
path='management/parts/info'
element={<PartInfo />}
/>
<Route
path='management/products'
element={<Products />}
/>
<Route
path='management/products/info'
element={<ProductInfo />}
/>
<Route path='management/vendors' element={<Vendors />} />
<Route
path='management/users/info'
element={<UserInfo />}
/>
<Route
path='management/vendors/info'
element={<VendorInfo />}
/>
<Route
path='management/materials'
element={<Materials />}
/>
<Route
path='management/notetypes'
element={<NoteTypes />}
/>
<Route path='management/users' element={<Users />} />
<Route
path='management/notetypes/info'
element={<NoteTypeInfo />}
/>
<Route
path='management/settings'
element={<Settings />}
/>
<Route
path='management/auditlogs'
element={<AuditLogs />}
/>
<Route
path='developer/sessionstorage'
element={<SessionStorage />}
/>
<Route
path='developer/authcontextdebug'
element={<AuthContextDebug />}
/>
<Route
path='developer/socketcontextdebug'
element={<SocketContextDebug />}
/>
</Route>
<Route
path='*'
element={
<AppError
message='Error 404. Page not found.'
showRefresh={false}
/>
}
/>
</Routes>
</SpotlightProvider>
</NotificationProvider>
</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 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;"/>
</g>
</g> </g>
<path d="M10.65,56.399C10.65,54.429 9.1,52.819 7.081,52.819C5.148,52.819 3.561,54.45 3.561,56.399C3.561,58.34 5.148,59.949 7.081,59.949C9.057,59.949 10.65,58.34 10.65,56.399ZM31.147,53.788L17.05,53.788C15.433,53.788 14.226,54.904 14.226,56.399C14.226,57.888 15.433,59.035 17.05,59.035L35.425,59.035C33.338,57.368 31.955,55.709 31.147,53.788ZM10.65,44.582C10.65,42.617 9.1,41.032 7.081,41.032C5.148,41.032 3.561,42.633 3.561,44.582C3.561,46.523 5.148,48.107 7.081,48.107C9.057,48.107 10.65,46.523 10.65,44.582ZM30.242,41.97L17.05,41.97C15.433,41.97 14.226,43.087 14.226,44.582C14.226,46.076 15.433,47.218 17.05,47.218L30.242,47.218L30.242,41.97ZM30.242,35.341L11.704,35.341C5.368,35.341 3,32.505 3,26.425L3,12.967C3,6.882 5.368,4.051 11.704,4.051L52.296,4.051C58.632,4.051 61,6.882 61,12.967L61,26.425C61,26.988 60.98,27.522 60.938,28.03C59.436,27.47 57.18,26.685 55.137,25.987L55.137,12.807C55.137,10.413 54.318,9.337 51.948,9.337L12.052,9.337C9.682,9.337 8.863,10.413 8.863,12.807L8.863,26.585C8.863,28.98 9.682,30.055 12.052,30.055L31.321,30.055C30.566,30.894 30.242,31.995 30.242,33.666L30.242,35.341Z" style="fill-rule:nonzero;"/>
</svg> </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,161 +177,295 @@ const FilamentStockInfo = () => {
} }
return ( return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}> <>
{contextHolder} {contextHolder}
<Flex vertical gap={'large'}> <Flex
<Collapse gap='large'
ghost vertical='true'
collapsible='icon' style={{ height: '100%', minHeight: 0 }}
activeKey={collapseState.info ? ['1'] : []} >
onChange={(keys) => updateCollapseState('info', keys.length > 0)} <Flex justify={'space-between'}>
expandIcon={({ isActive }) => ( <Space size='small'>
<CaretRightOutlined <Dropdown menu={actionItems}>
rotate={isActive ? 90 : 0} <Button>Actions</Button>
style={{ paddingTop: '2px' }} </Dropdown>
/> <Popover
)} content={getViewDropdownItems()}
className='no-h-padding-collapse no-t-padding-collapse' placement='bottomLeft'
> arrow={false}
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Filament Stock Information
</Title>
}
key='1'
>
<Form
form={form}
layout='vertical'
initialValues={{
filament: filamentStockData.filament || {}
}}
> >
<Descriptions <Button>View</Button>
bordered </Popover>
column={{ </Space>
xs: 1, </Flex>
sm: 1,
md: 1, {error ? (
lg: 2, <Space
xl: 2, direction='vertical'
xxl: 2 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'}>
<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'
> >
{/* Read-only fields */} <Collapse.Panel
<Descriptions.Item label='ID' span={1}> header={
{filamentStockData.id ? ( <Flex align='center' gap={'middle'}>
<IdText id={filamentStockData.id} type={'filamentstock'} /> <InfoCircleIcon />
) : ( <Title level={5} style={{ margin: 0 }}>
'n/a' Filament Stock Information
)} </Title>
</Descriptions.Item> </Flex>
<Descriptions.Item label='Created At'> }
<TimeDisplay key='1'
dateTime={filamentStockData.createdAt} >
showSince={true} <Form
/> form={form}
</Descriptions.Item> layout='vertical'
initialValues={{
<Descriptions.Item label='State'> filament: filamentStockData?.filament || {}
<FilamentStockState filamentStock={filamentStockData} /> }}
</Descriptions.Item> >
<Spin
<Descriptions.Item label='Updated At'> indicator={<LoadingOutlined />}
<TimeDisplay spinning={fetchLoading}
dateTime={filamentStockData.updatedAt} >
showSince={true} <Descriptions
/> bordered
</Descriptions.Item> column={{
xs: 1,
<Descriptions.Item label='Filament Name'> sm: 1,
{filamentStockData.filament ? ( md: 1,
<Space> lg: 2,
<FilamentIcon /> xl: 2,
<Badge xxl: 2
color={filamentStockData.filament.color} }}
text={filamentStockData.filament.name} >
/> {/* Read-only fields */}
</Space> <Descriptions.Item label='ID' span={1}>
) : ( {filamentStockData?.id ? (
'n/a' <IdText
)} id={filamentStockData.id}
</Descriptions.Item> type={'filamentstock'}
/>
<Descriptions.Item label='Filament ID' span={1}> ) : (
{filamentStockData.filament ? ( <Text>n/a</Text>
<IdText )}
id={filamentStockData.filament.id}
type={'filament'}
showHyperlink={true}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Current Weight'>
{filamentStockData.currentGrossWeight ? (
<Descriptions style={{ width: '250px' }} column={2}>
<Descriptions.Item label='Net'>
{filamentStockData.currentNetWeight.toFixed(2) + 'g'}
</Descriptions.Item>
<Descriptions.Item label='Gross'>
{filamentStockData.currentGrossWeight.toFixed(2) + 'g'}
</Descriptions.Item>
</Descriptions>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Starting Weight'>
{filamentStockData.startingGrossWeight ? (
<Space>
<Descriptions style={{ width: '250px' }} column={2}>
<Descriptions.Item label='Net'>
{filamentStockData.startingNetWeight.toFixed(2) + 'g'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label='Gross'> <Descriptions.Item label='Created At'>
{filamentStockData.startingGrossWeight.toFixed(2) + {filamentStockData?.createdAt ? (
'g'} <TimeDisplay
dateTime={filamentStockData.createdAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='State'>
{filamentStockData ? (
<FilamentStockState
filamentStock={filamentStockData}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{filamentStockData?.updatedAt ? (
<TimeDisplay
dateTime={filamentStockData.updatedAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Filament Name'>
{filamentStockData?.filament ? (
<Space>
<FilamentIcon />
<Badge
color={filamentStockData.filament.color}
text={filamentStockData.filament.name}
/>
</Space>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Filament ID' span={1}>
{filamentStockData?.filament ? (
<IdText
id={filamentStockData.filament.id}
type={'filament'}
showHyperlink={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Current Weight'>
{filamentStockData?.currentGrossWeight ? (
<Descriptions style={{ width: '250px' }} column={2}>
<Descriptions.Item label='Net'>
{filamentStockData.currentNetWeight.toFixed(2) +
'g'}
</Descriptions.Item>
<Descriptions.Item label='Gross'>
{filamentStockData.currentGrossWeight.toFixed(
2
) + 'g'}
</Descriptions.Item>
</Descriptions>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Starting Weight'>
{filamentStockData?.startingGrossWeight ? (
<Space>
<Descriptions
style={{ width: '250px' }}
column={2}
>
<Descriptions.Item label='Net'>
{filamentStockData.startingNetWeight.toFixed(
2
) + 'g'}
</Descriptions.Item>
<Descriptions.Item label='Gross'>
{filamentStockData.startingGrossWeight.toFixed(
2
) + 'g'}
</Descriptions.Item>
</Descriptions>
</Space>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
</Space> </Spin>
) : ( </Form>
'n/a' </Collapse.Panel>
)} </Collapse>
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</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) =>
expandIcon={({ isActive }) => ( updateCollapseState('events', keys.length > 0)
<CaretRightOutlined }
rotate={isActive ? 90 : 0} expandIcon={({ isActive }) => (
style={{ paddingTop: '2px' }} <CaretLeftOutlined rotate={isActive ? -90 : 0} />
/> )}
)} className='no-h-padding-collapse'
className='no-h-padding-collapse' >
> <Collapse.Panel
<Collapse.Panel header={
header={ <Flex align='center' gap={'middle'}>
<Title level={5} style={{ margin: 0 }}> <FilamentStockIcon />
Filament Stock Events <Title level={5} style={{ margin: 0 }}>
</Title> Filament Stock Events
} </Title>
key='2' </Flex>
> }
<StockEventTable stockEvents={filamentStockData.stockEvents} /> key='2'
</Collapse.Panel> >
</Collapse> <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>
</Flex>
</div>
)}
</Flex> </Flex>
</div> </>
) )
} }

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

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={
<Title level={5} style={{ margin: 0 }}> <Flex align='center' gap={'small'}>
Audit Logs <AuditLogIcon />
</Title> <Title level={5} style={{ margin: 0 }}>
Audit Logs
</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 = () => {
partForm.setFieldsValue({ // Reset form values to original data
name: partData?.name || '' if (partData) {
}) partForm.setFieldsValue({
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,290 +288,419 @@ const PartInfo = () => {
} }
return ( return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}> <>
{contextHolder} {contextHolder}
<Flex vertical gap={'large'}> <Flex
<Collapse gap='large'
ghost vertical='true'
collapsible='icon' style={{ height: '100%', minHeight: 0 }}
activeKey={collapseState.info ? ['1'] : []} >
onChange={(keys) => updateCollapseState('info', keys.length > 0)} <Flex justify={'space-between'}>
expandIcon={({ isActive }) => ( <Space size='small'>
<CaretRightOutlined <Dropdown menu={actionItems}>
rotate={isActive ? 90 : 0} <Button>Actions</Button>
style={{ paddingTop: '9px' }} </Dropdown>
/> <Popover
)} content={getViewDropdownItems()}
className='no-h-padding-collapse no-t-padding-collapse' placement='bottomLeft'
> arrow={false}
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Part Information
</Title>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateInfo}
loading={loading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
}
key='1'
>
<Form
form={partForm}
layout='vertical'
onValuesChange={(changedValues) =>
setPartFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={{
name: partData.name || '',
version: partData.version || '',
tags: partData.tags || []
}}
> >
<Descriptions <Button>View</Button>
bordered </Popover>
column={{ </Space>
xs: 1, <Space>
sm: 1, {isEditing ? (
md: 1, <>
lg: 2, <Button
xl: 2, icon={<CheckIcon />}
xxl: 2 type='primary'
}} onClick={updateInfo}
> loading={loading}
<Descriptions.Item label='ID' span={1}> disabled={loading}
{partData.id ? ( />
<IdText id={partData.id} type='part'></IdText> <Button
) : ( icon={<XMarkIcon />}
'n/a' onClick={cancelEditing}
)} disabled={loading}
</Descriptions.Item> />
<Descriptions.Item label='Created At'> </>
<TimeDisplay dateTime={partData.createdAt} showSince={true} /> ) : (
</Descriptions.Item> <Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
<Descriptions.Item label='Name' span={1}> {error ? (
{isEditing ? ( <Space
<Form.Item direction='vertical'
name='name' style={{ width: '100%', textAlign: 'center' }}
rules={[
{
required: true,
message: 'Please enter a product name'
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter product name' />
</Form.Item>
) : (
partData.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
<TimeDisplay dateTime={partData.updatedAt} showSince={true} />
</Descriptions.Item>
<Descriptions.Item label='Product Name' span={1}>
{partData.product.name || 'n/a'}
</Descriptions.Item>
<Descriptions.Item label='Product ID' span={1}>
{(
<IdText
id={partData.product._id}
type={'product'}
showHyperlink={true}
/>
) || 'n/a'}
</Descriptions.Item>
<Descriptions.Item
label={!marginOrPrice ? 'Margin' : 'Price'}
span={1}
>
{isEditing && useGlobalPricing == false ? (
<Flex gap='middle'>
{marginOrPrice == false ? (
<Form.Item
name='margin'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a margin.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonAfter='%'
/>
</Form.Item>
) : (
<Form.Item
name='price'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a price.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonBefore='£'
/>
</Form.Item>
)}
<Form.Item
name='marginOrPrice'
valuePropName='checked'
style={{ margin: 0 }}
>
<Checkbox>Price</Checkbox>
</Form.Item>
</Flex>
) : partData.margin &&
marginOrPrice == false &&
partData.useGlobalPricing == false ? (
partData.margin + '%'
) : partData.price &&
marginOrPrice == true &&
partData.useGlobalPricing == false ? (
'£' + partData.price
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Global Pricing'>
{isEditing ? (
<Form.Item
name='useGlobalPricing'
rules={[
{
required: true,
message: 'Please enter a global price method'
}
]}
style={{ margin: 0 }}
valuePropName='checked'
>
<Switch />
</Form.Item>
) : partData.useGlobalPricing == true ? (
<Tag color='success' icon={<CheckIcon />}>
Yes
</Tag>
) : partData.useGlobalPricing == false ? (
<Tag icon={<XMarkIcon />}>No</Tag>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Version' span={1}>
{partData.version || 'n/a'}
</Descriptions.Item>
<Descriptions.Item label='Tags'>
{partData.tags &&
partData.tags.map((tag, index) => (
<Tag key={index}>{tag}</Tag>
))}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.preview ? ['2'] : []}
onChange={(keys) => updateCollapseState('preview', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Part Preview
</Title>
}
key='2'
> >
<Card styles={{ body: { padding: '10px' } }}> <p>{error || 'Part not found'}</p>
{stlLoadError ? ( <Button icon={<ReloadIcon />} onClick={fetchPartDetails}>
<div Retry
style={{ </Button>
height: '40vw', </Space>
display: 'flex', ) : (
alignItems: 'center', <div style={{ height: '100%', overflow: 'auto' }}>
justifyContent: 'center', <Flex vertical gap={'large'}>
backgroundColor: '#f5f5f5' <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'
> >
<Space direction='vertical' align='center'> <Form
<XMarkIcon style={{ fontSize: '24px', color: '#ff4d4f' }} /> form={partForm}
<Typography.Text type='danger'> layout='vertical'
{stlLoadError} onValuesChange={(changedValues) =>
</Typography.Text> setPartFormValues((prevValues) => ({
</Space> ...prevValues,
</div> ...changedValues
) : ( }))
partFileObjectId && ( }
<StlViewer initialValues={{
url={partFileObjectId} name: partData?.name || '',
orbitControls version: partData?.version || '',
shadows tags: partData?.tags || []
style={{ height: '40vw' }}
modelProps={{
color: '#008675'
}} }}
></StlViewer> >
) <Spin
)} indicator={<LoadingOutlined />}
</Card> spinning={fetchLoading}
</Collapse.Panel> >
</Collapse> <Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID' span={1}>
{partData?.id ? (
<IdText id={partData.id} type='part'></IdText>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{partData?.createdAt ? (
<TimeDisplay
dateTime={partData.createdAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Name' span={1}>
{isEditing ? (
<Form.Item
name='name'
rules={[
{
required: true,
message: 'Please enter a product name'
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter product name' />
</Form.Item>
) : partData?.name ? (
<Text>{partData.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{partData?.updatedAt ? (
<TimeDisplay
dateTime={partData.updatedAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Product Name' span={1}>
{partData?.product?.name ? (
<Text>{partData.product.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Product ID' span={1}>
{partData?.product?._id ? (
<IdText
id={partData.product._id}
type={'product'}
showHyperlink={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item
label={!marginOrPrice ? 'Margin' : 'Price'}
span={1}
>
{isEditing && useGlobalPricing == false ? (
<Flex gap='middle'>
{marginOrPrice == false ? (
<Form.Item
name='margin'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a margin.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonAfter='%'
/>
</Form.Item>
) : (
<Form.Item
name='price'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a price.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonBefore='£'
/>
</Form.Item>
)}
<Form.Item
name='marginOrPrice'
valuePropName='checked'
style={{ margin: 0 }}
>
<Checkbox>Price</Checkbox>
</Form.Item>
</Flex>
) : partData?.margin &&
marginOrPrice == false &&
partData?.useGlobalPricing == false ? (
<Text>{partData.margin + '%'}</Text>
) : partData?.price &&
marginOrPrice == true &&
partData?.useGlobalPricing == false ? (
<Text>{'£' + partData.price}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Global Pricing'>
{isEditing ? (
<Form.Item
name='useGlobalPricing'
rules={[
{
required: true,
message: 'Please enter a global price method'
}
]}
style={{ margin: 0 }}
valuePropName='checked'
>
<Switch />
</Form.Item>
) : partData ? (
<BoolDisplay
value={partData.useGlobalPricing}
yesNo={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Version' span={1}>
{partData?.version ? (
<Text>{partData.version}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Tags'>
{partData?.tags && partData.tags.length > 0 ? (
partData.tags.map((tag, index) => (
<Tag key={index}>{tag}</Tag>
))
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</Descriptions>
</Spin>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.preview ? ['2'] : []}
onChange={(keys) =>
updateCollapseState('preview', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<PartIcon />
<Title level={5} style={{ margin: 0 }}>
Part Preview
</Title>
</Flex>
}
key='2'
>
<Card styles={{ body: { padding: '10px' } }}>
{stlLoadError ? (
<div
style={{
height: '40vw',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f5f5f5'
}}
>
<Space direction='vertical' align='center'>
<XMarkIcon
style={{ fontSize: '24px', color: '#ff4d4f' }}
/>
<Typography.Text type='danger'>
{stlLoadError}
</Typography.Text>
</Space>
</div>
) : (
partFileObjectId && (
<StlViewer
url={partFileObjectId}
orbitControls
shadows
style={{ height: '40vw' }}
modelProps={{
color: '#008675'
}}
></StlViewer>
)
)}
</Card>
</Collapse.Panel>
</Collapse>
<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>
</div>
)}
</Flex> </Flex>
</div> </>
) )
} }

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 = () => {
productForm.setFieldsValue({ // Reset form values to original data
name: productData?.name || '', if (productData) {
vendor: productData?.vendor || { id: null, name: '' }, productForm.setFieldsValue({
version: productData?.version || '', name: productData.name || '',
tags: productData?.tags || [], vendor: productData.vendor || { id: null, name: '' },
cost: productData?.cost || null, version: productData.version || '',
price: productData?.price || null, tags: productData.tags || [],
margin: productData?.margin || null, cost: productData.cost || null,
marginOrPrice: productData?.marginOrPrice || null price: productData.price || null,
}) margin: productData.margin || null,
setMarginOrPrice(productData?.marginOrPrice) marginOrPrice: productData.marginOrPrice || null
})
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,293 +240,418 @@ const ProductInfo = () => {
} }
return ( return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}> <>
{contextHolder} {contextHolder}
<Flex vertical gap={'large'}> <Flex
<Collapse gap='large'
ghost vertical='true'
collapsible='icon' style={{ height: '100%', minHeight: 0 }}
activeKey={collapseState.info ? ['1'] : []} >
onChange={(keys) => updateCollapseState('info', keys.length > 0)} <Flex justify={'space-between'}>
expandIcon={({ isActive }) => ( <Space size='small'>
<CaretRightOutlined <Dropdown menu={actionItems}>
rotate={isActive ? 90 : 0} <Button>Actions</Button>
style={{ paddingTop: '9px' }} </Dropdown>
/> <Popover
)} content={getViewDropdownItems()}
className='no-h-padding-collapse no-t-padding-collapse' placement='bottomLeft'
> arrow={false}
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Product Information
</Title>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateInfo}
loading={loading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
}
key='1'
>
<Form
form={productForm}
layout='vertical'
onValuesChange={(changedValues) =>
setProductFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={{
name: productData.name || '',
vendor: productData.vendor || { id: null, name: '' },
version: productData.version || '',
tags: productData.tags || []
}}
> >
<Descriptions <Button>View</Button>
bordered </Popover>
column={{ </Space>
xs: 1, <Space>
sm: 1, {isEditing ? (
md: 1, <>
lg: 2, <Button
xl: 2, icon={<CheckIcon />}
xxl: 2 type='primary'
}} onClick={updateInfo}
> loading={loading}
<Descriptions.Item label='ID' span={1}> disabled={loading}
{productData.id ? ( />
<IdText id={productData.id} type='product'></IdText> <Button
) : ( icon={<XMarkIcon />}
'n/a' onClick={cancelEditing}
)} disabled={loading}
</Descriptions.Item> />
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
<Descriptions.Item label='Created At'> {error ? (
<TimeDisplay <Space
dateTime={productData.createdAt} direction='vertical'
showSince={true} style={{ width: '100%', textAlign: 'center' }}
/>
</Descriptions.Item>
<Descriptions.Item label='Name' span={1}>
{isEditing ? (
<Form.Item
name='name'
rules={[
{
required: true,
message: 'Please enter a product name'
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter product name' />
</Form.Item>
) : (
productData.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
<TimeDisplay
dateTime={productData.updatedAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Vendor'>
{isEditing ? (
<Form.Item
name='vendor'
rules={[
{ required: true, message: 'Please enter a vendor' }
]}
style={{ margin: 0 }}
>
<VendorSelect />
</Form.Item>
) : (
productData.vendor.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor ID'>
<IdText
id={productData.vendor.id}
type={'vendor'}
showHyperlink={true}
/>
</Descriptions.Item>
<Descriptions.Item
label={!marginOrPrice ? 'Margin' : 'Price'}
span={1}
>
{isEditing ? (
<Flex gap='middle'>
{marginOrPrice == false ? (
<Form.Item
name='margin'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a margin.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonAfter='%'
/>
</Form.Item>
) : (
<Form.Item
name='price'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a price.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonBefore='£'
/>
</Form.Item>
)}
<Form.Item
name='marginOrPrice'
valuePropName='checked'
style={{ margin: 0 }}
>
<Checkbox>Price</Checkbox>
</Form.Item>
</Flex>
) : productData.margin && marginOrPrice == false ? (
productData.margin + '%'
) : productData.price && marginOrPrice == true ? (
'£' + productData.price
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Version' span={1}>
{isEditing ? (
<Form.Item name='version' style={{ margin: 0 }}>
<Input placeholder='Enter version' />
</Form.Item>
) : productData.version ? (
<Tag>{productData.version}</Tag>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Tags' span={1}>
{isEditing ? (
<Form.Item name='tags' style={{ margin: 0 }}>
<Space
size={[0, 2]}
wrap
style={{ marginBottom: 4, maxWidth: '300px' }}
>
{productData.tags.map((tag) => (
<Tag
key={tag}
color='blue'
closable
onClose={() => handleTagClose(tag)}
style={{ marginBottom: 12 }}
>
{tag}
</Tag>
))}
</Space>
<Space.Compact block>
<Form.Item name='newTag' noStyle>
<Input placeholder='Add new tag' />
</Form.Item>
<Button onClick={handleTagAdd} icon={<PlusIcon />} />
</Space.Compact>
</Form.Item>
) : productData.tags?.length > 0 ? (
<Space size={[0, 2]} wrap style={{ maxWidth: '300px' }}>
{productData.tags.map((tag, index) => (
<Tag key={index} color='blue'>
{tag}
</Tag>
))}
</Space>
) : (
'n/a'
)}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.parts ? ['2'] : []}
onChange={(keys) => updateCollapseState('parts', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Product Parts
</Title>
}
key='2'
> >
<PartsTable data={productData.parts} /> <p>{error || 'Product not found'}</p>
</Collapse.Panel> <Button icon={<ReloadIcon />} onClick={fetchProductDetails}>
</Collapse> 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'
>
<Form
form={productForm}
layout='vertical'
onValuesChange={(changedValues) =>
setProductFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={{
name: productData?.name || '',
vendor: productData?.vendor || { id: null, name: '' },
version: productData?.version || '',
tags: productData?.tags || []
}}
>
<Spin
indicator={<LoadingOutlined />}
spinning={fetchLoading}
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID' span={1}>
{productData?.id ? (
<IdText id={productData.id} type='product'></IdText>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{productData?.createdAt ? (
<TimeDisplay
dateTime={productData.createdAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Name' span={1}>
{isEditing ? (
<Form.Item
name='name'
rules={[
{
required: true,
message: 'Please enter a product name'
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter product name' />
</Form.Item>
) : productData?.name ? (
<Text>{productData.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{productData?.updatedAt ? (
<TimeDisplay
dateTime={productData.updatedAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor'>
{isEditing ? (
<Form.Item
name='vendor'
rules={[
{
required: true,
message: 'Please enter a vendor'
}
]}
style={{ margin: 0 }}
>
<VendorSelect />
</Form.Item>
) : productData?.vendor?.name ? (
<Text>{productData.vendor.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor ID'>
{productData?.vendor?.id ? (
<IdText
id={productData.vendor.id}
type={'vendor'}
showHyperlink={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item
label={!marginOrPrice ? 'Margin' : 'Price'}
span={1}
>
{isEditing ? (
<Flex gap='middle'>
{marginOrPrice == false ? (
<Form.Item
name='margin'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a margin.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonAfter='%'
/>
</Form.Item>
) : (
<Form.Item
name='price'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a price.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonBefore='£'
/>
</Form.Item>
)}
<Form.Item
name='marginOrPrice'
valuePropName='checked'
style={{ margin: 0 }}
>
<Checkbox>Price</Checkbox>
</Form.Item>
</Flex>
) : productData?.margin && marginOrPrice == false ? (
<Text>{productData.margin + '%'}</Text>
) : productData?.price && marginOrPrice == true ? (
<Text>{'£' + productData.price}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Version' span={1}>
{isEditing ? (
<Form.Item name='version' style={{ margin: 0 }}>
<Input placeholder='Enter version' />
</Form.Item>
) : productData?.version ? (
<Tag>{productData.version}</Tag>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Tags' span={1}>
{isEditing ? (
<Form.Item name='tags' style={{ margin: 0 }}>
<Space
size={[0, 2]}
wrap
style={{ marginBottom: 4, maxWidth: '300px' }}
>
{productData?.tags?.map((tag) => (
<Tag
key={tag}
color='blue'
closable
onClose={() => handleTagClose(tag)}
style={{ marginBottom: 12 }}
>
{tag}
</Tag>
))}
</Space>
<Space.Compact block>
<Form.Item name='newTag' noStyle>
<Input placeholder='Add new tag' />
</Form.Item>
<Button
onClick={handleTagAdd}
icon={<PlusIcon />}
/>
</Space.Compact>
</Form.Item>
) : productData?.tags?.length > 0 ? (
<Space
size={[0, 2]}
wrap
style={{ maxWidth: '300px' }}
>
{productData.tags.map((tag, index) => (
<Tag key={index} color='blue'>
{tag}
</Tag>
))}
</Space>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</Descriptions>
</Spin>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.parts ? ['2'] : []}
onChange={(keys) =>
updateCollapseState('parts', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<ProductIcon />
<Title level={5} style={{ margin: 0 }}>
Product Parts
</Title>
</Flex>
}
key='2'
>
<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>
</Flex>
</div>
)}
</Flex> </Flex>
</div> </>
) )
} }

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 = () => {
form.setFieldsValue({ // Reset form values to original data
name: vendorData?.name || '', if (vendorData) {
website: vendorData?.website || '', form.setFieldsValue({
contact: vendorData?.contact || '', name: vendorData.name || '',
country: vendorData?.country || '', website: vendorData.website || '',
phone: vendorData?.phone || '', contact: vendorData.contact || '',
email: vendorData?.email || '' country: vendorData.country || '',
}) phone: vendorData.phone || '',
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,219 +201,340 @@ const VendorInfo = () => {
} }
return ( return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}> <>
{contextHolder} {contextHolder}
<Flex vertical gap={'large'}> <Flex
<Collapse gap='large'
ghost vertical='true'
collapsible='icon' style={{ height: '100%', minHeight: 0 }}
activeKey={collapseState.info ? ['1'] : []} >
onChange={(keys) => updateCollapseState('info', keys.length > 0)} <Flex justify={'space-between'}>
expandIcon={({ isActive }) => ( <Space size='small'>
<CaretRightOutlined <Dropdown menu={actionItems}>
rotate={isActive ? 90 : 0} <Button>Actions</Button>
style={{ paddingTop: '9px' }} </Dropdown>
/> <Popover
)} content={getViewDropdownItems()}
className='no-h-padding-collapse no-t-padding-collapse' placement='bottomLeft'
> arrow={false}
<Collapse.Panel >
header={ <Button>View</Button>
<Flex </Popover>
align='center' </Space>
justify='space-between' <Space>
style={{ width: '100%' }} {isEditing ? (
> <>
<Title level={5} style={{ margin: 0 }}> <Button
Vendor Information icon={<CheckIcon />}
</Title> type='primary'
<Space> onClick={updateInfo}
{isEditing ? ( loading={loading}
<> disabled={loading}
<Button />
icon={<CheckIcon />} <Button
type='primary' icon={<XMarkIcon />}
onClick={updateInfo} onClick={cancelEditing}
loading={loading} disabled={loading}
/> />
<Button </>
icon={<XMarkIcon />} ) : (
onClick={cancelEditing} <Button icon={<EditIcon />} onClick={startEditing} />
disabled={loading} )}
/> </Space>
</> </Flex>
) : (
<Button icon={<EditIcon />} onClick={startEditing} /> {error ? (
)} <Space
</Space> direction='vertical'
</Flex> style={{ width: '100%', textAlign: 'center' }}
}
key='1'
> >
<Form form={form} layout='vertical'> <p>{error || 'Vendor not found'}</p>
<Descriptions <Button icon={<ReloadIcon />} onClick={fetchVendorDetails}>
bordered Retry
column={{ </Button>
xs: 1, </Space>
sm: 1, ) : (
md: 1, <div style={{ height: '100%', overflow: 'auto' }}>
lg: 2, <Flex vertical gap={'large'}>
xl: 2, <Collapse
xxl: 2 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'
> >
<Descriptions.Item label='ID'> <Collapse.Panel
<IdText id={vendorData._id} type='vendor' /> header={
</Descriptions.Item> <Flex align='center' gap={'middle'}>
<Descriptions.Item label='Created At'> <InfoCircleIcon />
<TimeDisplay <Title level={5} style={{ margin: 0 }}>
dateTime={vendorData.createdAt} Vendor Information
showSince={true} </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'>
{vendorData?._id ? (
<IdText id={vendorData._id} type='vendor' />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{vendorData?.createdAt ? (
<TimeDisplay
dateTime={vendorData.createdAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='name'
rules={[
{
required: true,
message: 'Please enter a vendor name'
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData?.name ? (
<Text>{vendorData.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{vendorData?.updatedAt ? (
<TimeDisplay
dateTime={vendorData.updatedAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Website'>
{isEditing ? (
<Form.Item
name='website'
rules={[
{
type: 'url',
message: 'Please enter a valid URL'
},
{
max: 200,
message:
'Website URL cannot exceed 200 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData?.website ? (
<Link
href={vendorData.website}
target='_blank'
rel='noopener noreferrer'
>
{new URL(vendorData.website).hostname + ' '}
<ExportOutlined />
</Link>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Country'>
{isEditing ? (
<Form.Item name='country' style={{ margin: 0 }}>
<CountrySelect
countryCode={vendorData?.country}
/>
</Form.Item>
) : vendorData?.country ? (
<CountryDisplay countryCode={vendorData.country} />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Contact'>
{isEditing ? (
<Form.Item
name='contact'
rules={[
{
max: 200,
message:
'Contact info cannot exceed 200 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData?.contact ? (
<Text>{vendorData.contact}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Phone'>
{isEditing ? (
<Form.Item
name='phone'
rules={[
{
type: 'phone',
message: 'Please enter a valid phone number'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData?.phone ? (
<Text>{vendorData.phone}</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>
) : vendorData?.email ? (
<Link href={`mailto:${vendorData.email}`}>
{vendorData.email + ' '}
<ExportOutlined />
</Link>
) : (
<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={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}
/> />
</Descriptions.Item> </Collapse.Panel>
</Collapse>
<Descriptions.Item label='Name'> </Flex>
{isEditing ? ( </div>
<Form.Item )}
name='name'
rules={[
{
required: true,
message: 'Please enter a vendor name'
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : (
vendorData.name
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
<TimeDisplay
dateTime={vendorData.updatedAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Website'>
{isEditing ? (
<Form.Item
name='website'
rules={[
{ type: 'url', message: 'Please enter a valid URL' },
{
max: 200,
message: 'Website URL cannot exceed 200 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData.website ? (
<Link
href={vendorData.website}
target='_blank'
rel='noopener noreferrer'
>
{new URL(vendorData.website).hostname + ' '}
<ExportOutlined />
</Link>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Country'>
{isEditing ? (
<Form.Item name='country' style={{ margin: 0 }}>
<CountrySelect countryCode={vendorData.country} />
</Form.Item>
) : vendorData.country ? (
<CountryDisplay countryCode={vendorData.country} />
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Contact'>
{isEditing ? (
<Form.Item
name='contact'
rules={[
{
max: 200,
message: 'Contact info cannot exceed 200 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData.contact ? (
vendorData.contact
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Phone'>
{isEditing ? (
<Form.Item
name='phone'
rules={[
{
type: 'phone',
message: 'Please enter a valid phone number'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData.phone ? (
vendorData.phone
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Email'>
{isEditing ? (
<Form.Item
name='email'
rules={[
{
type: 'email',
message: 'Please enter a valid email'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData.email ? (
<Link href={`mailto:${vendorData.email}`}>
{vendorData.email + ' '}
<ExportOutlined />
</Link>
) : (
'n/a'
)}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
</Flex> </Flex>
</div> </>
) )
} }

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,24 +357,34 @@ const GCodeFiles = () => {
<> <>
<Flex vertical={'true'} gap='large'> <Flex vertical={'true'} gap='large'>
{contextHolder} {contextHolder}
<Space> <Flex justify={'space-between'}>
<Dropdown menu={actionItems}> <Space>
<Button>Actions</Button> <Dropdown menu={actionItems}>
</Dropdown> <Button>Actions</Button>
<Popover </Dropdown>
content={getViewDropdownItems()} <Popover
placement='bottomLeft' content={getViewDropdownItems()}
arrow={false} placement='bottomLeft'
> arrow={false}
<Button>View</Button> >
</Popover> <Button>View</Button>
</Space> </Popover>
</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,289 +140,457 @@ const GCodeFileInfo = () => {
} }
} }
if (error || !gcodeFileData) { const actionItems = {
return ( items: [
<Space {
direction='vertical' label: 'Edit GCode File',
style={{ width: '100%', textAlign: 'center' }} key: 'edit',
> icon: <EditIcon />
<p>{error || 'GCodeFile not found'}</p> },
<Button icon={<ReloadIcon />} onClick={fetchGCodeFileDetails}> {
Retry label: 'Delete GCode File',
</Button> key: 'delete',
</Space> icon: <BinIcon />,
) danger: true
},
{ type: 'divider' },
{
label: 'Reload GCode File',
key: 'reload',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchGCodeFileDetails()
}
}
} }
if (fetchLoading) { 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 (
<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>
) )
} }
return ( return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}> <>
{contextHolder} {contextHolder}
<Flex vertical gap={'large'}> <Flex
<Collapse gap='large'
ghost vertical='true'
collapsible='icon' style={{ height: '100%', minHeight: 0 }}
activeKey={collapseState.info ? ['1'] : []} >
onChange={(keys) => updateCollapseState('info', keys.length > 0)} <Flex justify={'space-between'}>
expandIcon={({ isActive }) => ( <Space size='small'>
<CaretRightOutlined <Dropdown menu={actionItems}>
rotate={isActive ? 90 : 0} <Button>Actions</Button>
style={{ paddingTop: '9px' }} </Dropdown>
/> <Popover
)} content={getViewDropdownItems()}
className='no-h-padding-collapse no-t-padding-collapse' placement='bottomLeft'
> arrow={false}
<Collapse.Panel >
header={ <Button>View</Button>
<Flex </Popover>
align='center' </Space>
justify='space-between' <Space>
style={{ width: '100%' }} {isEditing ? (
> <>
<Title level={5} style={{ margin: 0 }}> <Button
GCode File Information icon={<CheckIcon />}
</Title> type='primary'
<Space> onClick={updateGCodeFileInfo}
{isEditing ? ( loading={editLoading}
<> disabled={editLoading}
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateInfo}
loading={loading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
}
key='1'
>
<Form form={form} layout='vertical'>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID' span={1}>
{gcodeFileData.id ? (
<IdText id={gcodeFileData.id} type='gcodeFile'></IdText>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
<TimeDisplay
dateTime={gcodeFileData.createdAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='name'
rules={[
{
required: true,
message: 'Please enter a vendor name'
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : (
gcodeFileData.name
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
<TimeDisplay
dateTime={gcodeFileData.updatedAt}
showSince={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Filament Name'>
{isEditing ? (
<Form.Item
name='filament'
rules={[
{ required: true, message: 'Please enter a filament' }
]}
style={{ margin: 0 }}
>
<FilamentSelect />
</Form.Item>
) : gcodeFileData.filament ? (
<Space>
<FilamentIcon />
<Badge
color={gcodeFileData.filament.color}
text={gcodeFileData.filament.name}
/>
</Space>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Filament ID'>
{gcodeFileData.filament ? (
<IdText
id={gcodeFileData.filament.id}
type={'filament'}
showHyperlink={true}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Est Print Time'>
{gcodeFileData.gcodeFileInfo
.estimatedPrintingTimeNormalMode || 'n/a'}
</Descriptions.Item>
<Descriptions.Item label='Cost'>
{'£' + gcodeFileData.cost.toFixed(2) || 'n/a'}
</Descriptions.Item>
<Descriptions.Item label='Infill Density'>
{(() => {
if (gcodeFileData.gcodeFileInfo.sparseInfillDensity) {
return gcodeFileData.gcodeFileInfo.sparseInfillDensity
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Infill Pattern'>
{(() => {
if (gcodeFileData.gcodeFileInfo.sparseInfillPattern) {
return capitalizeFirstLetter(
gcodeFileData.gcodeFileInfo.sparseInfillPattern
)
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Filament Used (mm)'>
{(() => {
if (gcodeFileData.gcodeFileInfo.filamentUsedMm) {
return `${gcodeFileData.gcodeFileInfo.filamentUsedMm}mm`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Filament Used (g)'>
{(() => {
if (gcodeFileData.gcodeFileInfo.filamentUsedG) {
return `${gcodeFileData.gcodeFileInfo.filamentUsedG}g`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Hotend Temperature'>
{(() => {
if (gcodeFileData.gcodeFileInfo.nozzleTemperature) {
return `${gcodeFileData.gcodeFileInfo.nozzleTemperature}°`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Bed Temperature'>
{(() => {
if (gcodeFileData.gcodeFileInfo.hotPlateTemp) {
return `${gcodeFileData.gcodeFileInfo.hotPlateTemp}°`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Filament Profile'>
{(() => {
if (gcodeFileData.gcodeFileInfo.filamentSettingsId) {
return `${gcodeFileData.gcodeFileInfo.filamentSettingsId.replaceAll('"', '')}`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Print Profile'>
{(() => {
if (gcodeFileData.gcodeFileInfo.printSettingsId) {
return `${gcodeFileData.gcodeFileInfo.printSettingsId.replaceAll('"', '')}`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
collapsible='icon'
activeKey={collapseState.preview ? ['2'] : []}
onChange={(keys) => updateCollapseState('preview', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
GCode File Preview
</Title>
}
key='2'
>
<Card styles={{ body: { padding: '10px' } }}>
{gcodeFileData.gcodeFileInfo.thumbnail ? (
<img
src={`data:image/png;base64,${gcodeFileData.gcodeFileInfo.thumbnail.data}`}
alt='GCodeFile'
style={{ maxWidth: '100%' }}
/> />
) : ( <Button
'n/a' icon={<XMarkIcon />}
)} onClick={cancelEditing}
</Card> disabled={editLoading}
</Collapse.Panel> />
</Collapse> </>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</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)
}
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'>
<Spin
spinning={fetchLoading}
indicator={<LoadingOutlined />}
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID' span={1}>
{gcodeFileData?._id ? (
<IdText
id={gcodeFileData._id}
type='gcodefile'
></IdText>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{gcodeFileData?.createdAt ? (
<TimeDisplay
dateTime={gcodeFileData.createdAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='name'
rules={[
{
required: true,
message: 'Please enter a vendor name'
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : gcodeFileData?.name ? (
<Text>{gcodeFileData.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{gcodeFileData?.updatedAt ? (
<TimeDisplay
dateTime={gcodeFileData.updatedAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Filament Name'>
{isEditing ? (
<Form.Item
name='filament'
rules={[
{
required: true,
message: 'Please enter a filament'
}
]}
style={{ margin: 0 }}
>
<FilamentSelect />
</Form.Item>
) : gcodeFileData?.filament ? (
<Space>
<FilamentIcon />
<Badge
color={gcodeFileData.filament.color}
text={gcodeFileData.filament.name}
/>
</Space>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Filament ID'>
{gcodeFileData?.filament ? (
<IdText
id={gcodeFileData.filament.id}
type={'filament'}
showHyperlink={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Est Print Time'>
{gcodeFileData?.gcodeFileInfo
?.estimatedPrintingTimeNormalMode ? (
<Text>
{
gcodeFileData.gcodeFileInfo
.estimatedPrintingTimeNormalMode
}
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Cost'>
{gcodeFileData?.cost ? (
<Text>{'£' + gcodeFileData.cost.toFixed(2)}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Infill Density'>
{gcodeFileData?.gcodeFileInfo?.sparseInfillDensity ? (
<Text>
{gcodeFileData.gcodeFileInfo.sparseInfillDensity}
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Infill Pattern'>
{gcodeFileData?.gcodeFileInfo?.sparseInfillPattern ? (
<Text>
{capitalizeFirstLetter(
gcodeFileData.gcodeFileInfo.sparseInfillPattern
)}
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Filament Used (mm)'>
{gcodeFileData?.gcodeFileInfo?.filamentUsedMm ? (
<Text>
{gcodeFileData.gcodeFileInfo.filamentUsedMm}mm
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Filament Used (g)'>
{gcodeFileData?.gcodeFileInfo?.filamentUsedG ? (
<Text>
{gcodeFileData.gcodeFileInfo.filamentUsedG}g
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Hotend Temperature'>
{gcodeFileData?.gcodeFileInfo?.nozzleTemperature ? (
<Text>
{gcodeFileData.gcodeFileInfo.nozzleTemperature}°
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Bed Temperature'>
{gcodeFileData?.gcodeFileInfo?.hotPlateTemp ? (
<Text>
{gcodeFileData.gcodeFileInfo.hotPlateTemp}°
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Filament Profile'>
{gcodeFileData?.gcodeFileInfo?.filamentSettingsId ? (
<Text>
{gcodeFileData.gcodeFileInfo.filamentSettingsId.replaceAll(
'"',
''
)}
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Print Profile'>
{gcodeFileData?.gcodeFileInfo?.printSettingsId ? (
<Text>
{gcodeFileData.gcodeFileInfo.printSettingsId.replaceAll(
'"',
''
)}
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</Descriptions>
</Spin>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.preview ? ['preview'] : []}
onChange={(keys) =>
updateCollapseState('preview', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<GCodeFileIcon />
<Title level={5} style={{ margin: 0 }}>
GCode File Preview
</Title>
</Flex>
}
key='preview'
>
<Spin spinning={fetchLoading} indicator={<LoadingOutlined />}>
<Card styles={{ body: { padding: '10px' } }}>
{gcodeFileData?.gcodeFileInfo?.thumbnail ? (
<img
src={`data:image/png;base64,${gcodeFileData.gcodeFileInfo.thumbnail.data}`}
alt='GCodeFile'
style={{ maxWidth: '100%' }}
/>
) : (
<Text>n/a</Text>
)}
</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>
</Flex>
</div>
)}
</Flex> </Flex>
</div> </>
) )
} }

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,30 +370,42 @@ const Jobs = () => {
{notificationContextHolder} {notificationContextHolder}
<Flex vertical={'true'} gap='large' style={{ height: '100%' }}> <Flex vertical={'true'} gap='large' style={{ height: '100%' }}>
{contextHolder} {contextHolder}
<Space size='small'> <Flex justify={'space-between'}>
<Dropdown menu={actionItems}> <Space size='small'>
<Button>Actions</Button> <Dropdown menu={actionItems}>
</Dropdown> <Button>Actions</Button>
<Popover </Dropdown>
content={getViewDropdownItems()} <Popover
placement='bottomLeft' content={getViewDropdownItems()}
arrow={false} placement='bottomLeft'
> arrow={false}
<Button>View</Button> >
</Popover> <Button>View</Button>
</Space> </Popover>
</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={
<Title level={5} style={{ margin: 0 }}> <Flex align='center' gap={'middle'}>
Job Information <InfoCircleIcon />
</Title> <Title level={5} style={{ margin: 0 }}>
Job Information
</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={
<Title level={5} style={{ margin: 0 }}> <Flex align='center' gap={'middle'}>
Sub Job Information <JobIcon />
</Title> <Title level={5} style={{ margin: 0 }}>
Sub Job Information
</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={
<Title level={5} style={{ margin: 0 }}> <Flex align='center' gap={'middle'}>
Notes <NoteIcon />
</Title> <Title level={5} style={{ margin: 0 }}>
Notes
</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={
<Title level={5} style={{ margin: 0 }}> <Flex align='center' gap={'middle'}>
Audit Logs <AuditLogIcon />
</Title> <Title level={5} style={{ margin: 0 }}>
Audit Logs
</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,24 +289,35 @@ const Printers = () => {
<> <>
<Flex vertical={'true'} gap='large'> <Flex vertical={'true'} gap='large'>
{contextHolder} {contextHolder}
<Space> <Flex justify={'space-between'}>
<Dropdown menu={actionItems}> <Space>
<Button>Actions</Button> <Dropdown menu={actionItems}>
</Dropdown> <Button>Actions</Button>
<Popover </Dropdown>
content={getViewDropdownItems()} <Popover
placement='bottomLeft' content={getViewDropdownItems()}
arrow={false} placement='bottomLeft'
> arrow={false}
<Button>View</Button> >
</Popover> <Button>View</Button>
</Space> </Popover>
</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,46 +488,44 @@ 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 }}> {printerData?.alerts?.some(
{printerData?.alerts?.some( (alert) => alert.type === 'klippyError'
(alert) => alert.type === 'klippyError' ) && <Alert message={klippyErrorMessage} type='error' />}
) && <Alert message={klippyErrorMessage} type='error' />} {printerData?.alerts?.some(
{printerData?.alerts?.some( (alert) => alert.type === 'klippyStartup'
(alert) => alert.type === 'klippyStartup' ) && <Alert message={klippyStartupMessage} type='warning' />}
) && <Alert message={klippyStartupMessage} type='warning' />} </Flex>
</Flex> <Collapse
<Collapse ghost
ghost expandIconPosition='end'
collapsible='icon' activeKey={collapseState.job ? ['1'] : []}
activeKey={collapseState.job ? ['1'] : []} onChange={(keys) => updateCollapseState('job', keys.length > 0)}
onChange={(keys) => expandIcon={({ isActive }) => (
updateCollapseState('job', keys.length > 0) <CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
style={{ padding: 0 }}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Current Job
</Title>
</Flex>
} }
expandIcon={({ isActive }) => ( key='1'
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
style={{ padding: 0 }}
className='no-h-padding-collapse no-t-padding-collapse'
> >
<Collapse.Panel <Spin
header={ indicator={<LoadingOutlined spin />}
<Flex spinning={fetchLoading}
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Current Job
</Title>
</Flex>
}
key='1'
> >
<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,88 +623,79 @@ 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 <Text ellipsis style={{ maxWidth: 200 }}>
.printSettingsId {printerData.currentJob.gcodeFile.gcodeFileInfo.printSettingsId.replaceAll(
) { '"',
return ( ''
<Text ellipsis style={{ maxWidth: 200 }}> )}
{printerData.currentJob.gcodeFile.gcodeFileInfo.printSettingsId.replaceAll( </Text>
'"', ) : (
'' <Text>n/a</Text>
)} )}
</Text>
)
} else {
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 <Text ellipsis>
.estimatedPrintingTimeNormalMode {
) { printerData.currentJob.gcodeFile.gcodeFileInfo
return ( .estimatedPrintingTimeNormalMode
<Text ellipsis> }
{ </Text>
printerData.currentJob.gcodeFile.gcodeFileInfo ) : (
.estimatedPrintingTimeNormalMode <Text>n/a</Text>
} )}
</Text>
)
}
return 'n/a'
})()}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
</Collapse.Panel> </Spin>
</Collapse> </Collapse.Panel>
<Collapse </Collapse>
ghost <Collapse
collapsible='icon' ghost
activeKey={collapseState.filament ? ['1'] : []} expandIconPosition='end'
onChange={(keys) => activeKey={collapseState.filament ? ['1'] : []}
updateCollapseState('filament', keys.length > 0) onChange={(keys) =>
updateCollapseState('filament', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
style={{ padding: 0 }}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Loaded Filament Stock
</Title>
</Flex>
} }
expandIcon={({ isActive }) => ( key='1'
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
style={{ padding: 0 }}
className='no-h-padding-collapse'
> >
<Collapse.Panel <Spin
header={ indicator={<LoadingOutlined spin />}
<Flex spinning={fetchLoading}
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Loaded Filament Stock
</Title>
</Flex>
}
key='1'
> >
<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,57 +779,66 @@ const ControlPrinter = () => {
</Descriptions> </Descriptions>
</div> </div>
) : ( ) : (
'n/a' <Text>n/a</Text>
)} )}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
</Collapse.Panel> </Spin>
</Collapse> </Collapse.Panel>
<Collapse </Collapse>
ghost <Collapse
collapsible='icon' ghost
activeKey={collapseState.jobs ? ['1'] : []} expandIconPosition='end'
onChange={(keys) => activeKey={collapseState.jobs ? ['1'] : []}
updateCollapseState('jobs', keys.length > 0) onChange={(keys) =>
updateCollapseState('jobs', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
style={{ padding: 0 }}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Printer Jobs
</Title>
</Flex>
} }
expandIcon={({ isActive }) => ( key='1'
<CaretRightOutlined >
rotate={isActive ? 90 : 0} <PrinterSubJobsTree
style={{ paddingTop: '2px' }} subJobs={printerData?.subJobs}
/> loading={fetchLoading}
)} />
style={{ padding: 0 }} </Collapse.Panel>
className='no-h-padding-collapse' </Collapse>
</Flex>
<Flex gap={'large'} wrap vertical>
{componentVisibility.temperature && (
<Spin
indicator={<LoadingOutlined spin />}
spinning={fetchLoading}
> >
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Printer Jobs
</Title>
</Flex>
}
key='1'
>
<PrinterSubJobsTree subJobs={printerData.subJobs} />
</Collapse.Panel>
</Collapse>
</Flex>
<Flex gap={'large'} wrap vertical>
{componentVisibility.temperature && (
<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,374 +185,505 @@ 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
<p>{error || 'Printer not found'}</p> checked={collapseState[section.key]}
<Button icon={<ReloadIcon />} onClick={fetchPrinterDetails}> key={section.key}
Retry onChange={(e) => {
</Button> updateCollapseState(section.key, e.target.checked)
</Space> }}
>
{section.label}
</Checkbox>
))}
</Flex>
</Flex>
) )
} }
return ( return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}> <>
{contextHolder} {contextHolder}
<Flex vertical gap={'large'}> <Flex
<Collapse gap='large'
ghost vertical='true'
collapsible='icon' style={{ height: '100%', minHeight: 0 }}
activeKey={collapseState.info ? ['1'] : []} >
onChange={(keys) => updateCollapseState('info', keys.length > 0)} <Flex justify={'space-between'}>
expandIcon={({ isActive }) => ( <Space size='small'>
<CaretRightOutlined <Dropdown menu={actionItems}>
rotate={isActive ? 90 : 0} <Button>Actions</Button>
style={{ paddingTop: '9px' }} </Dropdown>
/> <Popover
)} content={getViewDropdownItems()}
className='no-h-padding-collapse no-t-padding-collapse' placement='bottomLeft'
> arrow={false}
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Printer Information
</Title>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updatePrinterInfo}
loading={loading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
}
key='1'
>
<Form
form={form}
layout='vertical'
initialValues={{
name: printerData.name || '',
vendor: printerData.vendor || { id: null, name: '' },
moonraker: {
host: printerData.moonraker?.host || '',
port: printerData.moonraker?.port || null,
protocol: printerData.moonraker?.protocol || 'ws',
apiKey: printerData.moonraker?.apiKey || ''
},
tags: printerData.tags || []
}}
> >
<Descriptions <Button>View</Button>
bordered </Popover>
column={{ </Space>
xs: 1, <Space>
sm: 1, {isEditing ? (
md: 1, <>
lg: 2, <Button
xl: 2, icon={<CheckIcon />}
xxl: 2 type='primary'
}} onClick={updatePrinterInfo}
loading={editLoading}
disabled={editLoading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={editLoading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</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)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse no-t-padding-collapse'
> >
{/* Read-only fields */} <Collapse.Panel
<Descriptions.Item label='ID'> header={
<IdText id={printerData._id} type='printer' /> <Flex align='center' gap={'middle'}>
</Descriptions.Item> <InfoCircleIcon />
<Descriptions.Item label='Connected At'> <Title level={5} style={{ margin: 0 }}>
<TimeDisplay Printer Information
dateTime={printerData.connectedAt} </Title>
showSince={true} </Flex>
/> }
</Descriptions.Item> key='info'
>
{/* Editable fields */} <Form
<Descriptions.Item label='Name'> form={form}
{isEditing ? ( layout='vertical'
<Form.Item initialValues={{
name='name' name: printerData?.name || '',
rules={[ vendor: printerData?.vendor || { id: null, name: '' },
{ moonraker: {
required: true, host: printerData?.moonraker?.host || '',
message: 'Please enter a printer name' port: printerData?.moonraker?.port || null,
}, protocol: printerData?.moonraker?.protocol || 'ws',
{ apiKey: printerData?.moonraker?.apiKey || ''
max: 100, },
message: 'Name cannot exceed 100 characters' tags: printerData?.tags || []
} }}
]} >
style={{ margin: 0 }} <Spin
spinning={fetchLoading}
indicator={<LoadingOutlined />}
> >
<Input placeholder='Enter printer name' /> <Descriptions
</Form.Item> bordered
) : ( column={{
printerData.name || 'n/a' xs: 1,
)} sm: 1,
</Descriptions.Item> md: 1,
lg: 2,
<Descriptions.Item label='Host'> xl: 2,
{isEditing ? ( xxl: 2
<Form.Item }}
name={['moonraker', 'host']}
rules={[
{ required: true, message: 'Please enter a host' },
{
pattern:
/^[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$|\b(?:\d{1,3}\.){3}\d{1,3}\b/,
message: 'Please enter a valid hostname or IP address'
}
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter host (e.g., 192.168.1.100)' />
</Form.Item>
) : (
printerData.moonraker?.host || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor'>
{isEditing ? (
<Form.Item
name='vendor'
rules={[
{ required: true, message: 'Please enter a vendor' }
]}
style={{ margin: 0 }}
>
<VendorSelect />
</Form.Item>
) : (
<Space>
<VendorIcon />
{printerData?.vendor?.name || 'n/a'}
</Space>
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor ID'>
{printerData?.vendor ? (
<IdText
id={printerData?.vendor?.id}
type={'vendor'}
showHyperlink={true}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Port'>
{isEditing ? (
<Form.Item
name={['moonraker', 'port']}
rules={[
{
required: true,
message: 'Please enter a port number'
},
{
type: 'number',
min: 1,
max: 65535,
message: 'Port must be between 1 and 65535'
}
]}
style={{ margin: 0 }}
>
<InputNumber
min={1}
max={65535}
placeholder='Enter port'
style={{ width: '100%' }}
/>
</Form.Item>
) : (
printerData.moonraker.port
)}
</Descriptions.Item>
<Descriptions.Item label='Protocol'>
{isEditing ? (
<Form.Item
name={['moonraker', 'protocol']}
rules={[{ required: true, message: 'Port is required' }]}
style={{ margin: 0 }}
>
<Select
defaultValue='ws'
options={[
{ value: 'ws', label: 'Websocket' },
{ value: 'wss', label: 'Websocket Secure' }
]}
/>
</Form.Item>
) : printerData.moonraker.protocol == 'ws' ? (
'Websocket'
) : (
'Websocket Secure'
)}
</Descriptions.Item>
<Descriptions.Item label='API Key'>
{isEditing ? (
<Form.Item
name={['moonraker', 'apiKey']}
style={{ margin: 0 }}
>
<Input.Password placeholder='Enter API key' />
</Form.Item>
) : printerData.moonraker?.apiKey ? (
'Configured'
) : (
'Not configured'
)}
</Descriptions.Item>
<Descriptions.Item label='Status'>
<PrinterState
printer={printerData}
showPrinterName={false}
showControls={false}
showProgress={false}
/>
</Descriptions.Item>
<Descriptions.Item label='Tags'>
{isEditing ? (
<Form.Item name='tags' style={{ margin: 0 }}>
<Space
size={[0, 2]}
wrap
style={{ marginBottom: 4, maxWidth: '300px' }}
> >
{printerData.tags.map((tag) => ( {/* Read-only fields */}
<Tag <Descriptions.Item label='ID'>
key={tag} {printerData?._id ? (
color='blue' <IdText id={printerData._id} type={'printer'} />
closable ) : (
onClose={() => handleTagClose(tag)} <Text>n/a</Text>
style={{ marginBottom: 12 }} )}
> </Descriptions.Item>
{tag} <Descriptions.Item label='Connected At'>
</Tag> {printerData?.connectedAt ? (
))} <TimeDisplay
</Space> dateTime={printerData.connectedAt}
<Space.Compact block> showSince={true}
<Form.Item name='newTag' noStyle> />
<Input placeholder='Add new tag' /> ) : (
</Form.Item> <Text>n/a</Text>
<Button onClick={handleTagAdd} icon={<PlusIcon />} /> )}
</Space.Compact> </Descriptions.Item>
</Form.Item>
) : printerData.tags?.length > 0 ? (
<Space
size={[0, 2]}
wrap
style={{ marginBottom: 4, maxWidth: '300px' }}
>
{printerData.tags.map((tag, index) => (
<Tag key={index} color='blue'>
{tag}
</Tag>
))}
</Space>
) : (
'No tags'
)}
</Descriptions.Item>
<Descriptions.Item label='Firmware Version'> {/* Editable fields */}
{printerData.firmware || 'Unknown'} <Descriptions.Item label='Name'>
</Descriptions.Item> {isEditing ? (
</Descriptions> <Form.Item
</Form> name='name'
</Collapse.Panel> rules={[
</Collapse> {
required: true,
message: 'Please enter a printer name'
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter printer name' />
</Form.Item>
) : printerData?.name ? (
<Text>{printerData.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Collapse <Descriptions.Item label='Host'>
ghost {isEditing ? (
collapsible='icon' <Form.Item
activeKey={collapseState.jobs ? ['2'] : []} name={['moonraker', 'host']}
onChange={(keys) => updateCollapseState('jobs', keys.length > 0)} rules={[
expandIcon={({ isActive }) => ( {
<CaretRightOutlined required: true,
rotate={isActive ? 90 : 0} message: 'Please enter a host'
style={{ paddingTop: '2px' }} },
/> {
)} pattern:
className='no-h-padding-collapse' /^[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:
<Collapse.Panel 'Please enter a valid hostname or IP address'
header={ }
<Title level={5} style={{ margin: 0 }}> ]}
Printer Jobs style={{ margin: 0 }}
</Title> >
} <Input placeholder='Enter host (e.g., 192.168.1.100)' />
key='2' </Form.Item>
> ) : printerData?.moonraker?.host ? (
<PrinterSubJobsList subJobs={printerData.subJobs} /> <Text>{printerData.moonraker.host}</Text>
</Collapse.Panel> ) : (
</Collapse> <Text>n/a</Text>
)}
</Descriptions.Item>
<Collapse <Descriptions.Item label='Vendor'>
ghost {isEditing ? (
collapsible='icon' <Form.Item
activeKey={collapseState.auditLogs ? ['3'] : []} name='vendor'
onChange={(keys) => updateCollapseState('auditLogs', keys.length > 0)} rules={[
expandIcon={({ isActive }) => ( {
<CaretRightOutlined required: true,
rotate={isActive ? 90 : 0} message: 'Please enter a vendor'
style={{ paddingTop: '2px' }} }
/> ]}
)} style={{ margin: 0 }}
className='no-h-padding-collapse' >
> <VendorSelect />
<Collapse.Panel </Form.Item>
header={ ) : printerData?.vendor?.name ? (
<Title level={5} style={{ margin: 0 }}> <Space>
Audit Log <VendorIcon />
</Title> {printerData?.vendor?.name || 'n/a'}
} </Space>
key='3' ) : (
> <Text>n/a</Text>
<AuditLogTable )}
items={printerData.auditLogs || []} </Descriptions.Item>
loading={false}
showTargetColumn={false} <Descriptions.Item label='Vendor ID'>
/> {printerData?.vendor ? (
</Collapse.Panel> <IdText
</Collapse> id={printerData?.vendor?.id}
type={'vendor'}
showHyperlink={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Port'>
{isEditing ? (
<Form.Item
name={['moonraker', 'port']}
rules={[
{
required: true,
message: 'Please enter a port number'
},
{
type: 'number',
min: 1,
max: 65535,
message: 'Port must be between 1 and 65535'
}
]}
style={{ margin: 0 }}
>
<InputNumber
min={1}
max={65535}
placeholder='Enter port'
style={{ width: '100%' }}
/>
</Form.Item>
) : printerData?.moonraker?.port ? (
<Text>{printerData.moonraker.port}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Protocol'>
{isEditing ? (
<Form.Item
name={['moonraker', 'protocol']}
rules={[
{ required: true, message: 'Port is required' }
]}
style={{ margin: 0 }}
>
<Select
defaultValue='ws'
options={[
{ value: 'ws', label: 'Websocket' },
{ value: 'wss', label: 'Websocket Secure' }
]}
/>
</Form.Item>
) : printerData?.moonraker?.protocol == 'ws' ? (
<Text>Websocket</Text>
) : printerData?.moonraker?.protocol == 'wss' ? (
<Text>Websocket Secure</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='API Key'>
{isEditing ? (
<Form.Item
name={['moonraker', 'apiKey']}
style={{ margin: 0 }}
>
<Input.Password placeholder='Enter API key' />
</Form.Item>
) : printerData?.moonraker?.apiKey ? (
<Text>Configured</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Status'>
{printerData?.state ? (
<PrinterState
printer={printerData}
showPrinterName={false}
showControls={false}
showProgress={false}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Tags'>
{isEditing ? (
<Form.Item name='tags' style={{ margin: 0 }}>
<Space
size={[0, 2]}
wrap
style={{ marginBottom: 4, maxWidth: '300px' }}
>
{printerData.tags.map((tag) => (
<Tag
key={tag}
color='blue'
closable
onClose={() => handleTagClose(tag)}
style={{ marginBottom: 12 }}
>
{tag}
</Tag>
))}
</Space>
<Space.Compact block>
<Form.Item name='newTag' noStyle>
<Input placeholder='Add new tag' />
</Form.Item>
<Button
onClick={handleTagAdd}
icon={<PlusIcon />}
/>
</Space.Compact>
</Form.Item>
) : printerData?.tags?.length > 0 ? (
<Space
size={[0, 2]}
wrap
style={{ marginBottom: 4, maxWidth: '300px' }}
>
{printerData.tags.map((tag, index) => (
<Tag key={index} color='blue'>
{tag}
</Tag>
))}
</Space>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Firmware Version'>
{printerData?.firmware ? (
<Text>{printerData.firmware}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</Descriptions>
</Spin>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.jobs ? ['jobs'] : []}
onChange={(keys) =>
updateCollapseState('jobs', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<PrinterIcon />
<Title level={5} style={{ margin: 0 }}>
Printer Jobs
</Title>
</Flex>
}
key='jobs'
>
<PrinterSubJobsList
subJobs={printerData?.subJobs}
loading={fetchLoading}
/>
</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={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 }}>
Audit Log
</Title>
</Flex>
}
key='auditLogs'
>
<AuditLogTable
items={printerData?.auditLogs || []}
loading={fetchLoading}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</Flex>
</div>
)}
</Flex> </Flex>
</div> </>
) )
} }

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>
<Button <Badge count={unreadCount} size='small'>
icon={<BellIcon />} <Button
type='text' icon={<BellIcon />}
style={{ marginTop: '2px' }} type='text'
onClick={() => showSpotlight()} style={{ marginTop: '2px' }}
></Button> onClick={toggleNotificationCenter}
></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,137 +474,221 @@ 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> </Spin>
</Space>
</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}
> >
<Form <Flex vertical gap='large'>
form={form} <Flex vertical gap='middle'>
layout='vertical' <Flex align='center' justify='space-between'>
initialValues={{ <Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
type: 'general', New Note
showMarkdown: false </Title>
}} <Space gap={'small'}>
> <Text type='secondary'>Markdown:</Text>
<Form.Item <Switch onChange={setShowMarkdown} size='small' />
name='type' </Space>
label='Note Type' </Flex>
rules={[{ required: true, message: 'Please select a note type' }]} <Form
> form={newNoteForm}
<Select> layout='vertical'
<Select.Option value='general'>General</Select.Option> onFinish={handleNewNote}
<Select.Option value='task'>Task</Select.Option> initialValues={{ content: '' }}
<Select.Option value='idea'>Idea</Select.Option> onValuesChange={(changedValues) =>
<Select.Option value='bug'>Bug</Select.Option> setNewNoteFormValues((prevValues) => ({
</Select> ...prevValues,
</Form.Item> ...changedValues
}))
<Form.Item }
name='title'
label='Title'
rules={[{ required: true, message: 'Please enter a title' }]}
>
<Input placeholder='Enter note title' />
</Form.Item>
<Form.Item name='showMarkdown' valuePropName='checked'>
<Switch
checkedChildren='Show Markdown'
unCheckedChildren='Hide Markdown'
onChange={setShowMarkdown}
/>
</Form.Item>
<Flex gap='middle'>
<Form.Item
name='content'
label='Content'
rules={[{ required: true, message: 'Please enter note content' }]}
style={{ flex: 1 }}
> >
<TextArea <Flex vertical gap={'large'}>
rows={6} <Flex gap='middle' wrap>
placeholder='Enter note content' <Form.Item
style={{ resize: 'none' }} name='content'
/> rules={[{ required: true, message: '' }]}
</Form.Item> style={{ margin: 0, flexGrow: 1, minWidth: '300px' }}
>
<TextArea
rows={6}
placeholder='Enter note content'
style={{ resize: 'none' }}
/>
</Form.Item>
{showMarkdown && ( {showMarkdown && (
<div style={{ flex: 1 }}> <Card
<Text style={{
type='secondary' flexGrow: 1,
style={{ marginBottom: 8, display: 'block' }} minWidth: '300px',
backgroundColor: () => {
if (newNoteFormValues?.noteType?.color) {
return newNoteFormValues.noteType.color + '26'
}
}
}}
>
<MarkdownDisplay
content={newNoteForm.getFieldValue('content') || ''}
/>
</Card>
)}
</Flex>
<Form.Item
name='noteType'
style={{ margin: 0 }}
rules={[
{ required: true, message: 'Please select a note type' }
]}
> >
Preview <NoteTypeSelect />
</Text> </Form.Item>
<div </Flex>
style={{ </Form>
border: '1px solid #d9d9d9',
borderRadius: '6px',
padding: '8px',
minHeight: '150px',
maxHeight: '300px',
overflow: 'auto'
}}
>
<MarkdownDisplay
content={form.getFieldValue('content') || ''}
/>
</div>
</div>
)}
</Flex> </Flex>
</Form> <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>
</Space>
<Modal
open={deleteConfirmOpen}
title={
<Space size={'middle'}>
<ExclamationOctagonIcon />
Confirm Delete
</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( _id: PropTypes.string.isRequired,
PropTypes.shape({ onNewNote: PropTypes.func
_id: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
createdAt: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
user: PropTypes.object.isRequired
})
),
onNewNote: PropTypes.func.isRequired
} }
export default DashboardNotes 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() },
{ pageNum: initialPage + 1, items: createSkeletonData() }
])
// Fetch both pages const items = await fetchData(initialPage)
return Promise.all([fetchData(initialPage), fetchData(initialPage + 1)])
if (items.length >= 25) {
setPages((prev) => [
...prev,
{ pageNum: initialPage + 1, items: createSkeletonData() }
])
await fetchData(initialPage + 1)
}
}, [initialPage, createSkeletonData, fetchData]) }, [initialPage, createSkeletonData, fetchData])
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
@ -281,23 +303,106 @@ 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}
<Table {cards ? (
ref={tableRef} <Spin indicator={<LoadingOutlined />} spinning={loading}>
dataSource={tableData} {renderCards()}
columns={columnsWithSkeleton} </Spin>
className={'dashboard-table'} ) : (
pagination={false} <Table
scroll={{ y: adjustedScrollHeight }} ref={tableRef}
rowKey='_id' dataSource={tableData}
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }} columns={columnsWithSkeleton}
onScroll={handleScroll} className={'dashboard-table'}
onChange={handleTableChange} pagination={false}
showSorterTooltip={false} scroll={{ y: adjustedScrollHeight }}
style={{ height: '100%' }} rowKey='_id'
/> loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
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,34 +45,81 @@ const IdText = ({
} }
return ( return (
<Flex align={'center'} gap={'small'}> <Flex align={'center'} gap={'small'} className='idtext'>
{contextHolder} {contextHolder}
{showHyperlink && ( {showHyperlink &&
<Link (showSpotlight ? (
onClick={() => { <Popover
if (showHyperlink) { content={
navigate(hyperlink) id && type ? (
<SpotlightTooltip query={prefix + ':' + id} type={type} />
) : null
} }
}} trigger={['hover', 'click']}
> placement='topLeft'
arrow={false}
style={{ padding: 0 }}
>
<Link
onClick={() => {
if (showHyperlink) {
navigate(hyperlink)
}
}}
>
<Text code ellipsis>
<Space size={4}>
{icon}
{displayId}
</Space>
</Text>
</Link>
</Popover>
) : (
<Link
onClick={() => {
if (showHyperlink) {
navigate(hyperlink)
}
}}
>
<Text code ellipsis>
<Space size={4}>
{icon}
{displayId}
</Space>
</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> <Text code ellipsis>
<Space size={4}> <Space size={4}>
{icon} {icon}
{displayId} {displayId}
</Space> </Space>
</Text> </Text>
</Link> ))}
)}
{!showHyperlink && (
<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={() => {
navigator.clipboard const doCopy = (text) => {
.writeText(copyId) if (
.then(() => { navigator &&
messageApi.success('ID copied to clipboard') navigator.clipboard &&
}) navigator.clipboard.writeText
.catch(() => { ) {
messageApi.error('Failed to copy ID') navigator.clipboard
}) .writeText(text)
.then(() => {
messageApi.success('ID copied to clipboard')
})
.catch(() => {
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'
) : (
<Tag color='blue'>{item}</Tag>
)
const { authenticated } = useContext(AuthContext) // getValue/getKey: for leaf, use _id; for tag, use tag string
const getValue = (item, isLeaf) => (isLeaf ? item._id : item)
const fetchPrintersTreeData = useCallback(async () => { const getKey = (item, isLeaf) => (isLeaf ? item._id : item)
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 () => {
const printerData = await fetchPrintersTreeData()
setPrintersData(printerData)
// 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
checkAndFetchData(defaultQuery) 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)
} else {
setInputPrefix(null)
checkAndFetchData(defaultQuery)
}
} else { } else {
setInputPrefix('') 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,17 +109,51 @@ const SpotlightProvider = ({ children }) => {
setLoading(true) setLoading(true)
setListData([]) setListData([])
const response = await axios.get(
`${config.backendUrl}/spotlight/${encodeURIComponent(searchQuery.trim())}`, 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: { headers: {
Accept: 'application/json' Accept: 'application/json'
}, },
withCredentials: true withCredentials: true
} })
) } else {
// For other modes (:, ^), use the original behavior
response = await axios.get(
`${config.backendUrl}/spotlight/${encodeURIComponent(searchQuery.trim())}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
}
setLoading(false) setLoading(false)
setListData(response.data) // 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)
}
// 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) {
console.log('No prefix')
// Check for prefixes at the beginning of the input
const upperValue = value.toUpperCase()
if (upperValue.startsWith('JOB:') || upperValue.startsWith('PRN:')) { // Check if the input contains a prefix (format: XXX:, XXX?, or XXX^)
const parts = upperValue.split(':') const upperValue = value.toUpperCase()
const prefix = parts[0] + ':' const prefixInfo = parsePrefix(upperValue)
const restOfInput = value.substring(prefix.length)
// Set the prefix and update the input without the prefix // If it's a valid prefix
setInputPrefix(prefix) if (prefixInfo) {
if (formRef.current) { setInputPrefix(prefixInfo)
formRef.current.setFieldsValue({ query: restOfInput }) // Remove the prefix from the input value, keeping only what comes after the mode character
// Ensure input gets focus after prefix is set const remainingValue = value.substring(4)
focusInput() if (formRef.current) {
formRef.current.setFieldsValue({ query: remainingValue })
}
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={
<Spin <Flex align='center' gap='small'>
indicator={<LoadingOutlined />} {inputPrefix?.mode && (
spinning={loading} <Text type='secondary' style={{ fontSize: '12px' }}>
size='small' {getModeDescription(inputPrefix.mode)}
/> </Text>
)}
<Spin
indicator={<LoadingOutlined />}
spinning={loading}
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 && (
<List <div style={{ marginLeft: '18px', marginRight: '14px' }}>
bordered <List
dataSource={listData} dataSource={listData}
renderItem={(item) => ( renderItem={(item, index) => {
<List.Item> // Determine type for meta lookup
<List.Item.Meta let type = item.type || inputPrefix?.type
description={ // Fallback: try to infer type from known keys
<Flex gap={'middle'} align='center'> if (!type) {
<Text> if (item.printer) type = 'printer'
{item.printer ? ( else if (item.job) type = 'job'
<PrinterIcon style={{ fontSize: '20px' }} /> // Add more inference as needed
) : null} }
{item.job ? ( const meta = getTypeMeta(type)
<JobIcon style={{ fontSize: '20px' }} /> console.log('meta', inputPrefix?.type)
) : null} const Icon = meta.icon
</Text>
<Flex
vertical
gap={'6px'}
style={{ marginBottom: '2px' }}
>
<Text>{item.name}</Text>
{item.printer ? ( // Determine shortcut text
<Flex gap={'small'}> let shortcutText = ''
<PrinterState if (index === 0) {
printer={item.printer} shortcutText = 'ENTER'
showPrinterName={false} } else if (index <= 10) {
/> shortcutText = (index - 1).toString()
<IdText }
id={item.id}
longId={false} return (
type='printer' <List.Item>
/> <List.Item.Meta
</Flex> description={
) : null} <Flex gap={'middle'} align='center'>
{item.job ? ( <Text>
<Flex gap={'small'}> {Icon ? (
{item.job.state.type ? ( <Icon style={{ fontSize: '20px' }} />
) : null}
</Text>
<Flex gap={'small'} style={{ marginBottom: '2px' }}>
{item.name ? <Text>{item.name}</Text> : null}
{meta.type == 'printer' ? (
<PrinterState
printer={item}
showPrinterName={false}
showProgress={false}
showId={false}
/>
) : null}
{meta.type == 'job' ? (
<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>
) : null}
<IdText
id={item._id}
type={meta.type}
longId={false}
/>
</Flex> </Flex>
) : null} </Flex>
</Flex> }
/>
<Flex gap={'small'}>
{shortcutText && <Text keyboard>{shortcutText}</Text>}
<Button
icon={<InfoCircleIcon />}
size={'small'}
type={'text'}
onClick={() => navigateToItem(item)}
/>
</Flex> </Flex>
} </List.Item>
/> )
<Text keyboard>ENTER</Text> }}
</List.Item> ></List>
)} </div>
></List>
)} )}
</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);
}