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.
29
src/App.css
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
53
src/App.jsx
@ -41,7 +41,7 @@ import PartStocks from './components/Dashboard/Inventory/PartStocks.jsx'
|
|||||||
import StockAudits from './components/Dashboard/Inventory/StockAudits.jsx'
|
import StockAudits from './components/Dashboard/Inventory/StockAudits.jsx'
|
||||||
import StockAuditInfo from './components/Dashboard/Inventory/StockAudits/StockAuditInfo.jsx'
|
import StockAuditInfo from './components/Dashboard/Inventory/StockAudits/StockAuditInfo.jsx'
|
||||||
|
|
||||||
import Dashboard from './components/Dashboard/common/Dashboard'
|
import Dashboard from './components/Dashboard/Dashboard.jsx'
|
||||||
import PrivateRoute from './components/PrivateRoute'
|
import PrivateRoute from './components/PrivateRoute'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
import { SocketProvider } from './components/Dashboard/context/SocketContext.js'
|
import { SocketProvider } from './components/Dashboard/context/SocketContext.js'
|
||||||
@ -59,6 +59,12 @@ import {
|
|||||||
import AppError from './components/App/AppError'
|
import AppError from './components/App/AppError'
|
||||||
import NoteTypes from './components/Dashboard/Management/NoteTypes.jsx'
|
import NoteTypes from './components/Dashboard/Management/NoteTypes.jsx'
|
||||||
import NoteTypeInfo from './components/Dashboard/Management/NoteTypes/NoteTypeInfo.jsx'
|
import NoteTypeInfo from './components/Dashboard/Management/NoteTypes/NoteTypeInfo.jsx'
|
||||||
|
import SessionStorage from './components/Dashboard/Developer/SessionStorage.jsx'
|
||||||
|
import AuthContextDebug from './components/Dashboard/Developer/AuthContextDebug.jsx'
|
||||||
|
import SocketContextDebug from './components/Dashboard/Developer/SocketContextDebug.jsx'
|
||||||
|
import { NotificationProvider } from './components/Dashboard/context/NotificationContext.js'
|
||||||
|
import Users from './components/Dashboard/Management/Users.jsx'
|
||||||
|
import UserInfo from './components/Dashboard/Management/Users/UserInfo.jsx'
|
||||||
|
|
||||||
const AppContent = () => {
|
const AppContent = () => {
|
||||||
const { themeConfig } = useThemeContext()
|
const { themeConfig } = useThemeContext()
|
||||||
@ -67,9 +73,10 @@ const AppContent = () => {
|
|||||||
<ConfigProvider theme={themeConfig}>
|
<ConfigProvider theme={themeConfig}>
|
||||||
<App>
|
<App>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SocketProvider>
|
|
||||||
<SpotlightProvider>
|
|
||||||
<Router>
|
<Router>
|
||||||
|
<SocketProvider>
|
||||||
|
<NotificationProvider>
|
||||||
|
<SpotlightProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path='/'
|
path='/'
|
||||||
@ -93,7 +100,10 @@ const AppContent = () => {
|
|||||||
path='production/overview'
|
path='production/overview'
|
||||||
element={<ProductionOverview />}
|
element={<ProductionOverview />}
|
||||||
/>
|
/>
|
||||||
<Route path='production/printers' element={<Printers />} />
|
<Route
|
||||||
|
path='production/printers'
|
||||||
|
element={<Printers />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='production/printers/control'
|
path='production/printers/control'
|
||||||
element={<ControlPrinter />}
|
element={<ControlPrinter />}
|
||||||
@ -103,7 +113,10 @@ const AppContent = () => {
|
|||||||
element={<PrinterInfo />}
|
element={<PrinterInfo />}
|
||||||
/>
|
/>
|
||||||
<Route path='production/jobs' element={<Jobs />} />
|
<Route path='production/jobs' element={<Jobs />} />
|
||||||
<Route path='production/jobs/info' element={<JobInfo />} />
|
<Route
|
||||||
|
path='production/jobs/info'
|
||||||
|
element={<JobInfo />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='production/gcodefiles'
|
path='production/gcodefiles'
|
||||||
element={<GCodeFiles />}
|
element={<GCodeFiles />}
|
||||||
@ -153,12 +166,19 @@ const AppContent = () => {
|
|||||||
path='management/parts/info'
|
path='management/parts/info'
|
||||||
element={<PartInfo />}
|
element={<PartInfo />}
|
||||||
/>
|
/>
|
||||||
<Route path='management/products' element={<Products />} />
|
<Route
|
||||||
|
path='management/products'
|
||||||
|
element={<Products />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='management/products/info'
|
path='management/products/info'
|
||||||
element={<ProductInfo />}
|
element={<ProductInfo />}
|
||||||
/>
|
/>
|
||||||
<Route path='management/vendors' element={<Vendors />} />
|
<Route path='management/vendors' element={<Vendors />} />
|
||||||
|
<Route
|
||||||
|
path='management/users/info'
|
||||||
|
element={<UserInfo />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='management/vendors/info'
|
path='management/vendors/info'
|
||||||
element={<VendorInfo />}
|
element={<VendorInfo />}
|
||||||
@ -171,15 +191,31 @@ const AppContent = () => {
|
|||||||
path='management/notetypes'
|
path='management/notetypes'
|
||||||
element={<NoteTypes />}
|
element={<NoteTypes />}
|
||||||
/>
|
/>
|
||||||
|
<Route path='management/users' element={<Users />} />
|
||||||
<Route
|
<Route
|
||||||
path='management/notetypes/info'
|
path='management/notetypes/info'
|
||||||
element={<NoteTypeInfo />}
|
element={<NoteTypeInfo />}
|
||||||
/>
|
/>
|
||||||
<Route path='management/settings' element={<Settings />} />
|
<Route
|
||||||
|
path='management/settings'
|
||||||
|
element={<Settings />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='management/auditlogs'
|
path='management/auditlogs'
|
||||||
element={<AuditLogs />}
|
element={<AuditLogs />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path='developer/sessionstorage'
|
||||||
|
element={<SessionStorage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path='developer/authcontextdebug'
|
||||||
|
element={<AuthContextDebug />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path='developer/socketcontextdebug'
|
||||||
|
element={<SocketContextDebug />}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
path='*'
|
path='*'
|
||||||
@ -191,9 +227,10 @@ const AppContent = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
|
||||||
</SpotlightProvider>
|
</SpotlightProvider>
|
||||||
|
</NotificationProvider>
|
||||||
</SocketProvider>
|
</SocketProvider>
|
||||||
|
</Router>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</App>
|
</App>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
|
|||||||
@ -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 |
@ -1,9 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
<g transform="matrix(0.587492,0,0,0.587492,32,25.3907)">
|
<g transform="matrix(1,0,0,1,0,-0.603074)">
|
||||||
<rect x="0" y="0" width="54.469" height="65.719" style="fill-opacity:0;"/>
|
<g transform="matrix(0.55961,0,0,0.55961,30.6237,24.378)">
|
||||||
<path d="M27.141,65.719C27.781,65.719 28.688,65.453 29.5,65.031C47.813,55.5 54.281,50.531 54.281,39.141L54.281,16.016C54.281,11.563 52.797,9.766 48.906,8.094C45.813,6.797 34.453,2.969 31.547,2.016C30.172,1.594 28.5,1.328 27.141,1.328C25.781,1.328 24.109,1.641 22.75,2.016C19.844,2.828 8.469,6.813 5.375,8.094C1.5,9.75 0,11.563 0,16.016L0,39.141C0,50.531 6.484,55.484 24.781,65.031C25.609,65.453 26.5,65.719 27.141,65.719ZM27.141,57.547C26.781,57.547 26.438,57.422 25.578,56.891C11.563,48.25 7.469,46.297 7.469,37.953L7.469,17.063C7.469,15.891 7.719,15.391 8.625,15.031C13.031,13.266 21.594,10.438 24.828,9.203C25.891,8.859 26.516,8.719 27.141,8.719C27.766,8.719 28.375,8.875 29.453,9.203C32.688,10.438 41.219,13.375 45.672,15.031C46.547,15.375 46.813,15.891 46.813,17.063L46.813,37.953C46.813,46.438 42.281,48.828 28.703,56.891C27.859,57.406 27.5,57.547 27.141,57.547Z" style="fill-rule:nonzero;"/>
|
<path d="M27.141,65.719C27.781,65.719 28.688,65.453 29.5,65.031C47.813,55.5 54.281,50.531 54.281,39.141L54.281,16.016C54.281,11.563 52.797,9.766 48.906,8.094C45.813,6.797 34.453,2.969 31.547,2.016C30.172,1.594 28.5,1.328 27.141,1.328C25.781,1.328 24.109,1.641 22.75,2.016C19.844,2.828 8.469,6.813 5.375,8.094C1.5,9.75 0,11.563 0,16.016L0,39.141C0,50.531 6.484,55.484 24.781,65.031C25.609,65.453 26.5,65.719 27.141,65.719ZM27.141,57.547C26.781,57.547 26.438,57.422 25.578,56.891C11.563,48.25 7.469,46.297 7.469,37.953L7.469,17.063C7.469,15.891 7.719,15.391 8.625,15.031C13.031,13.266 21.594,10.438 24.828,9.203C25.891,8.859 26.516,8.719 27.141,8.719C27.766,8.719 28.375,8.875 29.453,9.203C32.688,10.438 41.219,13.375 45.672,15.031C46.547,15.375 46.813,15.891 46.813,17.063L46.813,37.953C46.813,46.438 42.281,48.828 28.703,56.891C27.859,57.406 27.5,57.547 27.141,57.547Z" style="fill-rule:nonzero;"/>
|
||||||
</g>
|
</g>
|
||||||
|
<g transform="matrix(0.952541,0,0,0.952541,0.142378,0.192271)">
|
||||||
<path d="M10.65,56.399C10.65,54.429 9.1,52.819 7.081,52.819C5.148,52.819 3.561,54.45 3.561,56.399C3.561,58.34 5.148,59.949 7.081,59.949C9.057,59.949 10.65,58.34 10.65,56.399ZM31.147,53.788L17.05,53.788C15.433,53.788 14.226,54.904 14.226,56.399C14.226,57.888 15.433,59.035 17.05,59.035L35.425,59.035C33.338,57.368 31.955,55.709 31.147,53.788ZM10.65,44.582C10.65,42.617 9.1,41.032 7.081,41.032C5.148,41.032 3.561,42.633 3.561,44.582C3.561,46.523 5.148,48.107 7.081,48.107C9.057,48.107 10.65,46.523 10.65,44.582ZM30.242,41.97L17.05,41.97C15.433,41.97 14.226,43.087 14.226,44.582C14.226,46.076 15.433,47.218 17.05,47.218L30.242,47.218L30.242,41.97ZM30.242,35.341L11.704,35.341C5.368,35.341 3,32.505 3,26.425L3,12.967C3,6.882 5.368,4.051 11.704,4.051L52.296,4.051C58.632,4.051 61,6.882 61,12.967L61,26.425C61,26.988 60.98,27.522 60.938,28.03C59.436,27.47 57.18,26.685 55.137,25.987L55.137,12.807C55.137,10.413 54.318,9.337 51.948,9.337L12.052,9.337C9.682,9.337 8.863,10.413 8.863,12.807L8.863,26.585C8.863,28.98 9.682,30.055 12.052,30.055L31.321,30.055C30.566,30.894 30.242,31.995 30.242,33.666L30.242,35.341Z" style="fill-rule:nonzero;"/>
|
<path d="M10.65,56.399C10.65,54.429 9.1,52.819 7.081,52.819C5.148,52.819 3.561,54.45 3.561,56.399C3.561,58.34 5.148,59.949 7.081,59.949C9.057,59.949 10.65,58.34 10.65,56.399ZM31.147,53.788L17.05,53.788C15.433,53.788 14.226,54.904 14.226,56.399C14.226,57.888 15.433,59.035 17.05,59.035L35.425,59.035C33.338,57.368 31.955,55.709 31.147,53.788ZM10.65,44.582C10.65,42.617 9.1,41.032 7.081,41.032C5.148,41.032 3.561,42.633 3.561,44.582C3.561,46.523 5.148,48.107 7.081,48.107C9.057,48.107 10.65,46.523 10.65,44.582ZM30.242,41.97L17.05,41.97C15.433,41.97 14.226,43.087 14.226,44.582C14.226,46.076 15.433,47.218 17.05,47.218L30.242,47.218L30.242,41.97ZM30.242,35.341L11.704,35.341C5.368,35.341 3,32.505 3,26.425L3,12.967C3,6.882 5.368,4.051 11.704,4.051L52.296,4.051C58.632,4.051 61,6.882 61,12.967L61,26.425C61,26.988 60.98,27.522 60.938,28.03C59.436,27.47 57.18,26.685 55.137,25.987L55.137,12.807C55.137,10.413 54.318,9.337 51.948,9.337L12.052,9.337C9.682,9.337 8.863,10.413 8.863,12.807L8.863,26.585C8.863,28.98 9.682,30.055 12.052,30.055L31.321,30.055C30.566,30.894 30.242,31.995 30.242,33.666L30.242,35.341Z" style="fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.7 KiB |
BIN
src/assets/icons/developericon.afdesign
Normal file
1
src/assets/icons/developericon.min.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 64 64"><path d="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 |
8
src/assets/icons/developericon.svg
Normal 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 |
BIN
src/assets/icons/gridicon.afdesign
Normal file
1
src/assets/icons/gridicon.min.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 64 64"><path d="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 |
18
src/assets/icons/gridicon.svg
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(0.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 |
BIN
src/assets/icons/listicon.afdesign
Normal file
1
src/assets/icons/listicon.min.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 64 64"><path d="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 |
9
src/assets/icons/listicon.svg
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(0.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 |
BIN
src/assets/icons/noteicon.afdesign
Normal file
1
src/assets/icons/noteicon.min.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 64 64"><path d="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 |
8
src/assets/icons/noteicon.svg
Normal 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 |
BIN
src/assets/icons/threedotsicon.afdesign
Normal file
1
src/assets/icons/threedotsicon.min.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 64 64"><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 |
13
src/assets/icons/threedotsicon.svg
Normal 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 |
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
105
src/components/Dashboard/Developer/AuthContextDebug.jsx
Normal 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
|
||||||
41
src/components/Dashboard/Developer/DeveloperSidebar.jsx
Normal 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
|
||||||
86
src/components/Dashboard/Developer/SessionStorage.jsx
Normal 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
|
||||||
77
src/components/Dashboard/Developer/SocketContextDebug.jsx
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
@ -11,9 +11,13 @@ import {
|
|||||||
Form,
|
Form,
|
||||||
Badge,
|
Badge,
|
||||||
Collapse,
|
Collapse,
|
||||||
Flex
|
Flex,
|
||||||
|
Dropdown,
|
||||||
|
Popover,
|
||||||
|
Card,
|
||||||
|
Checkbox
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||||
import IdText from '../../common/IdText'
|
import IdText from '../../common/IdText'
|
||||||
import { SocketContext } from '../../context/SocketContext'
|
import { SocketContext } from '../../context/SocketContext'
|
||||||
import FilamentStockState from '../../common/FilamentStockState'
|
import FilamentStockState from '../../common/FilamentStockState'
|
||||||
@ -22,10 +26,16 @@ import useCollapseState from '../../hooks/useCollapseState'
|
|||||||
import TimeDisplay from '../../common/TimeDisplay'
|
import TimeDisplay from '../../common/TimeDisplay'
|
||||||
import FilamentIcon from '../../../Icons/FilamentIcon'
|
import FilamentIcon from '../../../Icons/FilamentIcon'
|
||||||
import ReloadIcon from '../../../Icons/ReloadIcon'
|
import ReloadIcon from '../../../Icons/ReloadIcon'
|
||||||
|
import AuditLogTable from '../../common/AuditLogTable'
|
||||||
|
import DashboardNotes from '../../common/DashboardNotes'
|
||||||
|
|
||||||
import config from '../../../../config'
|
import config from '../../../../config'
|
||||||
|
import FilamentStockIcon from '../../../Icons/FilamentStockIcon'
|
||||||
|
import InfoCircleIcon from '../../../Icons/InfoCircleIcon'
|
||||||
|
import NoteIcon from '../../../Icons/NoteIcon'
|
||||||
|
import AuditLogIcon from '../../../Icons/AuditLogIcon'
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
const FilamentStockInfo = () => {
|
const FilamentStockInfo = () => {
|
||||||
const [filamentStockData, setFilamentStockData] = useState(null)
|
const [filamentStockData, setFilamentStockData] = useState(null)
|
||||||
@ -43,7 +53,9 @@ const FilamentStockInfo = () => {
|
|||||||
'FilamentStockInfo',
|
'FilamentStockInfo',
|
||||||
{
|
{
|
||||||
info: true,
|
info: true,
|
||||||
events: true
|
events: true,
|
||||||
|
notes: true,
|
||||||
|
auditLogs: true
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -108,15 +120,49 @@ const FilamentStockInfo = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fetchLoading) {
|
const actionItems = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Reload Filament Stock',
|
||||||
|
key: 'reload',
|
||||||
|
icon: <ReloadIcon />
|
||||||
|
}
|
||||||
|
],
|
||||||
|
onClick: ({ key }) => {
|
||||||
|
if (key === 'reload') {
|
||||||
|
fetchFilamentStockDetails()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getViewDropdownItems = () => {
|
||||||
|
const sections = [
|
||||||
|
{ key: 'info', label: 'Filament Stock Information' },
|
||||||
|
{ key: 'events', label: 'Filament Stock Events' },
|
||||||
|
{ key: 'notes', label: 'Notes' },
|
||||||
|
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
<Flex vertical>
|
||||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
|
||||||
</div>
|
{sections.map((section) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={collapseState[section.key]}
|
||||||
|
key={section.key}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateCollapseState(section.key, e.target.checked)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{section.label}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !filamentStockData) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Space
|
<Space
|
||||||
direction='vertical'
|
direction='vertical'
|
||||||
@ -131,27 +177,61 @@ const FilamentStockInfo = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
<>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
|
<Flex
|
||||||
|
gap='large'
|
||||||
|
vertical='true'
|
||||||
|
style={{ height: '100%', minHeight: 0 }}
|
||||||
|
>
|
||||||
|
<Flex justify={'space-between'}>
|
||||||
|
<Space size='small'>
|
||||||
|
<Dropdown menu={actionItems}>
|
||||||
|
<Button>Actions</Button>
|
||||||
|
</Dropdown>
|
||||||
|
<Popover
|
||||||
|
content={getViewDropdownItems()}
|
||||||
|
placement='bottomLeft'
|
||||||
|
arrow={false}
|
||||||
|
>
|
||||||
|
<Button>View</Button>
|
||||||
|
</Popover>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Space
|
||||||
|
direction='vertical'
|
||||||
|
style={{ width: '100%', textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
<p>{error || 'FilamentStock not found'}</p>
|
||||||
|
<Button icon={<ReloadIcon />} onClick={fetchFilamentStockDetails}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||||
<Flex vertical gap={'large'}>
|
<Flex vertical gap={'large'}>
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
collapsible='icon'
|
expandIconPosition='end'
|
||||||
activeKey={collapseState.info ? ['1'] : []}
|
activeKey={collapseState.info ? ['1'] : []}
|
||||||
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('info', keys.length > 0)
|
||||||
|
}
|
||||||
expandIcon={({ isActive }) => (
|
expandIcon={({ isActive }) => (
|
||||||
<CaretRightOutlined
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '2px' }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
className='no-h-padding-collapse no-t-padding-collapse'
|
className='no-h-padding-collapse no-t-padding-collapse'
|
||||||
>
|
>
|
||||||
<Collapse.Panel
|
<Collapse.Panel
|
||||||
header={
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<InfoCircleIcon />
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
Filament Stock Information
|
Filament Stock Information
|
||||||
</Title>
|
</Title>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
key='1'
|
key='1'
|
||||||
>
|
>
|
||||||
@ -159,8 +239,12 @@ const FilamentStockInfo = () => {
|
|||||||
form={form}
|
form={form}
|
||||||
layout='vertical'
|
layout='vertical'
|
||||||
initialValues={{
|
initialValues={{
|
||||||
filament: filamentStockData.filament || {}
|
filament: filamentStockData?.filament || {}
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<Spin
|
||||||
|
indicator={<LoadingOutlined />}
|
||||||
|
spinning={fetchLoading}
|
||||||
>
|
>
|
||||||
<Descriptions
|
<Descriptions
|
||||||
bordered
|
bordered
|
||||||
@ -175,32 +259,49 @@ const FilamentStockInfo = () => {
|
|||||||
>
|
>
|
||||||
{/* Read-only fields */}
|
{/* Read-only fields */}
|
||||||
<Descriptions.Item label='ID' span={1}>
|
<Descriptions.Item label='ID' span={1}>
|
||||||
{filamentStockData.id ? (
|
{filamentStockData?.id ? (
|
||||||
<IdText id={filamentStockData.id} type={'filamentstock'} />
|
<IdText
|
||||||
|
id={filamentStockData.id}
|
||||||
|
type={'filamentstock'}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Created At'>
|
<Descriptions.Item label='Created At'>
|
||||||
|
{filamentStockData?.createdAt ? (
|
||||||
<TimeDisplay
|
<TimeDisplay
|
||||||
dateTime={filamentStockData.createdAt}
|
dateTime={filamentStockData.createdAt}
|
||||||
showSince={true}
|
showSince={true}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='State'>
|
<Descriptions.Item label='State'>
|
||||||
<FilamentStockState filamentStock={filamentStockData} />
|
{filamentStockData ? (
|
||||||
|
<FilamentStockState
|
||||||
|
filamentStock={filamentStockData}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Updated At'>
|
<Descriptions.Item label='Updated At'>
|
||||||
|
{filamentStockData?.updatedAt ? (
|
||||||
<TimeDisplay
|
<TimeDisplay
|
||||||
dateTime={filamentStockData.updatedAt}
|
dateTime={filamentStockData.updatedAt}
|
||||||
showSince={true}
|
showSince={true}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Filament Name'>
|
<Descriptions.Item label='Filament Name'>
|
||||||
{filamentStockData.filament ? (
|
{filamentStockData?.filament ? (
|
||||||
<Space>
|
<Space>
|
||||||
<FilamentIcon />
|
<FilamentIcon />
|
||||||
<Badge
|
<Badge
|
||||||
@ -209,83 +310,162 @@ const FilamentStockInfo = () => {
|
|||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Filament ID' span={1}>
|
<Descriptions.Item label='Filament ID' span={1}>
|
||||||
{filamentStockData.filament ? (
|
{filamentStockData?.filament ? (
|
||||||
<IdText
|
<IdText
|
||||||
id={filamentStockData.filament.id}
|
id={filamentStockData.filament.id}
|
||||||
type={'filament'}
|
type={'filament'}
|
||||||
showHyperlink={true}
|
showHyperlink={true}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Current Weight'>
|
<Descriptions.Item label='Current Weight'>
|
||||||
{filamentStockData.currentGrossWeight ? (
|
{filamentStockData?.currentGrossWeight ? (
|
||||||
<Descriptions style={{ width: '250px' }} column={2}>
|
<Descriptions style={{ width: '250px' }} column={2}>
|
||||||
<Descriptions.Item label='Net'>
|
<Descriptions.Item label='Net'>
|
||||||
{filamentStockData.currentNetWeight.toFixed(2) + 'g'}
|
{filamentStockData.currentNetWeight.toFixed(2) +
|
||||||
|
'g'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Gross'>
|
<Descriptions.Item label='Gross'>
|
||||||
{filamentStockData.currentGrossWeight.toFixed(2) + 'g'}
|
{filamentStockData.currentGrossWeight.toFixed(
|
||||||
|
2
|
||||||
|
) + 'g'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Starting Weight'>
|
<Descriptions.Item label='Starting Weight'>
|
||||||
{filamentStockData.startingGrossWeight ? (
|
{filamentStockData?.startingGrossWeight ? (
|
||||||
<Space>
|
<Space>
|
||||||
<Descriptions style={{ width: '250px' }} column={2}>
|
<Descriptions
|
||||||
|
style={{ width: '250px' }}
|
||||||
|
column={2}
|
||||||
|
>
|
||||||
<Descriptions.Item label='Net'>
|
<Descriptions.Item label='Net'>
|
||||||
{filamentStockData.startingNetWeight.toFixed(2) + 'g'}
|
{filamentStockData.startingNetWeight.toFixed(
|
||||||
|
2
|
||||||
|
) + 'g'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Gross'>
|
<Descriptions.Item label='Gross'>
|
||||||
{filamentStockData.startingGrossWeight.toFixed(2) +
|
{filamentStockData.startingGrossWeight.toFixed(
|
||||||
'g'}
|
2
|
||||||
|
) + 'g'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
</Space>
|
</Space>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
</Spin>
|
||||||
</Form>
|
</Form>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
collapsible='icon'
|
expandIconPosition='end'
|
||||||
activeKey={collapseState.events ? ['2'] : []}
|
activeKey={collapseState.events ? ['2'] : []}
|
||||||
onChange={(keys) => updateCollapseState('events', keys.length > 0)}
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('events', keys.length > 0)
|
||||||
|
}
|
||||||
expandIcon={({ isActive }) => (
|
expandIcon={({ isActive }) => (
|
||||||
<CaretRightOutlined
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '2px' }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
className='no-h-padding-collapse'
|
className='no-h-padding-collapse'
|
||||||
>
|
>
|
||||||
<Collapse.Panel
|
<Collapse.Panel
|
||||||
header={
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<FilamentStockIcon />
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
Filament Stock Events
|
Filament Stock Events
|
||||||
</Title>
|
</Title>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
key='2'
|
key='2'
|
||||||
>
|
>
|
||||||
<StockEventTable stockEvents={filamentStockData.stockEvents} />
|
<Spin indicator={<LoadingOutlined />} spinning={fetchLoading}>
|
||||||
|
<StockEventTable
|
||||||
|
stockEvents={filamentStockData?.stockEvents || []}
|
||||||
|
/>
|
||||||
|
</Spin>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
expandIconPosition='end'
|
||||||
|
activeKey={collapseState.notes ? ['notes'] : []}
|
||||||
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('notes', keys.length > 0)
|
||||||
|
}
|
||||||
|
expandIcon={({ isActive }) => (
|
||||||
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
|
)}
|
||||||
|
className='no-h-padding-collapse'
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<NoteIcon />
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Notes
|
||||||
|
</Title>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
key='notes'
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<DashboardNotes _id={filamentStockId} />
|
||||||
|
</Card>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
expandIconPosition='end'
|
||||||
|
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
||||||
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('auditLogs', keys.length > 0)
|
||||||
|
}
|
||||||
|
expandIcon={({ isActive }) => (
|
||||||
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
|
)}
|
||||||
|
className='no-h-padding-collapse'
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<AuditLogIcon />
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Audit Logs
|
||||||
|
</Title>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
key='auditLogs'
|
||||||
|
>
|
||||||
|
<AuditLogTable
|
||||||
|
items={filamentStockData?.auditLogs || []}
|
||||||
|
loading={fetchLoading}
|
||||||
|
showTargetColumn={false}
|
||||||
|
/>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
73
src/components/Dashboard/Inventory/InventorySidebar.jsx
Normal 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
|
||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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
|
||||||
)}
|
)}
|
||||||
@ -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)) {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import {
|
import {
|
||||||
Descriptions,
|
Descriptions,
|
||||||
@ -15,14 +15,13 @@ import {
|
|||||||
InputNumber,
|
InputNumber,
|
||||||
ColorPicker,
|
ColorPicker,
|
||||||
Select,
|
Select,
|
||||||
Modal,
|
Collapse,
|
||||||
Collapse
|
Dropdown,
|
||||||
|
Popover,
|
||||||
|
Checkbox,
|
||||||
|
Card
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import {
|
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||||
LoadingOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
CaretRightOutlined
|
|
||||||
} from '@ant-design/icons'
|
|
||||||
import IdText from '../../common/IdText'
|
import IdText from '../../common/IdText'
|
||||||
import ReloadIcon from '../../../Icons/ReloadIcon'
|
import ReloadIcon from '../../../Icons/ReloadIcon'
|
||||||
import EditIcon from '../../../Icons/EditIcon.jsx'
|
import EditIcon from '../../../Icons/EditIcon.jsx'
|
||||||
@ -31,28 +30,33 @@ import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
|||||||
import TimeDisplay from '../../common/TimeDisplay.jsx'
|
import TimeDisplay from '../../common/TimeDisplay.jsx'
|
||||||
import VendorSelect from '../../common/VendorSelect'
|
import VendorSelect from '../../common/VendorSelect'
|
||||||
import useCollapseState from '../../hooks/useCollapseState'
|
import useCollapseState from '../../hooks/useCollapseState'
|
||||||
|
import AuditLogTable from '../../common/AuditLogTable'
|
||||||
|
import DashboardNotes from '../../common/DashboardNotes'
|
||||||
|
|
||||||
import config from '../../../../config.js'
|
import config from '../../../../config.js'
|
||||||
|
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
|
||||||
|
import NoteIcon from '../../../Icons/NoteIcon.jsx'
|
||||||
|
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
|
||||||
|
|
||||||
const { Title, Link, Text } = Typography
|
const { Title, Link, Text } = Typography
|
||||||
|
|
||||||
const FilamentInfo = () => {
|
const FilamentInfo = () => {
|
||||||
const [filamentData, setFilamentData] = useState(null)
|
const [filamentData, setFilamentData] = useState(null)
|
||||||
const [fetchLoading, setFetchLoading] = useState(true)
|
const [fetchLoading, setFetchLoading] = useState(true)
|
||||||
const [loading, setLoading] = useState(false)
|
const [editLoading, setEditLoading] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [messageApi, contextHolder] = message.useMessage()
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
const filamentId = new URLSearchParams(location.search).get('filamentId')
|
const filamentId = new URLSearchParams(location.search).get('filamentId')
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
const navigate = useNavigate()
|
|
||||||
const [collapseState, updateCollapseState] = useCollapseState(
|
const [collapseState, updateCollapseState] = useCollapseState(
|
||||||
'FilamentInfo',
|
'FilamentInfo',
|
||||||
{
|
{
|
||||||
info: true,
|
info: true,
|
||||||
details: true
|
details: true,
|
||||||
|
notes: true,
|
||||||
|
auditLogs: true
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -92,6 +96,7 @@ const FilamentInfo = () => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
setFilamentData(response.data)
|
setFilamentData(response.data)
|
||||||
|
form.setFieldsValue(response.data)
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to fetch filament details')
|
setError('Failed to fetch filament details')
|
||||||
@ -128,7 +133,7 @@ const FilamentInfo = () => {
|
|||||||
const updateFilamentInfo = async () => {
|
const updateFilamentInfo = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
setLoading(true)
|
setEditLoading(true)
|
||||||
|
|
||||||
await axios.put(
|
await axios.put(
|
||||||
`${config.backendUrl}/filaments/${filamentId}`,
|
`${config.backendUrl}/filaments/${filamentId}`,
|
||||||
@ -165,36 +170,52 @@ const FilamentInfo = () => {
|
|||||||
messageApi.error('Failed to update filament information')
|
messageApi.error('Failed to update filament information')
|
||||||
} finally {
|
} finally {
|
||||||
fetchFilamentDetails()
|
fetchFilamentDetails()
|
||||||
setLoading(false)
|
setEditLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const actionItems = {
|
||||||
try {
|
items: [
|
||||||
setLoading(true)
|
{
|
||||||
await axios.delete(`${config.backendUrl}/filaments/${filamentId}`, {
|
label: 'Reload Filament',
|
||||||
withCredentials: true
|
key: 'reload',
|
||||||
})
|
icon: <ReloadIcon />
|
||||||
messageApi.success('Filament deleted successfully')
|
}
|
||||||
navigate('/dashboard/filaments')
|
],
|
||||||
} catch (err) {
|
onClick: ({ key }) => {
|
||||||
console.error('Failed to delete filament:', err)
|
if (key === 'reload') {
|
||||||
messageApi.error('Failed to delete filament')
|
fetchFilamentDetails()
|
||||||
} finally {
|
}
|
||||||
setLoading(false)
|
|
||||||
setIsDeleteModalOpen(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fetchLoading) {
|
const getViewDropdownItems = () => {
|
||||||
|
const sections = [
|
||||||
|
{ key: 'info', label: 'Filament Information' },
|
||||||
|
{ key: 'notes', label: 'Notes' },
|
||||||
|
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
<Flex vertical>
|
||||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
|
||||||
</div>
|
{sections.map((section) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={collapseState[section.key]}
|
||||||
|
key={section.key}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateCollapseState(section.key, e.target.checked)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{section.label}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !filamentData) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Space
|
<Space
|
||||||
direction='vertical'
|
direction='vertical'
|
||||||
@ -209,52 +230,40 @@ const FilamentInfo = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
<>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
|
|
||||||
<Flex vertical gap={'large'}>
|
|
||||||
<Collapse
|
|
||||||
ghost
|
|
||||||
collapsible='icon'
|
|
||||||
activeKey={collapseState.info ? ['1'] : []}
|
|
||||||
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
|
|
||||||
expandIcon={({ isActive }) => (
|
|
||||||
<CaretRightOutlined
|
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '9px' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
className='no-h-padding-collapse no-t-padding-collapse'
|
|
||||||
>
|
|
||||||
<Collapse.Panel
|
|
||||||
header={
|
|
||||||
<Flex
|
<Flex
|
||||||
align='center'
|
gap='large'
|
||||||
justify='space-between'
|
vertical='true'
|
||||||
style={{ width: '100%' }}
|
style={{ height: '100%', minHeight: 0 }}
|
||||||
>
|
>
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Flex justify={'space-between'}>
|
||||||
Filament Information
|
<Space size='small'>
|
||||||
</Title>
|
<Dropdown menu={actionItems}>
|
||||||
|
<Button>Actions</Button>
|
||||||
|
</Dropdown>
|
||||||
|
<Popover
|
||||||
|
content={getViewDropdownItems()}
|
||||||
|
placement='bottomLeft'
|
||||||
|
arrow={false}
|
||||||
|
>
|
||||||
|
<Button>View</Button>
|
||||||
|
</Popover>
|
||||||
|
</Space>
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
danger
|
|
||||||
onClick={() => setIsDeleteModalOpen(true)}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
icon={<CheckIcon />}
|
icon={<CheckIcon />}
|
||||||
type='primary'
|
type='primary'
|
||||||
onClick={updateFilamentInfo}
|
onClick={updateFilamentInfo}
|
||||||
loading={loading}
|
loading={editLoading}
|
||||||
|
disabled={editLoading}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon={<XMarkIcon />}
|
icon={<XMarkIcon />}
|
||||||
onClick={cancelEditing}
|
onClick={cancelEditing}
|
||||||
disabled={loading}
|
disabled={editLoading}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -262,6 +271,40 @@ const FilamentInfo = () => {
|
|||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Space
|
||||||
|
direction='vertical'
|
||||||
|
style={{ width: '100%', textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
<p>{error || 'Print job not found'}</p>
|
||||||
|
<Button icon={<ReloadIcon />} onClick={fetchFilamentDetails}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||||
|
<Flex vertical gap={'large'}>
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
expandIconPosition='end'
|
||||||
|
activeKey={collapseState.info ? ['1'] : []}
|
||||||
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('info', keys.length > 0)
|
||||||
|
}
|
||||||
|
expandIcon={({ isActive }) => (
|
||||||
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
|
)}
|
||||||
|
className='no-h-padding-collapse no-t-padding-collapse'
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<InfoCircleIcon />
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Filament Information
|
||||||
|
</Title>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
key='1'
|
key='1'
|
||||||
>
|
>
|
||||||
@ -269,16 +312,20 @@ const FilamentInfo = () => {
|
|||||||
form={form}
|
form={form}
|
||||||
layout='vertical'
|
layout='vertical'
|
||||||
initialValues={{
|
initialValues={{
|
||||||
name: filamentData.name || '',
|
name: filamentData?.name || '',
|
||||||
vendor: filamentData.vendor || { id: null, name: '' },
|
vendor: filamentData?.vendor || { id: null, name: '' },
|
||||||
type: filamentData.type || '',
|
type: filamentData?.type || '',
|
||||||
cost: filamentData.cost || null,
|
cost: filamentData?.cost || null,
|
||||||
color: filamentData.color || '#000000',
|
color: filamentData?.color || '#000000',
|
||||||
diameter: filamentData.diameter || null,
|
diameter: filamentData?.diameter || null,
|
||||||
density: filamentData.density || null,
|
density: filamentData?.density || null,
|
||||||
url: filamentData.url || '',
|
url: filamentData?.url || '',
|
||||||
barcode: filamentData.barcode || ''
|
barcode: filamentData?.barcode || ''
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<Spin
|
||||||
|
indicator={<LoadingOutlined />}
|
||||||
|
spinning={fetchLoading}
|
||||||
>
|
>
|
||||||
<Descriptions
|
<Descriptions
|
||||||
bordered
|
bordered
|
||||||
@ -292,21 +339,24 @@ const FilamentInfo = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Descriptions.Item label='ID' span={1}>
|
<Descriptions.Item label='ID' span={1}>
|
||||||
{filamentData.id ? (
|
{filamentData?._id ? (
|
||||||
<IdText id={filamentData.id} type={'filament'} />
|
<IdText id={filamentData._id} type={'filament'} />
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Created At'>
|
<Descriptions.Item label='Created At'>
|
||||||
|
{filamentData?.createdAt ? (
|
||||||
<TimeDisplay
|
<TimeDisplay
|
||||||
dateTime={filamentData.createdAt}
|
dateTime={filamentData.createdAt}
|
||||||
showSince={true}
|
showSince={true}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Name'>
|
<Descriptions.Item label='Name'>
|
||||||
<Flex align={'center'} gap={'small'}>
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name='name'
|
name='name'
|
||||||
@ -324,49 +374,58 @@ const FilamentInfo = () => {
|
|||||||
>
|
>
|
||||||
<Input placeholder='Enter filament name' />
|
<Input placeholder='Enter filament name' />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
) : filamentData?.name ? (
|
||||||
|
<Text>{filamentData.name}</Text>
|
||||||
) : (
|
) : (
|
||||||
filamentData.name || 'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Updated At'>
|
<Descriptions.Item label='Updated At'>
|
||||||
|
{filamentData?.updatedAt ? (
|
||||||
<TimeDisplay
|
<TimeDisplay
|
||||||
dateTime={filamentData.updatedAt}
|
dateTime={filamentData.updatedAt}
|
||||||
showSince={true}
|
showSince={true}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Vendor'>
|
<Descriptions.Item label='Vendor'>
|
||||||
<Flex align={'center'} gap={'small'}>
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name='vendor'
|
name='vendor'
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: 'Please enter a vendor' }
|
{
|
||||||
|
required: true,
|
||||||
|
message: 'Please enter a vendor'
|
||||||
|
}
|
||||||
]}
|
]}
|
||||||
style={{ margin: 0 }}
|
style={{ margin: 0 }}
|
||||||
>
|
>
|
||||||
<VendorSelect />
|
<VendorSelect />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : filamentData.vendor.name ? (
|
) : filamentData?.vendor?.name ? (
|
||||||
<Text>{filamentData.vendor.name}</Text>
|
<Text>{filamentData.vendor.name}</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text>n/a</Text>
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Vendor ID'>
|
<Descriptions.Item label='Vendor ID'>
|
||||||
|
{filamentData?.vendor?.id ? (
|
||||||
<IdText
|
<IdText
|
||||||
id={filamentData.vendor.id}
|
id={filamentData.vendor.id}
|
||||||
type={'vendor'}
|
type={'vendor'}
|
||||||
showHyperlink={true}
|
showHyperlink={true}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Material'>
|
<Descriptions.Item label='Material'>
|
||||||
<Flex align={'center'} gap={'small'}>
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name='type'
|
name='type'
|
||||||
@ -387,20 +446,23 @@ const FilamentInfo = () => {
|
|||||||
<Select.Option value='TPU'>TPU</Select.Option>
|
<Select.Option value='TPU'>TPU</Select.Option>
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
) : filamentData?.type ? (
|
||||||
|
<Text>{filamentData.type}</Text>
|
||||||
) : (
|
) : (
|
||||||
filamentData.type || 'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Cost'>
|
<Descriptions.Item label='Cost'>
|
||||||
<Flex align={'center'} gap={'small'}>
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name='cost'
|
name='cost'
|
||||||
style={{ margin: 0 }}
|
style={{ margin: 0 }}
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: 'Please enter a cost' }
|
{
|
||||||
|
required: true,
|
||||||
|
message: 'Please enter a cost'
|
||||||
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
@ -409,12 +471,11 @@ const FilamentInfo = () => {
|
|||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : filamentData.cost ? (
|
) : filamentData?.cost ? (
|
||||||
`£${filamentData.cost}/kg`
|
<Text>{`£${filamentData.cost}/kg`}</Text>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Color'>
|
<Descriptions.Item label='Color'>
|
||||||
@ -424,7 +485,10 @@ const FilamentInfo = () => {
|
|||||||
name='color'
|
name='color'
|
||||||
style={{ margin: 0 }}
|
style={{ margin: 0 }}
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: 'Please select a color' }
|
{
|
||||||
|
required: true,
|
||||||
|
message: 'Please select a color'
|
||||||
|
}
|
||||||
]}
|
]}
|
||||||
getValueFromEvent={(color) => {
|
getValueFromEvent={(color) => {
|
||||||
return '#' + color.toHex()
|
return '#' + color.toHex()
|
||||||
@ -432,123 +496,160 @@ const FilamentInfo = () => {
|
|||||||
>
|
>
|
||||||
<ColorPicker showText disabledAlpha />
|
<ColorPicker showText disabledAlpha />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : (
|
) : filamentData?.color ? (
|
||||||
<Badge
|
<Badge
|
||||||
color={filamentData.color}
|
color={filamentData.color}
|
||||||
text={filamentData.color}
|
text={filamentData.color}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Diameter'>
|
<Descriptions.Item label='Diameter'>
|
||||||
<Flex align={'center'} gap={'small'}>
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name='diameter'
|
name='diameter'
|
||||||
style={{ margin: 0 }}
|
style={{ margin: 0 }}
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: 'Please enter a diameter' }
|
{
|
||||||
|
required: true,
|
||||||
|
message: 'Please enter a diameter'
|
||||||
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<InputNumber suffix='mm' style={{ width: '100%' }} />
|
<InputNumber
|
||||||
|
suffix='mm'
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : filamentData.diameter ? (
|
) : filamentData?.diameter ? (
|
||||||
`${filamentData.diameter}mm`
|
<Text>{`${filamentData.diameter}mm`}</Text>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Density'>
|
<Descriptions.Item label='Density'>
|
||||||
<Flex align={'center'} gap={'small'}>
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name='density'
|
name='density'
|
||||||
style={{ margin: 0 }}
|
style={{ margin: 0 }}
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: 'Please enter a density' }
|
{
|
||||||
|
required: true,
|
||||||
|
message: 'Please enter a density'
|
||||||
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<InputNumber suffix='g/cm³' style={{ width: '100%' }} />
|
<InputNumber
|
||||||
|
suffix='g/cm³'
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : filamentData.density ? (
|
) : filamentData?.density ? (
|
||||||
`${filamentData.density}g/cm³`
|
<Text>{`${filamentData.density}g/cm³`}</Text>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='URL'>
|
<Descriptions.Item label='URL'>
|
||||||
<Flex align={'center'} gap={'small'}>
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Form.Item name='url' style={{ margin: 0 }}>
|
<Form.Item name='url' style={{ margin: 0 }}>
|
||||||
<Input placeholder='Enter URL' />
|
<Input placeholder='Enter URL' />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : filamentData.url ? (
|
) : filamentData?.url ? (
|
||||||
<Link href={filamentData.url} target='_blank'>
|
<Link href={filamentData.url} target='_blank'>
|
||||||
{filamentData.url}
|
{filamentData.url}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Barcode'>
|
<Descriptions.Item label='Barcode'>
|
||||||
<Flex align={'center'} gap={'small'}>
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Form.Item name='barcode' style={{ margin: 0 }}>
|
<Form.Item name='barcode' style={{ margin: 0 }}>
|
||||||
<Input placeholder='Enter barcode' />
|
<Input placeholder='Enter barcode' />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
) : filamentData?.barcode ? (
|
||||||
|
<Text>{filamentData.barcode}</Text>
|
||||||
) : (
|
) : (
|
||||||
filamentData.barcode || 'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
</Spin>
|
||||||
</Form>
|
</Form>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
collapsible='icon'
|
expandIconPosition='end'
|
||||||
activeKey={collapseState.details ? ['2'] : []}
|
activeKey={collapseState.notes ? ['notes'] : []}
|
||||||
onChange={(keys) => updateCollapseState('details', keys.length > 0)}
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('notes', keys.length > 0)
|
||||||
|
}
|
||||||
expandIcon={({ isActive }) => (
|
expandIcon={({ isActive }) => (
|
||||||
<CaretRightOutlined
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '2px' }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
className='no-h-padding-collapse'
|
className='no-h-padding-collapse'
|
||||||
>
|
>
|
||||||
<Collapse.Panel
|
<Collapse.Panel
|
||||||
header={
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<NoteIcon />
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
Additional Details
|
Notes
|
||||||
</Title>
|
</Title>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
key='2'
|
key='notes'
|
||||||
>
|
>
|
||||||
{/* Add any additional details sections here */}
|
<Card>
|
||||||
|
<DashboardNotes _id={filamentId} />
|
||||||
|
</Card>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
expandIconPosition='end'
|
||||||
|
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
||||||
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('auditLogs', keys.length > 0)
|
||||||
|
}
|
||||||
|
expandIcon={({ isActive }) => (
|
||||||
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
|
)}
|
||||||
|
className='no-h-padding-collapse'
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<AuditLogIcon />
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Audit Logs
|
||||||
|
</Title>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
key='auditLogs'
|
||||||
|
>
|
||||||
|
<AuditLogTable
|
||||||
|
items={filamentData?.auditLogs || []}
|
||||||
|
loading={fetchLoading}
|
||||||
|
showTargetColumn={false}
|
||||||
|
/>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<Modal
|
|
||||||
title='Delete Filament'
|
|
||||||
open={isDeleteModalOpen}
|
|
||||||
onOk={handleDelete}
|
|
||||||
onCancel={() => setIsDeleteModalOpen(false)}
|
|
||||||
confirmLoading={loading}
|
|
||||||
>
|
|
||||||
<p>Are you sure you want to delete this filament?</p>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
109
src/components/Dashboard/Management/ManagementSidebar.jsx
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@ -15,12 +15,11 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
ColorPicker,
|
ColorPicker,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Tag,
|
|
||||||
Dropdown,
|
Dropdown,
|
||||||
Popover,
|
Popover,
|
||||||
Badge
|
Badge
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||||
import IdText from '../../common/IdText'
|
import IdText from '../../common/IdText'
|
||||||
import TimeDisplay from '../../common/TimeDisplay'
|
import TimeDisplay from '../../common/TimeDisplay'
|
||||||
import ReloadIcon from '../../../Icons/ReloadIcon'
|
import ReloadIcon from '../../../Icons/ReloadIcon'
|
||||||
@ -31,8 +30,11 @@ import useCollapseState from '../../hooks/useCollapseState'
|
|||||||
import AuditLogTable from '../../common/AuditLogTable'
|
import AuditLogTable from '../../common/AuditLogTable'
|
||||||
|
|
||||||
import config from '../../../../config.js'
|
import config from '../../../../config.js'
|
||||||
|
import BoolDisplay from '../../common/BoolDisplay.jsx'
|
||||||
|
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
|
||||||
|
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
const NoteTypeInfo = () => {
|
const NoteTypeInfo = () => {
|
||||||
const [noteTypeData, setNoteTypeData] = useState(null)
|
const [noteTypeData, setNoteTypeData] = useState(null)
|
||||||
@ -224,24 +226,21 @@ const NoteTypeInfo = () => {
|
|||||||
<Flex vertical gap={'large'}>
|
<Flex vertical gap={'large'}>
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
collapsible='icon'
|
expandIconPosition='end'
|
||||||
activeKey={collapseState.info ? ['1'] : []}
|
activeKey={collapseState.info ? ['1'] : []}
|
||||||
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
|
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
|
||||||
expandIcon={({ isActive }) => (
|
expandIcon={({ isActive }) => (
|
||||||
<CaretRightOutlined
|
<CaretLeftOutlined
|
||||||
rotate={isActive ? 90 : 0}
|
rotate={isActive ? 90 : 0}
|
||||||
style={{ paddingTop: '9px' }}
|
style={{ paddingTop: '2px' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
className='no-h-padding-collapse no-t-padding-collapse'
|
className='no-h-padding-collapse no-t-padding-collapse'
|
||||||
>
|
>
|
||||||
<Collapse.Panel
|
<Collapse.Panel
|
||||||
header={
|
header={
|
||||||
<Flex
|
<Flex align='center' gap={'small'}>
|
||||||
align='center'
|
<InfoCircleIcon />
|
||||||
justify='space-between'
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
>
|
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
Note Type Information
|
Note Type Information
|
||||||
</Title>
|
</Title>
|
||||||
@ -337,7 +336,7 @@ const NoteTypeInfo = () => {
|
|||||||
text={noteTypeData.color}
|
text={noteTypeData.color}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
'No color set'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
@ -350,10 +349,13 @@ const NoteTypeInfo = () => {
|
|||||||
>
|
>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : noteTypeData?.active ? (
|
) : noteTypeData ? (
|
||||||
<Tag color='success'>Yes</Tag>
|
<BoolDisplay
|
||||||
|
value={noteTypeData.active}
|
||||||
|
yesNo={true}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Tag color='error'>No</Tag>
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
@ -364,13 +366,13 @@ const NoteTypeInfo = () => {
|
|||||||
|
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
collapsible='icon'
|
expandIconPosition='end'
|
||||||
activeKey={collapseState.auditLogs ? ['2'] : []}
|
activeKey={collapseState.auditLogs ? ['2'] : []}
|
||||||
onChange={(keys) =>
|
onChange={(keys) =>
|
||||||
updateCollapseState('auditLogs', keys.length > 0)
|
updateCollapseState('auditLogs', keys.length > 0)
|
||||||
}
|
}
|
||||||
expandIcon={({ isActive }) => (
|
expandIcon={({ isActive }) => (
|
||||||
<CaretRightOutlined
|
<CaretLeftOutlined
|
||||||
rotate={isActive ? 90 : 0}
|
rotate={isActive ? 90 : 0}
|
||||||
style={{ paddingTop: '2px' }}
|
style={{ paddingTop: '2px' }}
|
||||||
/>
|
/>
|
||||||
@ -379,9 +381,12 @@ const NoteTypeInfo = () => {
|
|||||||
>
|
>
|
||||||
<Collapse.Panel
|
<Collapse.Panel
|
||||||
header={
|
header={
|
||||||
|
<Flex align='center' gap={'small'}>
|
||||||
|
<AuditLogIcon />
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
Audit Logs
|
Audit Logs
|
||||||
</Title>
|
</Title>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
key='2'
|
key='2'
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -16,9 +16,11 @@ import {
|
|||||||
InputNumber,
|
InputNumber,
|
||||||
Switch,
|
Switch,
|
||||||
Tag,
|
Tag,
|
||||||
Collapse
|
Collapse,
|
||||||
|
Dropdown,
|
||||||
|
Popover
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||||
import IdText from '../../common/IdText.jsx'
|
import IdText from '../../common/IdText.jsx'
|
||||||
import { StlViewer } from 'react-stl-viewer'
|
import { StlViewer } from 'react-stl-viewer'
|
||||||
import ReloadIcon from '../../../Icons/ReloadIcon'
|
import ReloadIcon from '../../../Icons/ReloadIcon'
|
||||||
@ -27,10 +29,17 @@ import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
|
|||||||
import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
||||||
import useCollapseState from '../../hooks/useCollapseState'
|
import useCollapseState from '../../hooks/useCollapseState'
|
||||||
import TimeDisplay from '../../common/TimeDisplay.jsx'
|
import TimeDisplay from '../../common/TimeDisplay.jsx'
|
||||||
|
import AuditLogTable from '../../common/AuditLogTable'
|
||||||
|
import DashboardNotes from '../../common/DashboardNotes'
|
||||||
|
|
||||||
import config from '../../../../config.js'
|
import config from '../../../../config.js'
|
||||||
|
import BoolDisplay from '../../common/BoolDisplay.jsx'
|
||||||
|
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
|
||||||
|
import PartIcon from '../../../Icons/PartIcon.jsx'
|
||||||
|
import NoteIcon from '../../../Icons/NoteIcon.jsx'
|
||||||
|
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
const PartInfo = () => {
|
const PartInfo = () => {
|
||||||
const [partData, setPartData] = useState(null)
|
const [partData, setPartData] = useState(null)
|
||||||
@ -43,7 +52,9 @@ const PartInfo = () => {
|
|||||||
const [useGlobalPricing, setUseGlobalPricing] = useState(true)
|
const [useGlobalPricing, setUseGlobalPricing] = useState(true)
|
||||||
const [collapseState, updateCollapseState] = useCollapseState('PartInfo', {
|
const [collapseState, updateCollapseState] = useCollapseState('PartInfo', {
|
||||||
info: true,
|
info: true,
|
||||||
preview: true
|
preview: true,
|
||||||
|
notes: true,
|
||||||
|
auditLogs: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const [partForm] = Form.useForm()
|
const [partForm] = Form.useForm()
|
||||||
@ -178,9 +189,16 @@ const PartInfo = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cancelEditing = () => {
|
const cancelEditing = () => {
|
||||||
|
// Reset form values to original data
|
||||||
|
if (partData) {
|
||||||
partForm.setFieldsValue({
|
partForm.setFieldsValue({
|
||||||
name: partData?.name || ''
|
name: partData.name || '',
|
||||||
|
price: partData.price || null,
|
||||||
|
margin: partData.margin || null,
|
||||||
|
marginOrPrice: partData.marginOrPrice,
|
||||||
|
useGlobalPricing: partData.useGlobalPricing
|
||||||
})
|
})
|
||||||
|
}
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -213,15 +231,49 @@ const PartInfo = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fetchLoading) {
|
const actionItems = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Reload Part',
|
||||||
|
key: 'reload',
|
||||||
|
icon: <ReloadIcon />
|
||||||
|
}
|
||||||
|
],
|
||||||
|
onClick: ({ key }) => {
|
||||||
|
if (key === 'reload') {
|
||||||
|
fetchPartDetails()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getViewDropdownItems = () => {
|
||||||
|
const sections = [
|
||||||
|
{ key: 'info', label: 'Part Information' },
|
||||||
|
{ key: 'preview', label: 'Part Preview' },
|
||||||
|
{ key: 'notes', label: 'Notes' },
|
||||||
|
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
<Flex vertical>
|
||||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
|
||||||
</div>
|
{sections.map((section) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={collapseState[section.key]}
|
||||||
|
key={section.key}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateCollapseState(section.key, e.target.checked)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{section.label}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !partData) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Space
|
<Space
|
||||||
direction='vertical'
|
direction='vertical'
|
||||||
@ -236,32 +288,26 @@ const PartInfo = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
<>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<Flex vertical gap={'large'}>
|
|
||||||
<Collapse
|
|
||||||
ghost
|
|
||||||
collapsible='icon'
|
|
||||||
activeKey={collapseState.info ? ['1'] : []}
|
|
||||||
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
|
|
||||||
expandIcon={({ isActive }) => (
|
|
||||||
<CaretRightOutlined
|
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '9px' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
className='no-h-padding-collapse no-t-padding-collapse'
|
|
||||||
>
|
|
||||||
<Collapse.Panel
|
|
||||||
header={
|
|
||||||
<Flex
|
<Flex
|
||||||
align='center'
|
gap='large'
|
||||||
justify='space-between'
|
vertical='true'
|
||||||
style={{ width: '100%' }}
|
style={{ height: '100%', minHeight: 0 }}
|
||||||
>
|
>
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Flex justify={'space-between'}>
|
||||||
Part Information
|
<Space size='small'>
|
||||||
</Title>
|
<Dropdown menu={actionItems}>
|
||||||
|
<Button>Actions</Button>
|
||||||
|
</Dropdown>
|
||||||
|
<Popover
|
||||||
|
content={getViewDropdownItems()}
|
||||||
|
placement='bottomLeft'
|
||||||
|
arrow={false}
|
||||||
|
>
|
||||||
|
<Button>View</Button>
|
||||||
|
</Popover>
|
||||||
|
</Space>
|
||||||
<Space>
|
<Space>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<>
|
<>
|
||||||
@ -270,6 +316,7 @@ const PartInfo = () => {
|
|||||||
type='primary'
|
type='primary'
|
||||||
onClick={updateInfo}
|
onClick={updateInfo}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon={<XMarkIcon />}
|
icon={<XMarkIcon />}
|
||||||
@ -282,6 +329,40 @@ const PartInfo = () => {
|
|||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Space
|
||||||
|
direction='vertical'
|
||||||
|
style={{ width: '100%', textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
<p>{error || 'Part not found'}</p>
|
||||||
|
<Button icon={<ReloadIcon />} onClick={fetchPartDetails}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||||
|
<Flex vertical gap={'large'}>
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
expandIconPosition='end'
|
||||||
|
activeKey={collapseState.info ? ['1'] : []}
|
||||||
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('info', keys.length > 0)
|
||||||
|
}
|
||||||
|
expandIcon={({ isActive }) => (
|
||||||
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
|
)}
|
||||||
|
className='no-h-padding-collapse no-t-padding-collapse'
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<InfoCircleIcon />
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Part Information
|
||||||
|
</Title>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
key='1'
|
key='1'
|
||||||
>
|
>
|
||||||
@ -295,10 +376,14 @@ const PartInfo = () => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
initialValues={{
|
initialValues={{
|
||||||
name: partData.name || '',
|
name: partData?.name || '',
|
||||||
version: partData.version || '',
|
version: partData?.version || '',
|
||||||
tags: partData.tags || []
|
tags: partData?.tags || []
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<Spin
|
||||||
|
indicator={<LoadingOutlined />}
|
||||||
|
spinning={fetchLoading}
|
||||||
>
|
>
|
||||||
<Descriptions
|
<Descriptions
|
||||||
bordered
|
bordered
|
||||||
@ -312,14 +397,21 @@ const PartInfo = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Descriptions.Item label='ID' span={1}>
|
<Descriptions.Item label='ID' span={1}>
|
||||||
{partData.id ? (
|
{partData?.id ? (
|
||||||
<IdText id={partData.id} type='part'></IdText>
|
<IdText id={partData.id} type='part'></IdText>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Created At'>
|
<Descriptions.Item label='Created At'>
|
||||||
<TimeDisplay dateTime={partData.createdAt} showSince={true} />
|
{partData?.createdAt ? (
|
||||||
|
<TimeDisplay
|
||||||
|
dateTime={partData.createdAt}
|
||||||
|
showSince={true}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Name' span={1}>
|
<Descriptions.Item label='Name' span={1}>
|
||||||
@ -340,26 +432,41 @@ const PartInfo = () => {
|
|||||||
>
|
>
|
||||||
<Input placeholder='Enter product name' />
|
<Input placeholder='Enter product name' />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
) : partData?.name ? (
|
||||||
|
<Text>{partData.name}</Text>
|
||||||
) : (
|
) : (
|
||||||
partData.name || 'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Updated At'>
|
<Descriptions.Item label='Updated At'>
|
||||||
<TimeDisplay dateTime={partData.updatedAt} showSince={true} />
|
{partData?.updatedAt ? (
|
||||||
|
<TimeDisplay
|
||||||
|
dateTime={partData.updatedAt}
|
||||||
|
showSince={true}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Product Name' span={1}>
|
<Descriptions.Item label='Product Name' span={1}>
|
||||||
{partData.product.name || 'n/a'}
|
{partData?.product?.name ? (
|
||||||
|
<Text>{partData.product.name}</Text>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Product ID' span={1}>
|
<Descriptions.Item label='Product ID' span={1}>
|
||||||
{(
|
{partData?.product?._id ? (
|
||||||
<IdText
|
<IdText
|
||||||
id={partData.product._id}
|
id={partData.product._id}
|
||||||
type={'product'}
|
type={'product'}
|
||||||
showHyperlink={true}
|
showHyperlink={true}
|
||||||
/>
|
/>
|
||||||
) || 'n/a'}
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item
|
<Descriptions.Item
|
||||||
label={!marginOrPrice ? 'Margin' : 'Price'}
|
label={!marginOrPrice ? 'Margin' : 'Price'}
|
||||||
@ -412,16 +519,16 @@ const PartInfo = () => {
|
|||||||
<Checkbox>Price</Checkbox>
|
<Checkbox>Price</Checkbox>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Flex>
|
</Flex>
|
||||||
) : partData.margin &&
|
) : partData?.margin &&
|
||||||
marginOrPrice == false &&
|
marginOrPrice == false &&
|
||||||
partData.useGlobalPricing == false ? (
|
partData?.useGlobalPricing == false ? (
|
||||||
partData.margin + '%'
|
<Text>{partData.margin + '%'}</Text>
|
||||||
) : partData.price &&
|
) : partData?.price &&
|
||||||
marginOrPrice == true &&
|
marginOrPrice == true &&
|
||||||
partData.useGlobalPricing == false ? (
|
partData?.useGlobalPricing == false ? (
|
||||||
'£' + partData.price
|
<Text>{'£' + partData.price}</Text>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Global Pricing'>
|
<Descriptions.Item label='Global Pricing'>
|
||||||
@ -439,48 +546,57 @@ const PartInfo = () => {
|
|||||||
>
|
>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : partData.useGlobalPricing == true ? (
|
) : partData ? (
|
||||||
<Tag color='success' icon={<CheckIcon />}>
|
<BoolDisplay
|
||||||
Yes
|
value={partData.useGlobalPricing}
|
||||||
</Tag>
|
yesNo={true}
|
||||||
) : partData.useGlobalPricing == false ? (
|
/>
|
||||||
<Tag icon={<XMarkIcon />}>No</Tag>
|
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Version' span={1}>
|
<Descriptions.Item label='Version' span={1}>
|
||||||
{partData.version || 'n/a'}
|
{partData?.version ? (
|
||||||
|
<Text>{partData.version}</Text>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Tags'>
|
<Descriptions.Item label='Tags'>
|
||||||
{partData.tags &&
|
{partData?.tags && partData.tags.length > 0 ? (
|
||||||
partData.tags.map((tag, index) => (
|
partData.tags.map((tag, index) => (
|
||||||
<Tag key={index}>{tag}</Tag>
|
<Tag key={index}>{tag}</Tag>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
</Spin>
|
||||||
</Form>
|
</Form>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
collapsible='icon'
|
expandIconPosition='end'
|
||||||
activeKey={collapseState.preview ? ['2'] : []}
|
activeKey={collapseState.preview ? ['2'] : []}
|
||||||
onChange={(keys) => updateCollapseState('preview', keys.length > 0)}
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('preview', keys.length > 0)
|
||||||
|
}
|
||||||
expandIcon={({ isActive }) => (
|
expandIcon={({ isActive }) => (
|
||||||
<CaretRightOutlined
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '2px' }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
className='no-h-padding-collapse'
|
className='no-h-padding-collapse'
|
||||||
>
|
>
|
||||||
<Collapse.Panel
|
<Collapse.Panel
|
||||||
header={
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<PartIcon />
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
Part Preview
|
Part Preview
|
||||||
</Title>
|
</Title>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
key='2'
|
key='2'
|
||||||
>
|
>
|
||||||
@ -496,7 +612,9 @@ const PartInfo = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Space direction='vertical' align='center'>
|
<Space direction='vertical' align='center'>
|
||||||
<XMarkIcon style={{ fontSize: '24px', color: '#ff4d4f' }} />
|
<XMarkIcon
|
||||||
|
style={{ fontSize: '24px', color: '#ff4d4f' }}
|
||||||
|
/>
|
||||||
<Typography.Text type='danger'>
|
<Typography.Text type='danger'>
|
||||||
{stlLoadError}
|
{stlLoadError}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
@ -518,8 +636,71 @@ const PartInfo = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
expandIconPosition='end'
|
||||||
|
activeKey={collapseState.notes ? ['notes'] : []}
|
||||||
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('notes', keys.length > 0)
|
||||||
|
}
|
||||||
|
expandIcon={({ isActive }) => (
|
||||||
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
|
)}
|
||||||
|
className='no-h-padding-collapse'
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<NoteIcon />
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Notes
|
||||||
|
</Title>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
key='notes'
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<DashboardNotes _id={partId} />
|
||||||
|
</Card>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
expandIconPosition='end'
|
||||||
|
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
||||||
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('auditLogs', keys.length > 0)
|
||||||
|
}
|
||||||
|
expandIcon={({ isActive }) => (
|
||||||
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
|
)}
|
||||||
|
className='no-h-padding-collapse'
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<AuditLogIcon />
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Audit Logs
|
||||||
|
</Title>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
key='auditLogs'
|
||||||
|
>
|
||||||
|
<AuditLogTable
|
||||||
|
items={partData?.auditLogs || []}
|
||||||
|
loading={fetchLoading}
|
||||||
|
showTargetColumn={false}
|
||||||
|
/>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -14,9 +14,12 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Collapse
|
Collapse,
|
||||||
|
Dropdown,
|
||||||
|
Popover,
|
||||||
|
Card
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||||
import IdText from '../../common/IdText.jsx'
|
import IdText from '../../common/IdText.jsx'
|
||||||
import VendorSelect from '../../common/VendorSelect.jsx'
|
import VendorSelect from '../../common/VendorSelect.jsx'
|
||||||
import PartsTable from '../../common/PartsTable.jsx'
|
import PartsTable from '../../common/PartsTable.jsx'
|
||||||
@ -27,10 +30,16 @@ import EditIcon from '../../../Icons/EditIcon.jsx'
|
|||||||
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
|
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
|
||||||
import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
||||||
import TimeDisplay from '../../common/TimeDisplay.jsx'
|
import TimeDisplay from '../../common/TimeDisplay.jsx'
|
||||||
|
import AuditLogTable from '../../common/AuditLogTable'
|
||||||
|
import DashboardNotes from '../../common/DashboardNotes'
|
||||||
|
|
||||||
import config from '../../../../config.js'
|
import config from '../../../../config.js'
|
||||||
|
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
|
||||||
|
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
|
||||||
|
import ProductIcon from '../../../Icons/ProductIcon.jsx'
|
||||||
|
import NoteIcon from '../../../Icons/NoteIcon.jsx'
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
const ProductInfo = () => {
|
const ProductInfo = () => {
|
||||||
const [productData, setProductData] = useState(null)
|
const [productData, setProductData] = useState(null)
|
||||||
@ -44,7 +53,9 @@ const ProductInfo = () => {
|
|||||||
const [marginOrPrice, setMarginOrPrice] = useState(false)
|
const [marginOrPrice, setMarginOrPrice] = useState(false)
|
||||||
const [collapseState, updateCollapseState] = useCollapseState('ProductInfo', {
|
const [collapseState, updateCollapseState] = useCollapseState('ProductInfo', {
|
||||||
info: true,
|
info: true,
|
||||||
parts: true
|
parts: true,
|
||||||
|
notes: true,
|
||||||
|
auditLogs: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const [productForm] = Form.useForm()
|
const [productForm] = Form.useForm()
|
||||||
@ -125,17 +136,20 @@ const ProductInfo = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cancelEditing = () => {
|
const cancelEditing = () => {
|
||||||
|
// Reset form values to original data
|
||||||
|
if (productData) {
|
||||||
productForm.setFieldsValue({
|
productForm.setFieldsValue({
|
||||||
name: productData?.name || '',
|
name: productData.name || '',
|
||||||
vendor: productData?.vendor || { id: null, name: '' },
|
vendor: productData.vendor || { id: null, name: '' },
|
||||||
version: productData?.version || '',
|
version: productData.version || '',
|
||||||
tags: productData?.tags || [],
|
tags: productData.tags || [],
|
||||||
cost: productData?.cost || null,
|
cost: productData.cost || null,
|
||||||
price: productData?.price || null,
|
price: productData.price || null,
|
||||||
margin: productData?.margin || null,
|
margin: productData.margin || null,
|
||||||
marginOrPrice: productData?.marginOrPrice || null
|
marginOrPrice: productData.marginOrPrice || null
|
||||||
})
|
})
|
||||||
setMarginOrPrice(productData?.marginOrPrice)
|
setMarginOrPrice(productData.marginOrPrice)
|
||||||
|
}
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,15 +183,49 @@ const ProductInfo = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fetchLoading) {
|
const actionItems = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Reload Product',
|
||||||
|
key: 'reload',
|
||||||
|
icon: <ReloadIcon />
|
||||||
|
}
|
||||||
|
],
|
||||||
|
onClick: ({ key }) => {
|
||||||
|
if (key === 'reload') {
|
||||||
|
fetchProductDetails()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getViewDropdownItems = () => {
|
||||||
|
const sections = [
|
||||||
|
{ key: 'info', label: 'Product Information' },
|
||||||
|
{ key: 'parts', label: 'Product Parts' },
|
||||||
|
{ key: 'notes', label: 'Notes' },
|
||||||
|
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
<Flex vertical>
|
||||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
|
||||||
</div>
|
{sections.map((section) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={collapseState[section.key]}
|
||||||
|
key={section.key}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateCollapseState(section.key, e.target.checked)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{section.label}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !productData) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Space
|
<Space
|
||||||
direction='vertical'
|
direction='vertical'
|
||||||
@ -192,32 +240,26 @@ const ProductInfo = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
<>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<Flex vertical gap={'large'}>
|
|
||||||
<Collapse
|
|
||||||
ghost
|
|
||||||
collapsible='icon'
|
|
||||||
activeKey={collapseState.info ? ['1'] : []}
|
|
||||||
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
|
|
||||||
expandIcon={({ isActive }) => (
|
|
||||||
<CaretRightOutlined
|
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '9px' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
className='no-h-padding-collapse no-t-padding-collapse'
|
|
||||||
>
|
|
||||||
<Collapse.Panel
|
|
||||||
header={
|
|
||||||
<Flex
|
<Flex
|
||||||
align='center'
|
gap='large'
|
||||||
justify='space-between'
|
vertical='true'
|
||||||
style={{ width: '100%' }}
|
style={{ height: '100%', minHeight: 0 }}
|
||||||
>
|
>
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Flex justify={'space-between'}>
|
||||||
Product Information
|
<Space size='small'>
|
||||||
</Title>
|
<Dropdown menu={actionItems}>
|
||||||
|
<Button>Actions</Button>
|
||||||
|
</Dropdown>
|
||||||
|
<Popover
|
||||||
|
content={getViewDropdownItems()}
|
||||||
|
placement='bottomLeft'
|
||||||
|
arrow={false}
|
||||||
|
>
|
||||||
|
<Button>View</Button>
|
||||||
|
</Popover>
|
||||||
|
</Space>
|
||||||
<Space>
|
<Space>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<>
|
<>
|
||||||
@ -226,6 +268,7 @@ const ProductInfo = () => {
|
|||||||
type='primary'
|
type='primary'
|
||||||
onClick={updateInfo}
|
onClick={updateInfo}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon={<XMarkIcon />}
|
icon={<XMarkIcon />}
|
||||||
@ -238,6 +281,40 @@ const ProductInfo = () => {
|
|||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Space
|
||||||
|
direction='vertical'
|
||||||
|
style={{ width: '100%', textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
<p>{error || 'Product not found'}</p>
|
||||||
|
<Button icon={<ReloadIcon />} onClick={fetchProductDetails}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||||
|
<Flex vertical gap={'large'}>
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
expandIconPosition='end'
|
||||||
|
activeKey={collapseState.info ? ['1'] : []}
|
||||||
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('info', keys.length > 0)
|
||||||
|
}
|
||||||
|
expandIcon={({ isActive }) => (
|
||||||
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
|
)}
|
||||||
|
className='no-h-padding-collapse no-t-padding-collapse'
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<InfoCircleIcon />
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Product Information
|
||||||
|
</Title>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
key='1'
|
key='1'
|
||||||
>
|
>
|
||||||
@ -251,11 +328,15 @@ const ProductInfo = () => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
initialValues={{
|
initialValues={{
|
||||||
name: productData.name || '',
|
name: productData?.name || '',
|
||||||
vendor: productData.vendor || { id: null, name: '' },
|
vendor: productData?.vendor || { id: null, name: '' },
|
||||||
version: productData.version || '',
|
version: productData?.version || '',
|
||||||
tags: productData.tags || []
|
tags: productData?.tags || []
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<Spin
|
||||||
|
indicator={<LoadingOutlined />}
|
||||||
|
spinning={fetchLoading}
|
||||||
>
|
>
|
||||||
<Descriptions
|
<Descriptions
|
||||||
bordered
|
bordered
|
||||||
@ -269,18 +350,22 @@ const ProductInfo = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Descriptions.Item label='ID' span={1}>
|
<Descriptions.Item label='ID' span={1}>
|
||||||
{productData.id ? (
|
{productData?.id ? (
|
||||||
<IdText id={productData.id} type='product'></IdText>
|
<IdText id={productData.id} type='product'></IdText>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Created At'>
|
<Descriptions.Item label='Created At'>
|
||||||
|
{productData?.createdAt ? (
|
||||||
<TimeDisplay
|
<TimeDisplay
|
||||||
dateTime={productData.createdAt}
|
dateTime={productData.createdAt}
|
||||||
showSince={true}
|
showSince={true}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Name' span={1}>
|
<Descriptions.Item label='Name' span={1}>
|
||||||
@ -301,16 +386,22 @@ const ProductInfo = () => {
|
|||||||
>
|
>
|
||||||
<Input placeholder='Enter product name' />
|
<Input placeholder='Enter product name' />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
) : productData?.name ? (
|
||||||
|
<Text>{productData.name}</Text>
|
||||||
) : (
|
) : (
|
||||||
productData.name || 'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Updated At'>
|
<Descriptions.Item label='Updated At'>
|
||||||
|
{productData?.updatedAt ? (
|
||||||
<TimeDisplay
|
<TimeDisplay
|
||||||
dateTime={productData.updatedAt}
|
dateTime={productData.updatedAt}
|
||||||
showSince={true}
|
showSince={true}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Vendor'>
|
<Descriptions.Item label='Vendor'>
|
||||||
@ -318,23 +409,32 @@ const ProductInfo = () => {
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
name='vendor'
|
name='vendor'
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: 'Please enter a vendor' }
|
{
|
||||||
|
required: true,
|
||||||
|
message: 'Please enter a vendor'
|
||||||
|
}
|
||||||
]}
|
]}
|
||||||
style={{ margin: 0 }}
|
style={{ margin: 0 }}
|
||||||
>
|
>
|
||||||
<VendorSelect />
|
<VendorSelect />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
) : productData?.vendor?.name ? (
|
||||||
|
<Text>{productData.vendor.name}</Text>
|
||||||
) : (
|
) : (
|
||||||
productData.vendor.name || 'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Vendor ID'>
|
<Descriptions.Item label='Vendor ID'>
|
||||||
|
{productData?.vendor?.id ? (
|
||||||
<IdText
|
<IdText
|
||||||
id={productData.vendor.id}
|
id={productData.vendor.id}
|
||||||
type={'vendor'}
|
type={'vendor'}
|
||||||
showHyperlink={true}
|
showHyperlink={true}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item
|
<Descriptions.Item
|
||||||
@ -388,12 +488,12 @@ const ProductInfo = () => {
|
|||||||
<Checkbox>Price</Checkbox>
|
<Checkbox>Price</Checkbox>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Flex>
|
</Flex>
|
||||||
) : productData.margin && marginOrPrice == false ? (
|
) : productData?.margin && marginOrPrice == false ? (
|
||||||
productData.margin + '%'
|
<Text>{productData.margin + '%'}</Text>
|
||||||
) : productData.price && marginOrPrice == true ? (
|
) : productData?.price && marginOrPrice == true ? (
|
||||||
'£' + productData.price
|
<Text>{'£' + productData.price}</Text>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
@ -402,10 +502,10 @@ const ProductInfo = () => {
|
|||||||
<Form.Item name='version' style={{ margin: 0 }}>
|
<Form.Item name='version' style={{ margin: 0 }}>
|
||||||
<Input placeholder='Enter version' />
|
<Input placeholder='Enter version' />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : productData.version ? (
|
) : productData?.version ? (
|
||||||
<Tag>{productData.version}</Tag>
|
<Tag>{productData.version}</Tag>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
@ -417,7 +517,7 @@ const ProductInfo = () => {
|
|||||||
wrap
|
wrap
|
||||||
style={{ marginBottom: 4, maxWidth: '300px' }}
|
style={{ marginBottom: 4, maxWidth: '300px' }}
|
||||||
>
|
>
|
||||||
{productData.tags.map((tag) => (
|
{productData?.tags?.map((tag) => (
|
||||||
<Tag
|
<Tag
|
||||||
key={tag}
|
key={tag}
|
||||||
color='blue'
|
color='blue'
|
||||||
@ -433,11 +533,18 @@ const ProductInfo = () => {
|
|||||||
<Form.Item name='newTag' noStyle>
|
<Form.Item name='newTag' noStyle>
|
||||||
<Input placeholder='Add new tag' />
|
<Input placeholder='Add new tag' />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button onClick={handleTagAdd} icon={<PlusIcon />} />
|
<Button
|
||||||
|
onClick={handleTagAdd}
|
||||||
|
icon={<PlusIcon />}
|
||||||
|
/>
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : productData.tags?.length > 0 ? (
|
) : productData?.tags?.length > 0 ? (
|
||||||
<Space size={[0, 2]} wrap style={{ maxWidth: '300px' }}>
|
<Space
|
||||||
|
size={[0, 2]}
|
||||||
|
wrap
|
||||||
|
style={{ maxWidth: '300px' }}
|
||||||
|
>
|
||||||
{productData.tags.map((tag, index) => (
|
{productData.tags.map((tag, index) => (
|
||||||
<Tag key={index} color='blue'>
|
<Tag key={index} color='blue'>
|
||||||
{tag}
|
{tag}
|
||||||
@ -445,40 +552,106 @@ const ProductInfo = () => {
|
|||||||
))}
|
))}
|
||||||
</Space>
|
</Space>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
</Spin>
|
||||||
</Form>
|
</Form>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
collapsible='icon'
|
expandIconPosition='end'
|
||||||
activeKey={collapseState.parts ? ['2'] : []}
|
activeKey={collapseState.parts ? ['2'] : []}
|
||||||
onChange={(keys) => updateCollapseState('parts', keys.length > 0)}
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('parts', keys.length > 0)
|
||||||
|
}
|
||||||
expandIcon={({ isActive }) => (
|
expandIcon={({ isActive }) => (
|
||||||
<CaretRightOutlined
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '2px' }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
className='no-h-padding-collapse'
|
className='no-h-padding-collapse'
|
||||||
>
|
>
|
||||||
<Collapse.Panel
|
<Collapse.Panel
|
||||||
header={
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<ProductIcon />
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
Product Parts
|
Product Parts
|
||||||
</Title>
|
</Title>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
key='2'
|
key='2'
|
||||||
>
|
>
|
||||||
<PartsTable data={productData.parts} />
|
<PartsTable data={productData?.parts || []} />
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
expandIconPosition='end'
|
||||||
|
activeKey={collapseState.notes ? ['notes'] : []}
|
||||||
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('notes', keys.length > 0)
|
||||||
|
}
|
||||||
|
expandIcon={({ isActive }) => (
|
||||||
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
|
)}
|
||||||
|
className='no-h-padding-collapse'
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<NoteIcon />
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Notes
|
||||||
|
</Title>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
key='notes'
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<DashboardNotes _id={productId} />
|
||||||
|
</Card>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
expandIconPosition='end'
|
||||||
|
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
||||||
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('auditLogs', keys.length > 0)
|
||||||
|
}
|
||||||
|
expandIcon={({ isActive }) => (
|
||||||
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
|
)}
|
||||||
|
className='no-h-padding-collapse'
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<AuditLogIcon />
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Audit Logs
|
||||||
|
</Title>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
key='auditLogs'
|
||||||
|
>
|
||||||
|
<AuditLogTable
|
||||||
|
items={productData?.auditLogs || []}
|
||||||
|
loading={fetchLoading}
|
||||||
|
showTargetColumn={false}
|
||||||
|
/>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
381
src/components/Dashboard/Management/Users.jsx
Normal 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
|
||||||
264
src/components/Dashboard/Management/Users/NewUser.jsx
Normal 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
|
||||||
510
src/components/Dashboard/Management/Users/UserInfo.jsx
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
@ -11,12 +11,16 @@ import {
|
|||||||
Flex,
|
Flex,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
Collapse
|
Collapse,
|
||||||
|
Dropdown,
|
||||||
|
Popover,
|
||||||
|
Card,
|
||||||
|
Checkbox
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import {
|
import {
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
ExportOutlined,
|
ExportOutlined,
|
||||||
CaretRightOutlined
|
CaretLeftOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import IdText from '../../common/IdText'
|
import IdText from '../../common/IdText'
|
||||||
import CountrySelect from '../../common/CountrySelect'
|
import CountrySelect from '../../common/CountrySelect'
|
||||||
@ -27,10 +31,15 @@ import EditIcon from '../../../Icons/EditIcon.jsx'
|
|||||||
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
|
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
|
||||||
import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
||||||
import useCollapseState from '../../hooks/useCollapseState'
|
import useCollapseState from '../../hooks/useCollapseState'
|
||||||
|
import AuditLogTable from '../../common/AuditLogTable'
|
||||||
|
import DashboardNotes from '../../common/DashboardNotes'
|
||||||
|
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
|
||||||
|
import NoteIcon from '../../../Icons/NoteIcon.jsx'
|
||||||
|
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
|
||||||
|
|
||||||
import config from '../../../../config.js'
|
import config from '../../../../config.js'
|
||||||
|
|
||||||
const { Title, Link } = Typography
|
const { Title, Link, Text } = Typography
|
||||||
|
|
||||||
const VendorInfo = () => {
|
const VendorInfo = () => {
|
||||||
const [vendorData, setVendorData] = useState(null)
|
const [vendorData, setVendorData] = useState(null)
|
||||||
@ -43,7 +52,9 @@ const VendorInfo = () => {
|
|||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
const [fetchLoading, setFetchLoading] = useState(true)
|
const [fetchLoading, setFetchLoading] = useState(true)
|
||||||
const [collapseState, updateCollapseState] = useCollapseState('VendorInfo', {
|
const [collapseState, updateCollapseState] = useCollapseState('VendorInfo', {
|
||||||
info: true
|
info: true,
|
||||||
|
notes: true,
|
||||||
|
auditLogs: true
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -93,14 +104,17 @@ const VendorInfo = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cancelEditing = () => {
|
const cancelEditing = () => {
|
||||||
|
// Reset form values to original data
|
||||||
|
if (vendorData) {
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
name: vendorData?.name || '',
|
name: vendorData.name || '',
|
||||||
website: vendorData?.website || '',
|
website: vendorData.website || '',
|
||||||
contact: vendorData?.contact || '',
|
contact: vendorData.contact || '',
|
||||||
country: vendorData?.country || '',
|
country: vendorData.country || '',
|
||||||
phone: vendorData?.phone || '',
|
phone: vendorData.phone || '',
|
||||||
email: vendorData?.email || ''
|
email: vendorData.email || ''
|
||||||
})
|
})
|
||||||
|
}
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,15 +145,48 @@ const VendorInfo = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fetchLoading) {
|
const actionItems = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Reload Vendor',
|
||||||
|
key: 'reload',
|
||||||
|
icon: <ReloadIcon />
|
||||||
|
}
|
||||||
|
],
|
||||||
|
onClick: ({ key }) => {
|
||||||
|
if (key === 'reload') {
|
||||||
|
fetchVendorDetails()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getViewDropdownItems = () => {
|
||||||
|
const sections = [
|
||||||
|
{ key: 'info', label: 'Vendor Information' },
|
||||||
|
{ key: 'notes', label: 'Notes' },
|
||||||
|
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
<Flex vertical>
|
||||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
|
||||||
</div>
|
{sections.map((section) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={collapseState[section.key]}
|
||||||
|
key={section.key}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateCollapseState(section.key, e.target.checked)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{section.label}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !vendorData) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Space
|
<Space
|
||||||
direction='vertical'
|
direction='vertical'
|
||||||
@ -154,32 +201,26 @@ const VendorInfo = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
<>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<Flex vertical gap={'large'}>
|
|
||||||
<Collapse
|
|
||||||
ghost
|
|
||||||
collapsible='icon'
|
|
||||||
activeKey={collapseState.info ? ['1'] : []}
|
|
||||||
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
|
|
||||||
expandIcon={({ isActive }) => (
|
|
||||||
<CaretRightOutlined
|
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '9px' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
className='no-h-padding-collapse no-t-padding-collapse'
|
|
||||||
>
|
|
||||||
<Collapse.Panel
|
|
||||||
header={
|
|
||||||
<Flex
|
<Flex
|
||||||
align='center'
|
gap='large'
|
||||||
justify='space-between'
|
vertical='true'
|
||||||
style={{ width: '100%' }}
|
style={{ height: '100%', minHeight: 0 }}
|
||||||
>
|
>
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Flex justify={'space-between'}>
|
||||||
Vendor Information
|
<Space size='small'>
|
||||||
</Title>
|
<Dropdown menu={actionItems}>
|
||||||
|
<Button>Actions</Button>
|
||||||
|
</Dropdown>
|
||||||
|
<Popover
|
||||||
|
content={getViewDropdownItems()}
|
||||||
|
placement='bottomLeft'
|
||||||
|
arrow={false}
|
||||||
|
>
|
||||||
|
<Button>View</Button>
|
||||||
|
</Popover>
|
||||||
|
</Space>
|
||||||
<Space>
|
<Space>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<>
|
<>
|
||||||
@ -188,6 +229,7 @@ const VendorInfo = () => {
|
|||||||
type='primary'
|
type='primary'
|
||||||
onClick={updateInfo}
|
onClick={updateInfo}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon={<XMarkIcon />}
|
icon={<XMarkIcon />}
|
||||||
@ -200,10 +242,51 @@ const VendorInfo = () => {
|
|||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Space
|
||||||
|
direction='vertical'
|
||||||
|
style={{ width: '100%', textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
<p>{error || 'Vendor not found'}</p>
|
||||||
|
<Button icon={<ReloadIcon />} onClick={fetchVendorDetails}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||||
|
<Flex vertical gap={'large'}>
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
expandIconPosition='end'
|
||||||
|
activeKey={collapseState.info ? ['1'] : []}
|
||||||
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('info', keys.length > 0)
|
||||||
|
}
|
||||||
|
expandIcon={({ isActive }) => (
|
||||||
|
<CaretLeftOutlined
|
||||||
|
rotate={isActive ? -90 : 0}
|
||||||
|
style={{ paddingTop: '9px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
className='no-h-padding-collapse no-t-padding-collapse'
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<InfoCircleIcon />
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Vendor Information
|
||||||
|
</Title>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
key='1'
|
key='1'
|
||||||
>
|
>
|
||||||
<Form form={form} layout='vertical'>
|
<Form form={form} layout='vertical'>
|
||||||
|
<Spin
|
||||||
|
indicator={<LoadingOutlined />}
|
||||||
|
spinning={fetchLoading}
|
||||||
|
>
|
||||||
<Descriptions
|
<Descriptions
|
||||||
bordered
|
bordered
|
||||||
column={{
|
column={{
|
||||||
@ -216,13 +299,21 @@ const VendorInfo = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Descriptions.Item label='ID'>
|
<Descriptions.Item label='ID'>
|
||||||
|
{vendorData?._id ? (
|
||||||
<IdText id={vendorData._id} type='vendor' />
|
<IdText id={vendorData._id} type='vendor' />
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Created At'>
|
<Descriptions.Item label='Created At'>
|
||||||
|
{vendorData?.createdAt ? (
|
||||||
<TimeDisplay
|
<TimeDisplay
|
||||||
dateTime={vendorData.createdAt}
|
dateTime={vendorData.createdAt}
|
||||||
showSince={true}
|
showSince={true}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Name'>
|
<Descriptions.Item label='Name'>
|
||||||
@ -243,16 +334,22 @@ const VendorInfo = () => {
|
|||||||
>
|
>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
) : vendorData?.name ? (
|
||||||
|
<Text>{vendorData.name}</Text>
|
||||||
) : (
|
) : (
|
||||||
vendorData.name
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Updated At'>
|
<Descriptions.Item label='Updated At'>
|
||||||
|
{vendorData?.updatedAt ? (
|
||||||
<TimeDisplay
|
<TimeDisplay
|
||||||
dateTime={vendorData.updatedAt}
|
dateTime={vendorData.updatedAt}
|
||||||
showSince={true}
|
showSince={true}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Website'>
|
<Descriptions.Item label='Website'>
|
||||||
@ -260,17 +357,21 @@ const VendorInfo = () => {
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
name='website'
|
name='website'
|
||||||
rules={[
|
rules={[
|
||||||
{ type: 'url', message: 'Please enter a valid URL' },
|
{
|
||||||
|
type: 'url',
|
||||||
|
message: 'Please enter a valid URL'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
max: 200,
|
max: 200,
|
||||||
message: 'Website URL cannot exceed 200 characters'
|
message:
|
||||||
|
'Website URL cannot exceed 200 characters'
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
style={{ margin: 0 }}
|
style={{ margin: 0 }}
|
||||||
>
|
>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : vendorData.website ? (
|
) : vendorData?.website ? (
|
||||||
<Link
|
<Link
|
||||||
href={vendorData.website}
|
href={vendorData.website}
|
||||||
target='_blank'
|
target='_blank'
|
||||||
@ -280,19 +381,21 @@ const VendorInfo = () => {
|
|||||||
<ExportOutlined />
|
<ExportOutlined />
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Country'>
|
<Descriptions.Item label='Country'>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Form.Item name='country' style={{ margin: 0 }}>
|
<Form.Item name='country' style={{ margin: 0 }}>
|
||||||
<CountrySelect countryCode={vendorData.country} />
|
<CountrySelect
|
||||||
|
countryCode={vendorData?.country}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : vendorData.country ? (
|
) : vendorData?.country ? (
|
||||||
<CountryDisplay countryCode={vendorData.country} />
|
<CountryDisplay countryCode={vendorData.country} />
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
@ -303,17 +406,18 @@ const VendorInfo = () => {
|
|||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
max: 200,
|
max: 200,
|
||||||
message: 'Contact info cannot exceed 200 characters'
|
message:
|
||||||
|
'Contact info cannot exceed 200 characters'
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
style={{ margin: 0 }}
|
style={{ margin: 0 }}
|
||||||
>
|
>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : vendorData.contact ? (
|
) : vendorData?.contact ? (
|
||||||
vendorData.contact
|
<Text>{vendorData.contact}</Text>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
@ -331,10 +435,10 @@ const VendorInfo = () => {
|
|||||||
>
|
>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : vendorData.phone ? (
|
) : vendorData?.phone ? (
|
||||||
vendorData.phone
|
<Text>{vendorData.phone}</Text>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
@ -352,21 +456,85 @@ const VendorInfo = () => {
|
|||||||
>
|
>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : vendorData.email ? (
|
) : vendorData?.email ? (
|
||||||
<Link href={`mailto:${vendorData.email}`}>
|
<Link href={`mailto:${vendorData.email}`}>
|
||||||
{vendorData.email + ' '}
|
{vendorData.email + ' '}
|
||||||
<ExportOutlined />
|
<ExportOutlined />
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
</Spin>
|
||||||
</Form>
|
</Form>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
expandIconPosition='end'
|
||||||
|
activeKey={collapseState.notes ? ['notes'] : []}
|
||||||
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('notes', keys.length > 0)
|
||||||
|
}
|
||||||
|
expandIcon={({ isActive }) => (
|
||||||
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
|
)}
|
||||||
|
className='no-h-padding-collapse'
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<NoteIcon />
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Notes
|
||||||
|
</Title>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
key='notes'
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<DashboardNotes _id={vendorId} />
|
||||||
|
</Card>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
expandIconPosition='end'
|
||||||
|
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
||||||
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('auditLogs', keys.length > 0)
|
||||||
|
}
|
||||||
|
expandIcon={({ isActive }) => (
|
||||||
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
|
)}
|
||||||
|
className='no-h-padding-collapse'
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<AuditLogIcon />
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Audit Logs
|
||||||
|
</Title>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
key='auditLogs'
|
||||||
|
>
|
||||||
|
<AuditLogTable
|
||||||
|
items={vendorData?.auditLogs || []}
|
||||||
|
loading={fetchLoading}
|
||||||
|
showTargetColumn={false}
|
||||||
|
/>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,9 @@ import XMarkIcon from '../../Icons/XMarkIcon'
|
|||||||
import CheckIcon from '../../Icons/CheckIcon'
|
import CheckIcon from '../../Icons/CheckIcon'
|
||||||
import TimeDisplay from '../common/TimeDisplay'
|
import TimeDisplay from '../common/TimeDisplay'
|
||||||
import DashboardTable from '../common/DashboardTable'
|
import DashboardTable from '../common/DashboardTable'
|
||||||
|
import ListIcon from '../../Icons/ListIcon'
|
||||||
|
import GridIcon from '../../Icons/GridIcon'
|
||||||
|
import useViewMode from '../hooks/useViewMode'
|
||||||
|
|
||||||
import config from '../../../config'
|
import config from '../../../config'
|
||||||
|
|
||||||
@ -42,6 +45,7 @@ const GCodeFiles = () => {
|
|||||||
const [newGCodeFileOpen, setNewGCodeFileOpen] = useState(false)
|
const [newGCodeFileOpen, setNewGCodeFileOpen] = useState(false)
|
||||||
const [showDeleted, setShowDeleted] = useState(false)
|
const [showDeleted, setShowDeleted] = useState(false)
|
||||||
const tableRef = useRef()
|
const tableRef = useRef()
|
||||||
|
const [viewMode, setViewMode] = useViewMode('GCodeFiles')
|
||||||
|
|
||||||
const getFilterDropdown = ({
|
const getFilterDropdown = ({
|
||||||
setSelectedKeys,
|
setSelectedKeys,
|
||||||
@ -82,12 +86,11 @@ const GCodeFiles = () => {
|
|||||||
// Column definitions
|
// Column definitions
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '',
|
title: <GCodeFileIcon />,
|
||||||
dataIndex: '',
|
key: 'icon',
|
||||||
key: '',
|
|
||||||
width: 40,
|
width: 40,
|
||||||
fixed: 'left',
|
fixed: 'left',
|
||||||
render: () => <GCodeFileIcon></GCodeFileIcon>
|
render: () => <GCodeFileIcon />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Name',
|
title: 'Name',
|
||||||
@ -121,12 +124,14 @@ const GCodeFiles = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Filament',
|
title: 'Filament',
|
||||||
dataIndex: ['filament', 'name'],
|
|
||||||
key: 'filament',
|
key: 'filament',
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (text, record) => {
|
render: (record) => {
|
||||||
return (
|
return (
|
||||||
<Badge color={record.filament.color} text={record.filament.name} />
|
<Badge
|
||||||
|
color={record?.filament?.color}
|
||||||
|
text={record?.filament?.name}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
filterDropdown: ({
|
filterDropdown: ({
|
||||||
@ -151,17 +156,16 @@ const GCodeFiles = () => {
|
|||||||
key: 'cost',
|
key: 'cost',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (cost) => {
|
render: (cost) => {
|
||||||
return '£' + cost.toFixed(2)
|
return '£' + cost?.toFixed(2)
|
||||||
},
|
},
|
||||||
sorter: true
|
sorter: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Print Time',
|
title: 'Print Time',
|
||||||
key: 'estimatedPrintingTimeNormalMode',
|
key: 'estimatedPrintingTimeNormalMode',
|
||||||
dataIndex: ['gcodeFileInfo', 'estimatedPrintingTimeNormalMode'],
|
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (text, record) => {
|
render: (record) => {
|
||||||
return `${record.gcodeFileInfo.estimatedPrintingTimeNormalMode}`
|
return `${record?.gcodeFileInfo?.estimatedPrintingTimeNormalMode}`
|
||||||
},
|
},
|
||||||
sorter: true
|
sorter: true
|
||||||
},
|
},
|
||||||
@ -198,7 +202,7 @@ const GCodeFiles = () => {
|
|||||||
key: 'actions',
|
key: 'actions',
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (text, record) => {
|
render: (record) => {
|
||||||
return (
|
return (
|
||||||
<Space gap='small'>
|
<Space gap='small'>
|
||||||
<Button
|
<Button
|
||||||
@ -353,6 +357,7 @@ const GCodeFiles = () => {
|
|||||||
<>
|
<>
|
||||||
<Flex vertical={'true'} gap='large'>
|
<Flex vertical={'true'} gap='large'>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
|
<Flex justify={'space-between'}>
|
||||||
<Space>
|
<Space>
|
||||||
<Dropdown menu={actionItems}>
|
<Dropdown menu={actionItems}>
|
||||||
<Button>Actions</Button>
|
<Button>Actions</Button>
|
||||||
@ -365,12 +370,21 @@ const GCodeFiles = () => {
|
|||||||
<Button>View</Button>
|
<Button>View</Button>
|
||||||
</Popover>
|
</Popover>
|
||||||
</Space>
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
|
||||||
|
onClick={() =>
|
||||||
|
setViewMode(viewMode === 'cards' ? 'list' : 'cards')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
<DashboardTable
|
<DashboardTable
|
||||||
ref={tableRef}
|
ref={tableRef}
|
||||||
columns={visibleColumns}
|
columns={visibleColumns}
|
||||||
url={`${config.backendUrl}/gcodefiles`}
|
url={`${config.backendUrl}/gcodefiles`}
|
||||||
authenticated={authenticated}
|
authenticated={authenticated}
|
||||||
|
cards={viewMode === 'cards'}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@ -13,9 +13,12 @@ import {
|
|||||||
Flex,
|
Flex,
|
||||||
Input,
|
Input,
|
||||||
Card,
|
Card,
|
||||||
Collapse
|
Collapse,
|
||||||
|
Dropdown,
|
||||||
|
Popover,
|
||||||
|
Checkbox
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||||
import IdText from '../../common/IdText.jsx'
|
import IdText from '../../common/IdText.jsx'
|
||||||
import { capitalizeFirstLetter } from '../../utils/Utils.js'
|
import { capitalizeFirstLetter } from '../../utils/Utils.js'
|
||||||
import FilamentSelect from '../../common/FilamentSelect'
|
import FilamentSelect from '../../common/FilamentSelect'
|
||||||
@ -28,12 +31,19 @@ import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
|
|||||||
import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
||||||
|
|
||||||
import config from '../../../../config.js'
|
import config from '../../../../config.js'
|
||||||
|
import AuditLogTable from '../../common/AuditLogTable.jsx'
|
||||||
|
import DashboardNotes from '../../common/DashboardNotes.jsx'
|
||||||
|
import BinIcon from '../../../Icons/BinIcon.jsx'
|
||||||
|
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
|
||||||
|
import GCodeFileIcon from '../../../Icons/GCodeFileIcon.jsx'
|
||||||
|
import NoteIcon from '../../../Icons/NoteIcon.jsx'
|
||||||
|
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
const GCodeFileInfo = () => {
|
const GCodeFileInfo = () => {
|
||||||
const [gcodeFileData, setGCodeFileData] = useState(null)
|
const [gcodeFileData, setGCodeFileData] = useState(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [editLoading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [messageApi, contextHolder] = message.useMessage()
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
@ -99,7 +109,7 @@ const GCodeFileInfo = () => {
|
|||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateInfo = async () => {
|
const updateGCodeFileInfo = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@ -130,68 +140,95 @@ const GCodeFileInfo = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !gcodeFileData) {
|
const actionItems = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Edit GCode File',
|
||||||
|
key: 'edit',
|
||||||
|
icon: <EditIcon />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete GCode File',
|
||||||
|
key: 'delete',
|
||||||
|
icon: <BinIcon />,
|
||||||
|
danger: true
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
label: 'Reload GCode File',
|
||||||
|
key: 'reload',
|
||||||
|
icon: <ReloadIcon />
|
||||||
|
}
|
||||||
|
],
|
||||||
|
onClick: ({ key }) => {
|
||||||
|
if (key === 'reload') {
|
||||||
|
fetchGCodeFileDetails()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getViewDropdownItems = () => {
|
||||||
|
const sections = [
|
||||||
|
{ key: 'info', label: 'GCode File Information' },
|
||||||
|
{ key: 'preview', label: 'GCode File Preview' },
|
||||||
|
{ key: 'notes', label: 'Notes' },
|
||||||
|
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space
|
<Flex vertical>
|
||||||
direction='vertical'
|
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
|
||||||
style={{ width: '100%', textAlign: 'center' }}
|
{sections.map((section) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={collapseState[section.key]}
|
||||||
|
key={section.key}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateCollapseState(section.key, e.target.checked)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<p>{error || 'GCodeFile not found'}</p>
|
{section.label}
|
||||||
<Button icon={<ReloadIcon />} onClick={fetchGCodeFileDetails}>
|
</Checkbox>
|
||||||
Retry
|
))}
|
||||||
</Button>
|
</Flex>
|
||||||
</Space>
|
</Flex>
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fetchLoading) {
|
|
||||||
return (
|
|
||||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
|
||||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
<>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<Flex vertical gap={'large'}>
|
|
||||||
<Collapse
|
|
||||||
ghost
|
|
||||||
collapsible='icon'
|
|
||||||
activeKey={collapseState.info ? ['1'] : []}
|
|
||||||
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
|
|
||||||
expandIcon={({ isActive }) => (
|
|
||||||
<CaretRightOutlined
|
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '9px' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
className='no-h-padding-collapse no-t-padding-collapse'
|
|
||||||
>
|
|
||||||
<Collapse.Panel
|
|
||||||
header={
|
|
||||||
<Flex
|
<Flex
|
||||||
align='center'
|
gap='large'
|
||||||
justify='space-between'
|
vertical='true'
|
||||||
style={{ width: '100%' }}
|
style={{ height: '100%', minHeight: 0 }}
|
||||||
>
|
>
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Flex justify={'space-between'}>
|
||||||
GCode File Information
|
<Space size='small'>
|
||||||
</Title>
|
<Dropdown menu={actionItems}>
|
||||||
|
<Button>Actions</Button>
|
||||||
|
</Dropdown>
|
||||||
|
<Popover
|
||||||
|
content={getViewDropdownItems()}
|
||||||
|
placement='bottomLeft'
|
||||||
|
arrow={false}
|
||||||
|
>
|
||||||
|
<Button>View</Button>
|
||||||
|
</Popover>
|
||||||
|
</Space>
|
||||||
<Space>
|
<Space>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
icon={<CheckIcon />}
|
icon={<CheckIcon />}
|
||||||
type='primary'
|
type='primary'
|
||||||
onClick={updateInfo}
|
onClick={updateGCodeFileInfo}
|
||||||
loading={loading}
|
loading={editLoading}
|
||||||
|
disabled={editLoading}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon={<XMarkIcon />}
|
icon={<XMarkIcon />}
|
||||||
onClick={cancelEditing}
|
onClick={cancelEditing}
|
||||||
disabled={loading}
|
disabled={editLoading}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -199,10 +236,48 @@ const GCodeFileInfo = () => {
|
|||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Space
|
||||||
|
direction='vertical'
|
||||||
|
style={{ width: '100%', textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
<p>{error || 'Print job not found'}</p>
|
||||||
|
<Button icon={<ReloadIcon />} onClick={fetchGCodeFileDetails}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||||
|
<Flex vertical gap={'large'}>
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
expandIconPosition='end'
|
||||||
|
activeKey={collapseState.info ? ['info'] : []}
|
||||||
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('info', keys.length > 0)
|
||||||
}
|
}
|
||||||
key='1'
|
expandIcon={({ isActive }) => (
|
||||||
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
|
)}
|
||||||
|
className='no-h-padding-collapse no-t-padding-collapse'
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<InfoCircleIcon />
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
GCode File Information
|
||||||
|
</Title>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
key='info'
|
||||||
>
|
>
|
||||||
<Form form={form} layout='vertical'>
|
<Form form={form} layout='vertical'>
|
||||||
|
<Spin
|
||||||
|
spinning={fetchLoading}
|
||||||
|
indicator={<LoadingOutlined />}
|
||||||
|
>
|
||||||
<Descriptions
|
<Descriptions
|
||||||
bordered
|
bordered
|
||||||
column={{
|
column={{
|
||||||
@ -215,17 +290,24 @@ const GCodeFileInfo = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Descriptions.Item label='ID' span={1}>
|
<Descriptions.Item label='ID' span={1}>
|
||||||
{gcodeFileData.id ? (
|
{gcodeFileData?._id ? (
|
||||||
<IdText id={gcodeFileData.id} type='gcodeFile'></IdText>
|
<IdText
|
||||||
|
id={gcodeFileData._id}
|
||||||
|
type='gcodefile'
|
||||||
|
></IdText>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Created At'>
|
<Descriptions.Item label='Created At'>
|
||||||
|
{gcodeFileData?.createdAt ? (
|
||||||
<TimeDisplay
|
<TimeDisplay
|
||||||
dateTime={gcodeFileData.createdAt}
|
dateTime={gcodeFileData.createdAt}
|
||||||
showSince={true}
|
showSince={true}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Name'>
|
<Descriptions.Item label='Name'>
|
||||||
@ -246,29 +328,38 @@ const GCodeFileInfo = () => {
|
|||||||
>
|
>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
) : gcodeFileData?.name ? (
|
||||||
|
<Text>{gcodeFileData.name}</Text>
|
||||||
) : (
|
) : (
|
||||||
gcodeFileData.name
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Updated At'>
|
<Descriptions.Item label='Updated At'>
|
||||||
|
{gcodeFileData?.updatedAt ? (
|
||||||
<TimeDisplay
|
<TimeDisplay
|
||||||
dateTime={gcodeFileData.updatedAt}
|
dateTime={gcodeFileData.updatedAt}
|
||||||
showSince={true}
|
showSince={true}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Filament Name'>
|
<Descriptions.Item label='Filament Name'>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name='filament'
|
name='filament'
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: 'Please enter a filament' }
|
{
|
||||||
|
required: true,
|
||||||
|
message: 'Please enter a filament'
|
||||||
|
}
|
||||||
]}
|
]}
|
||||||
style={{ margin: 0 }}
|
style={{ margin: 0 }}
|
||||||
>
|
>
|
||||||
<FilamentSelect />
|
<FilamentSelect />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : gcodeFileData.filament ? (
|
) : gcodeFileData?.filament ? (
|
||||||
<Space>
|
<Space>
|
||||||
<FilamentIcon />
|
<FilamentIcon />
|
||||||
<Badge
|
<Badge
|
||||||
@ -277,142 +368,229 @@ const GCodeFileInfo = () => {
|
|||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Filament ID'>
|
<Descriptions.Item label='Filament ID'>
|
||||||
{gcodeFileData.filament ? (
|
{gcodeFileData?.filament ? (
|
||||||
<IdText
|
<IdText
|
||||||
id={gcodeFileData.filament.id}
|
id={gcodeFileData.filament.id}
|
||||||
type={'filament'}
|
type={'filament'}
|
||||||
showHyperlink={true}
|
showHyperlink={true}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Est Print Time'>
|
<Descriptions.Item label='Est Print Time'>
|
||||||
{gcodeFileData.gcodeFileInfo
|
{gcodeFileData?.gcodeFileInfo
|
||||||
.estimatedPrintingTimeNormalMode || 'n/a'}
|
?.estimatedPrintingTimeNormalMode ? (
|
||||||
|
<Text>
|
||||||
|
{
|
||||||
|
gcodeFileData.gcodeFileInfo
|
||||||
|
.estimatedPrintingTimeNormalMode
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Cost'>
|
<Descriptions.Item label='Cost'>
|
||||||
{'£' + gcodeFileData.cost.toFixed(2) || 'n/a'}
|
{gcodeFileData?.cost ? (
|
||||||
|
<Text>{'£' + gcodeFileData.cost.toFixed(2)}</Text>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Infill Density'>
|
<Descriptions.Item label='Infill Density'>
|
||||||
{(() => {
|
{gcodeFileData?.gcodeFileInfo?.sparseInfillDensity ? (
|
||||||
if (gcodeFileData.gcodeFileInfo.sparseInfillDensity) {
|
<Text>
|
||||||
return gcodeFileData.gcodeFileInfo.sparseInfillDensity
|
{gcodeFileData.gcodeFileInfo.sparseInfillDensity}
|
||||||
} else {
|
</Text>
|
||||||
return 'n/a'
|
) : (
|
||||||
}
|
<Text>n/a</Text>
|
||||||
})()}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Infill Pattern'>
|
<Descriptions.Item label='Infill Pattern'>
|
||||||
{(() => {
|
{gcodeFileData?.gcodeFileInfo?.sparseInfillPattern ? (
|
||||||
if (gcodeFileData.gcodeFileInfo.sparseInfillPattern) {
|
<Text>
|
||||||
return capitalizeFirstLetter(
|
{capitalizeFirstLetter(
|
||||||
gcodeFileData.gcodeFileInfo.sparseInfillPattern
|
gcodeFileData.gcodeFileInfo.sparseInfillPattern
|
||||||
)
|
)}
|
||||||
} else {
|
</Text>
|
||||||
return 'n/a'
|
) : (
|
||||||
}
|
<Text>n/a</Text>
|
||||||
})()}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Filament Used (mm)'>
|
<Descriptions.Item label='Filament Used (mm)'>
|
||||||
{(() => {
|
{gcodeFileData?.gcodeFileInfo?.filamentUsedMm ? (
|
||||||
if (gcodeFileData.gcodeFileInfo.filamentUsedMm) {
|
<Text>
|
||||||
return `${gcodeFileData.gcodeFileInfo.filamentUsedMm}mm`
|
{gcodeFileData.gcodeFileInfo.filamentUsedMm}mm
|
||||||
} else {
|
</Text>
|
||||||
return 'n/a'
|
) : (
|
||||||
}
|
<Text>n/a</Text>
|
||||||
})()}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Filament Used (g)'>
|
<Descriptions.Item label='Filament Used (g)'>
|
||||||
{(() => {
|
{gcodeFileData?.gcodeFileInfo?.filamentUsedG ? (
|
||||||
if (gcodeFileData.gcodeFileInfo.filamentUsedG) {
|
<Text>
|
||||||
return `${gcodeFileData.gcodeFileInfo.filamentUsedG}g`
|
{gcodeFileData.gcodeFileInfo.filamentUsedG}g
|
||||||
} else {
|
</Text>
|
||||||
return 'n/a'
|
) : (
|
||||||
}
|
<Text>n/a</Text>
|
||||||
})()}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Hotend Temperature'>
|
<Descriptions.Item label='Hotend Temperature'>
|
||||||
{(() => {
|
{gcodeFileData?.gcodeFileInfo?.nozzleTemperature ? (
|
||||||
if (gcodeFileData.gcodeFileInfo.nozzleTemperature) {
|
<Text>
|
||||||
return `${gcodeFileData.gcodeFileInfo.nozzleTemperature}°`
|
{gcodeFileData.gcodeFileInfo.nozzleTemperature}°
|
||||||
} else {
|
</Text>
|
||||||
return 'n/a'
|
) : (
|
||||||
}
|
<Text>n/a</Text>
|
||||||
})()}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Bed Temperature'>
|
<Descriptions.Item label='Bed Temperature'>
|
||||||
{(() => {
|
{gcodeFileData?.gcodeFileInfo?.hotPlateTemp ? (
|
||||||
if (gcodeFileData.gcodeFileInfo.hotPlateTemp) {
|
<Text>
|
||||||
return `${gcodeFileData.gcodeFileInfo.hotPlateTemp}°`
|
{gcodeFileData.gcodeFileInfo.hotPlateTemp}°
|
||||||
} else {
|
</Text>
|
||||||
return 'n/a'
|
) : (
|
||||||
}
|
<Text>n/a</Text>
|
||||||
})()}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Filament Profile'>
|
<Descriptions.Item label='Filament Profile'>
|
||||||
{(() => {
|
{gcodeFileData?.gcodeFileInfo?.filamentSettingsId ? (
|
||||||
if (gcodeFileData.gcodeFileInfo.filamentSettingsId) {
|
<Text>
|
||||||
return `${gcodeFileData.gcodeFileInfo.filamentSettingsId.replaceAll('"', '')}`
|
{gcodeFileData.gcodeFileInfo.filamentSettingsId.replaceAll(
|
||||||
} else {
|
'"',
|
||||||
return 'n/a'
|
''
|
||||||
}
|
)}
|
||||||
})()}
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Print Profile'>
|
<Descriptions.Item label='Print Profile'>
|
||||||
{(() => {
|
{gcodeFileData?.gcodeFileInfo?.printSettingsId ? (
|
||||||
if (gcodeFileData.gcodeFileInfo.printSettingsId) {
|
<Text>
|
||||||
return `${gcodeFileData.gcodeFileInfo.printSettingsId.replaceAll('"', '')}`
|
{gcodeFileData.gcodeFileInfo.printSettingsId.replaceAll(
|
||||||
} else {
|
'"',
|
||||||
return 'n/a'
|
''
|
||||||
}
|
)}
|
||||||
})()}
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
</Spin>
|
||||||
</Form>
|
</Form>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
collapsible='icon'
|
expandIconPosition='end'
|
||||||
activeKey={collapseState.preview ? ['2'] : []}
|
activeKey={collapseState.preview ? ['preview'] : []}
|
||||||
onChange={(keys) => updateCollapseState('preview', keys.length > 0)}
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('preview', keys.length > 0)
|
||||||
|
}
|
||||||
expandIcon={({ isActive }) => (
|
expandIcon={({ isActive }) => (
|
||||||
<CaretRightOutlined
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '2px' }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
className='no-h-padding-collapse'
|
className='no-h-padding-collapse'
|
||||||
>
|
>
|
||||||
<Collapse.Panel
|
<Collapse.Panel
|
||||||
header={
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<GCodeFileIcon />
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
GCode File Preview
|
GCode File Preview
|
||||||
</Title>
|
</Title>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
key='2'
|
key='preview'
|
||||||
>
|
>
|
||||||
|
<Spin spinning={fetchLoading} indicator={<LoadingOutlined />}>
|
||||||
<Card styles={{ body: { padding: '10px' } }}>
|
<Card styles={{ body: { padding: '10px' } }}>
|
||||||
{gcodeFileData.gcodeFileInfo.thumbnail ? (
|
{gcodeFileData?.gcodeFileInfo?.thumbnail ? (
|
||||||
<img
|
<img
|
||||||
src={`data:image/png;base64,${gcodeFileData.gcodeFileInfo.thumbnail.data}`}
|
src={`data:image/png;base64,${gcodeFileData.gcodeFileInfo.thumbnail.data}`}
|
||||||
alt='GCodeFile'
|
alt='GCodeFile'
|
||||||
style={{ maxWidth: '100%' }}
|
style={{ maxWidth: '100%' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
</Spin>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
expandIconPosition='end'
|
||||||
|
activeKey={collapseState.notes ? ['notes'] : []}
|
||||||
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('notes', keys.length > 0)
|
||||||
|
}
|
||||||
|
expandIcon={({ isActive }) => (
|
||||||
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
|
)}
|
||||||
|
className='no-h-padding-collapse'
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<NoteIcon />
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Notes
|
||||||
|
</Title>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
key='notes'
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<DashboardNotes _id={gcodeFileId} />
|
||||||
|
</Card>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
expandIconPosition='end'
|
||||||
|
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
||||||
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('auditLogs', keys.length > 0)
|
||||||
|
}
|
||||||
|
expandIcon={({ isActive }) => (
|
||||||
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
|
)}
|
||||||
|
className='no-h-padding-collapse'
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<AuditLogIcon />
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Audit Log
|
||||||
|
</Title>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
key='auditLogs'
|
||||||
|
>
|
||||||
|
<AuditLogTable
|
||||||
|
items={gcodeFileData?.auditLogs || []}
|
||||||
|
loading={fetchLoading}
|
||||||
|
showTargetColumn={false}
|
||||||
|
/>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,9 @@ import PauseCircleIcon from '../../Icons/PauseCircleIcon.jsx'
|
|||||||
import XMarkCircleIcon from '../../Icons/XMarkCircleIcon.jsx'
|
import XMarkCircleIcon from '../../Icons/XMarkCircleIcon.jsx'
|
||||||
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon.jsx'
|
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon.jsx'
|
||||||
import DashboardTable from '../common/DashboardTable'
|
import DashboardTable from '../common/DashboardTable'
|
||||||
|
import ListIcon from '../../Icons/ListIcon.jsx'
|
||||||
|
import GridIcon from '../../Icons/GridIcon.jsx'
|
||||||
|
import useViewMode from '../hooks/useViewMode.js'
|
||||||
|
|
||||||
import config from '../../../config.js'
|
import config from '../../../config.js'
|
||||||
|
|
||||||
@ -49,6 +52,7 @@ const Jobs = () => {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [newJobOpen, setNewJobOpen] = useState(false)
|
const [newJobOpen, setNewJobOpen] = useState(false)
|
||||||
const tableRef = useRef()
|
const tableRef = useRef()
|
||||||
|
const [viewMode, setViewMode] = useViewMode('Jobs')
|
||||||
|
|
||||||
const getFilterDropdown = ({
|
const getFilterDropdown = ({
|
||||||
setSelectedKeys,
|
setSelectedKeys,
|
||||||
@ -89,20 +93,18 @@ const Jobs = () => {
|
|||||||
// Column definitions
|
// Column definitions
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '',
|
title: <JobIcon />,
|
||||||
dataIndex: '',
|
key: 'icon',
|
||||||
key: '',
|
|
||||||
width: 40,
|
width: 40,
|
||||||
fixed: 'left',
|
fixed: 'left',
|
||||||
render: () => <JobIcon />
|
render: () => <JobIcon />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'GCode File Name',
|
title: 'GCode File Name',
|
||||||
dataIndex: 'gcodeFile',
|
|
||||||
key: 'gcodeFileName',
|
key: 'gcodeFileName',
|
||||||
width: 200,
|
width: 200,
|
||||||
fixed: 'left',
|
fixed: 'left',
|
||||||
render: (gcodeFile) => <Text ellipsis>{gcodeFile.name}</Text>,
|
render: (record) => <Text ellipsis>{record?.gcodeFile?.name}</Text>,
|
||||||
filterDropdown: ({
|
filterDropdown: ({
|
||||||
setSelectedKeys,
|
setSelectedKeys,
|
||||||
selectedKeys,
|
selectedKeys,
|
||||||
@ -162,7 +164,7 @@ const Jobs = () => {
|
|||||||
propertyName: 'state'
|
propertyName: 'state'
|
||||||
}),
|
}),
|
||||||
onFilter: (value, record) =>
|
onFilter: (value, record) =>
|
||||||
record.state.type.toLowerCase().includes(value.toLowerCase())
|
record?.state?.type?.toLowerCase().includes(value.toLowerCase())
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: <CheckCircleIcon />,
|
title: <CheckCircleIcon />,
|
||||||
@ -226,13 +228,13 @@ const Jobs = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Actions',
|
title: 'Actions',
|
||||||
key: 'operation',
|
key: 'actions',
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (record) => {
|
render: (record) => {
|
||||||
return (
|
return (
|
||||||
<Space size='small'>
|
<Space size='small'>
|
||||||
{record.state.type === 'draft' ? (
|
{record?.state?.type === 'draft' ? (
|
||||||
<Button
|
<Button
|
||||||
icon={<PlayCircleIcon />}
|
icon={<PlayCircleIcon />}
|
||||||
onClick={() => handleDeployJob(record.id)}
|
onClick={() => handleDeployJob(record.id)}
|
||||||
@ -368,6 +370,7 @@ const Jobs = () => {
|
|||||||
{notificationContextHolder}
|
{notificationContextHolder}
|
||||||
<Flex vertical={'true'} gap='large' style={{ height: '100%' }}>
|
<Flex vertical={'true'} gap='large' style={{ height: '100%' }}>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
|
<Flex justify={'space-between'}>
|
||||||
<Space size='small'>
|
<Space size='small'>
|
||||||
<Dropdown menu={actionItems}>
|
<Dropdown menu={actionItems}>
|
||||||
<Button>Actions</Button>
|
<Button>Actions</Button>
|
||||||
@ -380,18 +383,29 @@ const Jobs = () => {
|
|||||||
<Button>View</Button>
|
<Button>View</Button>
|
||||||
</Popover>
|
</Popover>
|
||||||
</Space>
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
|
||||||
|
onClick={() =>
|
||||||
|
setViewMode(viewMode === 'cards' ? 'list' : 'cards')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
<DashboardTable
|
<DashboardTable
|
||||||
ref={tableRef}
|
ref={tableRef}
|
||||||
columns={visibleColumns}
|
columns={visibleColumns}
|
||||||
url={`${config.backendUrl}/jobs`}
|
url={`${config.backendUrl}/jobs`}
|
||||||
authenticated={authenticated}
|
authenticated={authenticated}
|
||||||
|
cards={viewMode === 'cards'}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Modal
|
<Modal
|
||||||
open={newJobOpen}
|
open={newJobOpen}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={700}
|
width={'auto'}
|
||||||
|
height={'auto'}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setNewJobOpen(false)
|
setNewJobOpen(false)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
Card
|
Card
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||||
import TimeDisplay from '../../common/TimeDisplay'
|
import TimeDisplay from '../../common/TimeDisplay'
|
||||||
import JobState from '../../common/JobState'
|
import JobState from '../../common/JobState'
|
||||||
import IdText from '../../common/IdText'
|
import IdText from '../../common/IdText'
|
||||||
@ -28,12 +28,16 @@ import useCollapseState from '../../hooks/useCollapseState'
|
|||||||
import config from '../../../../config'
|
import config from '../../../../config'
|
||||||
import AuditLogTable from '../../common/AuditLogTable'
|
import AuditLogTable from '../../common/AuditLogTable'
|
||||||
import DashboardNotes from '../../common/DashboardNotes'
|
import DashboardNotes from '../../common/DashboardNotes'
|
||||||
|
import InfoCircleIcon from '../../../Icons/InfoCircleIcon'
|
||||||
|
import JobIcon from '../../../Icons/JobIcon'
|
||||||
|
import AuditLogIcon from '../../../Icons/AuditLogIcon'
|
||||||
|
import NoteIcon from '../../../Icons/NoteIcon'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
const JobInfo = () => {
|
const JobInfo = () => {
|
||||||
const [jobData, setJobData] = useState(null)
|
const [jobData, setJobData] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [fetchLoading, setFetchLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [messageApi] = message.useMessage()
|
const [messageApi] = message.useMessage()
|
||||||
@ -77,7 +81,7 @@ const JobInfo = () => {
|
|||||||
|
|
||||||
const fetchJobDetails = async () => {
|
const fetchJobDetails = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setFetchLoading(true)
|
||||||
const response = await axios.get(`${config.backendUrl}/jobs/${jobId}`, {
|
const response = await axios.get(`${config.backendUrl}/jobs/${jobId}`, {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json'
|
Accept: 'application/json'
|
||||||
@ -90,7 +94,7 @@ const JobInfo = () => {
|
|||||||
setError('Failed to fetch print job details')
|
setError('Failed to fetch print job details')
|
||||||
messageApi.error('Failed to fetch print job details')
|
messageApi.error('Failed to fetch print job details')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setFetchLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,28 +180,28 @@ const JobInfo = () => {
|
|||||||
<Flex vertical style={{ flexGrow: 1 }} gap={'large'}>
|
<Flex vertical style={{ flexGrow: 1 }} gap={'large'}>
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
collapsible='icon'
|
expandIconPosition='end'
|
||||||
activeKey={collapseState.info ? ['info'] : []}
|
activeKey={collapseState.info ? ['info'] : []}
|
||||||
onChange={(keys) =>
|
onChange={(keys) =>
|
||||||
updateCollapseState('info', keys.length > 0)
|
updateCollapseState('info', keys.length > 0)
|
||||||
}
|
}
|
||||||
expandIcon={({ isActive }) => (
|
expandIcon={({ isActive }) => (
|
||||||
<CaretRightOutlined
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '2px' }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
className='no-h-padding-collapse no-t-padding-collapse'
|
className='no-h-padding-collapse no-t-padding-collapse'
|
||||||
>
|
>
|
||||||
<Collapse.Panel
|
<Collapse.Panel
|
||||||
header={
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<InfoCircleIcon />
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
Job Information
|
Job Information
|
||||||
</Title>
|
</Title>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
key='info'
|
key='info'
|
||||||
>
|
>
|
||||||
<Spin spinning={loading} indicator={<LoadingOutlined />}>
|
<Spin spinning={fetchLoading} indicator={<LoadingOutlined />}>
|
||||||
<Descriptions
|
<Descriptions
|
||||||
bordered
|
bordered
|
||||||
column={{
|
column={{
|
||||||
@ -303,86 +307,86 @@ const JobInfo = () => {
|
|||||||
|
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
collapsible='icon'
|
expandIconPosition='end'
|
||||||
activeKey={collapseState.subJobs ? ['2'] : []}
|
activeKey={collapseState.subJobs ? ['2'] : []}
|
||||||
onChange={(keys) =>
|
onChange={(keys) =>
|
||||||
updateCollapseState('subJobs', keys.length > 0)
|
updateCollapseState('subJobs', keys.length > 0)
|
||||||
}
|
}
|
||||||
expandIcon={({ isActive }) => (
|
expandIcon={({ isActive }) => (
|
||||||
<CaretRightOutlined
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '2px' }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
className='no-h-padding-collapse'
|
className='no-h-padding-collapse'
|
||||||
>
|
>
|
||||||
<Collapse.Panel
|
<Collapse.Panel
|
||||||
header={
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<JobIcon />
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
Sub Job Information
|
Sub Job Information
|
||||||
</Title>
|
</Title>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
key='2'
|
key='2'
|
||||||
>
|
>
|
||||||
<SubJobsTree jobData={jobData} loading={loading} />
|
<SubJobsTree jobData={jobData} loading={fetchLoading} />
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
collapsible='icon'
|
expandIconPosition='end'
|
||||||
activeKey={collapseState.notes ? ['notes'] : []}
|
activeKey={collapseState.notes ? ['notes'] : []}
|
||||||
onChange={(keys) =>
|
onChange={(keys) =>
|
||||||
updateCollapseState('notes', keys.length > 0)
|
updateCollapseState('notes', keys.length > 0)
|
||||||
}
|
}
|
||||||
expandIcon={({ isActive }) => (
|
expandIcon={({ isActive }) => (
|
||||||
<CaretRightOutlined
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '2px' }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
className='no-h-padding-collapse'
|
className='no-h-padding-collapse'
|
||||||
>
|
>
|
||||||
<Collapse.Panel
|
<Collapse.Panel
|
||||||
header={
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<NoteIcon />
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
Notes
|
Notes
|
||||||
</Title>
|
</Title>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
key='notes'
|
key='notes'
|
||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<DashboardNotes />
|
<DashboardNotes _id={jobId} />
|
||||||
</Card>
|
</Card>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
collapsible='icon'
|
expandIconPosition='end'
|
||||||
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
||||||
onChange={(keys) =>
|
onChange={(keys) =>
|
||||||
updateCollapseState('auditLogs', keys.length > 0)
|
updateCollapseState('auditLogs', keys.length > 0)
|
||||||
}
|
}
|
||||||
expandIcon={({ isActive }) => (
|
expandIcon={({ isActive }) => (
|
||||||
<CaretRightOutlined
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '2px' }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
className='no-h-padding-collapse'
|
className='no-h-padding-collapse'
|
||||||
>
|
>
|
||||||
<Collapse.Panel
|
<Collapse.Panel
|
||||||
header={
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<AuditLogIcon />
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
Audit Logs
|
Audit Logs
|
||||||
</Title>
|
</Title>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
key='auditLogs'
|
key='auditLogs'
|
||||||
>
|
>
|
||||||
<AuditLogTable
|
<AuditLogTable
|
||||||
items={jobData?.auditLogs || []}
|
items={jobData?.auditLogs || []}
|
||||||
loading={loading}
|
loading={fetchLoading}
|
||||||
showTargetColumn={false}
|
showTargetColumn={false}
|
||||||
/>
|
/>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
|
|||||||
@ -29,6 +29,9 @@ import CheckIcon from '../../Icons/CheckIcon'
|
|||||||
import DashboardTable from '../common/DashboardTable'
|
import DashboardTable from '../common/DashboardTable'
|
||||||
|
|
||||||
import config from '../../../config'
|
import config from '../../../config'
|
||||||
|
import GridIcon from '../../Icons/GridIcon'
|
||||||
|
import ListIcon from '../../Icons/ListIcon'
|
||||||
|
import useViewMode from '../hooks/useViewMode'
|
||||||
|
|
||||||
const Printers = () => {
|
const Printers = () => {
|
||||||
const [messageApi, contextHolder] = message.useMessage()
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
@ -37,12 +40,14 @@ const Printers = () => {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const tableRef = useRef()
|
const tableRef = useRef()
|
||||||
|
|
||||||
|
// View mode state (cards/list), persisted in sessionStorage via custom hook
|
||||||
|
const [viewMode, setViewMode] = useViewMode('Printers')
|
||||||
|
|
||||||
// Column definitions
|
// Column definitions
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '',
|
title: <PrinterIcon />,
|
||||||
dataIndex: '',
|
key: 'icon',
|
||||||
key: '',
|
|
||||||
width: 40,
|
width: 40,
|
||||||
fixed: 'left',
|
fixed: 'left',
|
||||||
render: () => <PrinterIcon />
|
render: () => <PrinterIcon />
|
||||||
@ -71,7 +76,7 @@ const Printers = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'ID',
|
title: 'ID',
|
||||||
dataIndex: 'id',
|
dataIndex: '_id',
|
||||||
key: 'id',
|
key: 'id',
|
||||||
width: 180,
|
width: 180,
|
||||||
render: (text) => <IdText id={text} type='printer' longId={false} />
|
render: (text) => <IdText id={text} type='printer' longId={false} />
|
||||||
@ -129,7 +134,7 @@ const Printers = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Actions',
|
title: 'Actions',
|
||||||
key: 'operation',
|
key: 'actions',
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (record) => {
|
render: (record) => {
|
||||||
@ -139,11 +144,11 @@ const Printers = () => {
|
|||||||
icon={<ControlIcon />}
|
icon={<ControlIcon />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(
|
navigate(
|
||||||
`/dashboard/production/printers/control?printerId=${record.id}`
|
`/dashboard/production/printers/control?printerId=${record._id}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Dropdown menu={getPrinterActionItems(record.id)}>
|
<Dropdown menu={getPrinterActionItems(record._id)}>
|
||||||
<Button>Actions</Button>
|
<Button>Actions</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</Space>
|
</Space>
|
||||||
@ -284,6 +289,7 @@ const Printers = () => {
|
|||||||
<>
|
<>
|
||||||
<Flex vertical={'true'} gap='large'>
|
<Flex vertical={'true'} gap='large'>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
|
<Flex justify={'space-between'}>
|
||||||
<Space>
|
<Space>
|
||||||
<Dropdown menu={actionItems}>
|
<Dropdown menu={actionItems}>
|
||||||
<Button>Actions</Button>
|
<Button>Actions</Button>
|
||||||
@ -296,12 +302,22 @@ const Printers = () => {
|
|||||||
<Button>View</Button>
|
<Button>View</Button>
|
||||||
</Popover>
|
</Popover>
|
||||||
</Space>
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
|
||||||
|
onClick={() =>
|
||||||
|
setViewMode(viewMode === 'cards' ? 'list' : 'cards')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
<DashboardTable
|
<DashboardTable
|
||||||
ref={tableRef}
|
ref={tableRef}
|
||||||
columns={visibleColumns}
|
columns={visibleColumns}
|
||||||
url={`${config.backendUrl}/printers`}
|
url={`${config.backendUrl}/printers`}
|
||||||
authenticated={authenticated}
|
authenticated={authenticated}
|
||||||
|
cards={viewMode === 'cards'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
Collapse
|
Collapse
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
import { SocketContext } from '../../context/SocketContext'
|
import { SocketContext } from '../../context/SocketContext'
|
||||||
|
|
||||||
@ -65,6 +65,7 @@ const ControlPrinter = () => {
|
|||||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
|
|
||||||
const [printerData, setPrinterData] = useState(null)
|
const [printerData, setPrinterData] = useState(null)
|
||||||
|
const [fetchLoading, setFetchLoading] = useState(true)
|
||||||
const [initialized, setInitialized] = useState(false)
|
const [initialized, setInitialized] = useState(false)
|
||||||
const [loadFilamentStockModalOpen, setLoadFilamentStockModalOpen] =
|
const [loadFilamentStockModalOpen, setLoadFilamentStockModalOpen] =
|
||||||
useState(false)
|
useState(false)
|
||||||
@ -111,6 +112,7 @@ const ControlPrinter = () => {
|
|||||||
// Fetch printer details when the component mounts
|
// Fetch printer details when the component mounts
|
||||||
const fetchPrinterDetails = useCallback(async () => {
|
const fetchPrinterDetails = useCallback(async () => {
|
||||||
if (printerId) {
|
if (printerId) {
|
||||||
|
setFetchLoading(true)
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(
|
const response = await axios.get(
|
||||||
`${config.backendUrl}/printers/${printerId}`,
|
`${config.backendUrl}/printers/${printerId}`,
|
||||||
@ -121,9 +123,10 @@ const ControlPrinter = () => {
|
|||||||
withCredentials: true // Important for including cookies
|
withCredentials: true // Important for including cookies
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
setFetchLoading(false)
|
||||||
setPrinterData(response.data)
|
setPrinterData(response.data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setFetchLoading(false)
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
messageApi.error(
|
messageApi.error(
|
||||||
'Error fetching printer data:',
|
'Error fetching printer data:',
|
||||||
@ -485,7 +488,6 @@ const ControlPrinter = () => {
|
|||||||
</Space>
|
</Space>
|
||||||
</Flex>
|
</Flex>
|
||||||
<div style={{ height: '100%', overflow: 'auto' }}>
|
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||||
{printerData ? (
|
|
||||||
<Flex gap={'large'} wrap>
|
<Flex gap={'large'} wrap>
|
||||||
<Flex vertical style={{ flexGrow: 1 }} gap={'large'}>
|
<Flex vertical style={{ flexGrow: 1 }} gap={'large'}>
|
||||||
<Flex gap={16} vertical style={{ flexGrow: 1 }}>
|
<Flex gap={16} vertical style={{ flexGrow: 1 }}>
|
||||||
@ -498,16 +500,11 @@ const ControlPrinter = () => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
collapsible='icon'
|
expandIconPosition='end'
|
||||||
activeKey={collapseState.job ? ['1'] : []}
|
activeKey={collapseState.job ? ['1'] : []}
|
||||||
onChange={(keys) =>
|
onChange={(keys) => updateCollapseState('job', keys.length > 0)}
|
||||||
updateCollapseState('job', keys.length > 0)
|
|
||||||
}
|
|
||||||
expandIcon={({ isActive }) => (
|
expandIcon={({ isActive }) => (
|
||||||
<CaretRightOutlined
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '2px' }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
style={{ padding: 0 }}
|
style={{ padding: 0 }}
|
||||||
className='no-h-padding-collapse no-t-padding-collapse'
|
className='no-h-padding-collapse no-t-padding-collapse'
|
||||||
@ -525,6 +522,10 @@ const ControlPrinter = () => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
}
|
}
|
||||||
key='1'
|
key='1'
|
||||||
|
>
|
||||||
|
<Spin
|
||||||
|
indicator={<LoadingOutlined spin />}
|
||||||
|
spinning={fetchLoading}
|
||||||
>
|
>
|
||||||
<Descriptions
|
<Descriptions
|
||||||
bordered
|
bordered
|
||||||
@ -538,11 +539,15 @@ const ControlPrinter = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Descriptions.Item label='Printer Name'>
|
<Descriptions.Item label='Printer Name'>
|
||||||
{printerData.name}
|
{printerData?.name ? (
|
||||||
|
<Text>{printerData.name}</Text>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Printer ID'>
|
<Descriptions.Item label='Printer ID'>
|
||||||
{printerData._id ? (
|
{printerData?._id ? (
|
||||||
<IdText
|
<IdText
|
||||||
id={printerData._id}
|
id={printerData._id}
|
||||||
type='printer'
|
type='printer'
|
||||||
@ -550,12 +555,12 @@ const ControlPrinter = () => {
|
|||||||
showHyperlink={true}
|
showHyperlink={true}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='GCode File Name'>
|
<Descriptions.Item label='GCode File Name'>
|
||||||
{printerData.currentJob?.gcodeFile?.name ? (
|
{printerData?.currentJob?.gcodeFile?.name ? (
|
||||||
<Space>
|
<Space>
|
||||||
<GCodeFileIcon />
|
<GCodeFileIcon />
|
||||||
<Text ellipsis style={{ maxWidth: 200 }}>
|
<Text ellipsis style={{ maxWidth: 200 }}>
|
||||||
@ -563,11 +568,11 @@ const ControlPrinter = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='GCode File ID'>
|
<Descriptions.Item label='GCode File ID'>
|
||||||
{printerData.currentJob?.gcodeFile ? (
|
{printerData?.currentJob?.gcodeFile ? (
|
||||||
<IdText
|
<IdText
|
||||||
id={printerData.currentJob.gcodeFile.id}
|
id={printerData.currentJob.gcodeFile.id}
|
||||||
type='gcodeFile'
|
type='gcodeFile'
|
||||||
@ -575,12 +580,12 @@ const ControlPrinter = () => {
|
|||||||
showHyperlink={true}
|
showHyperlink={true}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Print Job ID'>
|
<Descriptions.Item label='Print Job ID'>
|
||||||
{printerData.currentJob?.id ? (
|
{printerData?.currentJob?.id ? (
|
||||||
<IdText
|
<IdText
|
||||||
id={printerData.currentJob.id}
|
id={printerData.currentJob.id}
|
||||||
type='job'
|
type='job'
|
||||||
@ -588,12 +593,12 @@ const ControlPrinter = () => {
|
|||||||
showHyperlink={true}
|
showHyperlink={true}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Sub Job ID'>
|
<Descriptions.Item label='Sub Job ID'>
|
||||||
{printerData.currentSubJob?.id ? (
|
{printerData?.currentSubJob?.id ? (
|
||||||
<IdText
|
<IdText
|
||||||
id={printerData.currentSubJob.number
|
id={printerData.currentSubJob.number
|
||||||
.toString()
|
.toString()
|
||||||
@ -603,11 +608,11 @@ const ControlPrinter = () => {
|
|||||||
showHyperlink={false}
|
showHyperlink={false}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
{printerData?.state.type === 'printing' && (
|
{printerData?.state?.type === 'printing' && (
|
||||||
<>
|
<>
|
||||||
<Descriptions.Item label='Progress' span={1}>
|
<Descriptions.Item label='Progress' span={1}>
|
||||||
<Progress
|
<Progress
|
||||||
@ -618,71 +623,58 @@ const ControlPrinter = () => {
|
|||||||
/>
|
/>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Started At' span={1}>
|
<Descriptions.Item label='Started At' span={1}>
|
||||||
{printerData.currentSubJob?.startedAt ? (
|
{printerData?.currentSubJob?.startedAt ? (
|
||||||
<TimeDisplay
|
<TimeDisplay
|
||||||
dateTime={printerData.currentSubJob.startedAt}
|
dateTime={printerData.currentSubJob.startedAt}
|
||||||
showSince={true}
|
showSince={true}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Descriptions.Item label='Print Profile'>
|
<Descriptions.Item label='Print Profile'>
|
||||||
{(() => {
|
{printerData?.currentJob?.gcodeFile?.gcodeFileInfo
|
||||||
if (
|
?.printSettingsId ? (
|
||||||
printerData?.currentJob?.gcodeFile.gcodeFileInfo
|
|
||||||
.printSettingsId
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<Text ellipsis style={{ maxWidth: 200 }}>
|
<Text ellipsis style={{ maxWidth: 200 }}>
|
||||||
{printerData.currentJob.gcodeFile.gcodeFileInfo.printSettingsId.replaceAll(
|
{printerData.currentJob.gcodeFile.gcodeFileInfo.printSettingsId.replaceAll(
|
||||||
'"',
|
'"',
|
||||||
''
|
''
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
) : (
|
||||||
} else {
|
<Text>n/a</Text>
|
||||||
return 'n/a'
|
)}
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Est. Print Time'>
|
<Descriptions.Item label='Est. Print Time'>
|
||||||
{(() => {
|
{printerData?.currentJob?.gcodeFile?.gcodeFileInfo
|
||||||
if (
|
?.estimatedPrintingTimeNormalMode ? (
|
||||||
printerData.currentJob?.gcodeFile?.gcodeFileInfo
|
|
||||||
.estimatedPrintingTimeNormalMode
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<Text ellipsis>
|
<Text ellipsis>
|
||||||
{
|
{
|
||||||
printerData.currentJob.gcodeFile.gcodeFileInfo
|
printerData.currentJob.gcodeFile.gcodeFileInfo
|
||||||
.estimatedPrintingTimeNormalMode
|
.estimatedPrintingTimeNormalMode
|
||||||
}
|
}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
) : (
|
||||||
}
|
<Text>n/a</Text>
|
||||||
return 'n/a'
|
)}
|
||||||
})()}
|
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
</Spin>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
collapsible='icon'
|
expandIconPosition='end'
|
||||||
activeKey={collapseState.filament ? ['1'] : []}
|
activeKey={collapseState.filament ? ['1'] : []}
|
||||||
onChange={(keys) =>
|
onChange={(keys) =>
|
||||||
updateCollapseState('filament', keys.length > 0)
|
updateCollapseState('filament', keys.length > 0)
|
||||||
}
|
}
|
||||||
expandIcon={({ isActive }) => (
|
expandIcon={({ isActive }) => (
|
||||||
<CaretRightOutlined
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '2px' }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
style={{ padding: 0 }}
|
style={{ padding: 0 }}
|
||||||
className='no-h-padding-collapse'
|
className='no-h-padding-collapse'
|
||||||
@ -700,6 +692,10 @@ const ControlPrinter = () => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
}
|
}
|
||||||
key='1'
|
key='1'
|
||||||
|
>
|
||||||
|
<Spin
|
||||||
|
indicator={<LoadingOutlined spin />}
|
||||||
|
spinning={fetchLoading}
|
||||||
>
|
>
|
||||||
<Descriptions
|
<Descriptions
|
||||||
bordered
|
bordered
|
||||||
@ -718,11 +714,11 @@ const ControlPrinter = () => {
|
|||||||
filamentStock={printerData?.currentFilamentStock}
|
filamentStock={printerData?.currentFilamentStock}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Filament Stock ID'>
|
<Descriptions.Item label='Filament Stock ID'>
|
||||||
{printerData.currentFilamentStock ? (
|
{printerData?.currentFilamentStock?._id ? (
|
||||||
<IdText
|
<IdText
|
||||||
id={printerData.currentFilamentStock._id}
|
id={printerData.currentFilamentStock._id}
|
||||||
type='filamentstock'
|
type='filamentstock'
|
||||||
@ -730,11 +726,11 @@ const ControlPrinter = () => {
|
|||||||
showHyperlink={true}
|
showHyperlink={true}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Filament Name'>
|
<Descriptions.Item label='Filament Name'>
|
||||||
{printerData.currentFilamentStock?.filament?.name ? (
|
{printerData?.currentFilamentStock?.filament?.name ? (
|
||||||
<Space>
|
<Space>
|
||||||
<FilamentIcon />
|
<FilamentIcon />
|
||||||
<Badge
|
<Badge
|
||||||
@ -747,7 +743,7 @@ const ControlPrinter = () => {
|
|||||||
></Badge>
|
></Badge>
|
||||||
</Space>
|
</Space>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Filament ID'>
|
<Descriptions.Item label='Filament ID'>
|
||||||
@ -759,11 +755,11 @@ const ControlPrinter = () => {
|
|||||||
showHyperlink={true}
|
showHyperlink={true}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Weight'>
|
<Descriptions.Item label='Weight'>
|
||||||
{printerData.currentFilamentStock?.currentNetWeight ? (
|
{printerData?.currentFilamentStock?.currentNetWeight ? (
|
||||||
<div>
|
<div>
|
||||||
<Descriptions
|
<Descriptions
|
||||||
style={{ width: isMobile ? '100%' : '250px' }}
|
style={{ width: isMobile ? '100%' : '250px' }}
|
||||||
@ -783,24 +779,22 @@ const ControlPrinter = () => {
|
|||||||
</Descriptions>
|
</Descriptions>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
</Spin>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
collapsible='icon'
|
expandIconPosition='end'
|
||||||
activeKey={collapseState.jobs ? ['1'] : []}
|
activeKey={collapseState.jobs ? ['1'] : []}
|
||||||
onChange={(keys) =>
|
onChange={(keys) =>
|
||||||
updateCollapseState('jobs', keys.length > 0)
|
updateCollapseState('jobs', keys.length > 0)
|
||||||
}
|
}
|
||||||
expandIcon={({ isActive }) => (
|
expandIcon={({ isActive }) => (
|
||||||
<CaretRightOutlined
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '2px' }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
style={{ padding: 0 }}
|
style={{ padding: 0 }}
|
||||||
className='no-h-padding-collapse'
|
className='no-h-padding-collapse'
|
||||||
@ -819,21 +813,32 @@ const ControlPrinter = () => {
|
|||||||
}
|
}
|
||||||
key='1'
|
key='1'
|
||||||
>
|
>
|
||||||
<PrinterSubJobsTree subJobs={printerData.subJobs} />
|
<PrinterSubJobsTree
|
||||||
|
subJobs={printerData?.subJobs}
|
||||||
|
loading={fetchLoading}
|
||||||
|
/>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex gap={'large'} wrap vertical>
|
<Flex gap={'large'} wrap vertical>
|
||||||
{componentVisibility.temperature && (
|
{componentVisibility.temperature && (
|
||||||
|
<Spin
|
||||||
|
indicator={<LoadingOutlined spin />}
|
||||||
|
spinning={fetchLoading}
|
||||||
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<PrinterTemperaturePanel
|
<PrinterTemperaturePanel
|
||||||
printerId={printerId}
|
printerId={printerId}
|
||||||
disabled={!printerData.online}
|
|
||||||
></PrinterTemperaturePanel>
|
></PrinterTemperaturePanel>
|
||||||
</Card>
|
</Card>
|
||||||
|
</Spin>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{componentVisibility.position && (
|
{componentVisibility.position && (
|
||||||
|
<Spin
|
||||||
|
indicator={<LoadingOutlined spin />}
|
||||||
|
spinning={fetchLoading}
|
||||||
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<PrinterPositionPanel
|
<PrinterPositionPanel
|
||||||
printerId={printerId}
|
printerId={printerId}
|
||||||
@ -841,26 +846,34 @@ const ControlPrinter = () => {
|
|||||||
showMoreInfo={true}
|
showMoreInfo={true}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
</Spin>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{componentVisibility.movement && (
|
{componentVisibility.movement && (
|
||||||
|
<Spin
|
||||||
|
indicator={<LoadingOutlined spin />}
|
||||||
|
spinning={fetchLoading}
|
||||||
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<PrinterMovementPanel
|
<PrinterMovementPanel
|
||||||
printerId={printerId}
|
printerId={printerId}
|
||||||
></PrinterMovementPanel>
|
></PrinterMovementPanel>
|
||||||
</Card>
|
</Card>
|
||||||
|
</Spin>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{componentVisibility.misc && (
|
{componentVisibility.misc && (
|
||||||
|
<Spin
|
||||||
|
indicator={<LoadingOutlined spin />}
|
||||||
|
spinning={fetchLoading}
|
||||||
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<PrinterMiscPanel printerId={printerId} />
|
<PrinterMiscPanel printerId={printerId} />
|
||||||
</Card>
|
</Card>
|
||||||
|
</Spin>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
) : (
|
|
||||||
<Spin indicator={<LoadingOutlined spin />} size='large' />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@ -14,9 +14,13 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Select,
|
Select,
|
||||||
Collapse
|
Collapse,
|
||||||
|
Dropdown,
|
||||||
|
Popover,
|
||||||
|
Checkbox,
|
||||||
|
Card
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||||
import PrinterState from '../../common/PrinterState'
|
import PrinterState from '../../common/PrinterState'
|
||||||
import TimeDisplay from '../../common/TimeDisplay'
|
import TimeDisplay from '../../common/TimeDisplay'
|
||||||
import IdText from '../../common/IdText'
|
import IdText from '../../common/IdText'
|
||||||
@ -32,13 +36,18 @@ import useCollapseState from '../../hooks/useCollapseState'
|
|||||||
|
|
||||||
import config from '../../../../config.js'
|
import config from '../../../../config.js'
|
||||||
import AuditLogTable from '../../common/AuditLogTable.jsx'
|
import AuditLogTable from '../../common/AuditLogTable.jsx'
|
||||||
|
import DashboardNotes from '../../common/DashboardNotes.jsx'
|
||||||
|
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
|
||||||
|
import NoteIcon from '../../../Icons/NoteIcon.jsx'
|
||||||
|
import PrinterIcon from '../../../Icons/PrinterIcon.jsx'
|
||||||
|
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
const PrinterInfo = () => {
|
const PrinterInfo = () => {
|
||||||
const [printerData, setPrinterData] = useState(null)
|
const [printerData, setPrinterData] = useState(null)
|
||||||
const [fetchLoading, setFetchLoading] = useState(true)
|
const [fetchLoading, setFetchLoading] = useState(true)
|
||||||
const [loading, setLoading] = useState(false)
|
const [editLoading, setEditLoading] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const printerId = new URLSearchParams(location.search).get('printerId')
|
const printerId = new URLSearchParams(location.search).get('printerId')
|
||||||
@ -121,7 +130,7 @@ const PrinterInfo = () => {
|
|||||||
const updatePrinterInfo = async () => {
|
const updatePrinterInfo = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
setLoading(true)
|
setEditLoading(true)
|
||||||
|
|
||||||
await axios.put(
|
await axios.put(
|
||||||
`${config.backendUrl}/printers/${printerId}`,
|
`${config.backendUrl}/printers/${printerId}`,
|
||||||
@ -156,7 +165,7 @@ const PrinterInfo = () => {
|
|||||||
console.error('Failed to update printer information:', err)
|
console.error('Failed to update printer information:', err)
|
||||||
messageApi.error('Failed to update printer information')
|
messageApi.error('Failed to update printer information')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setEditLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,55 +185,69 @@ const PrinterInfo = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fetchLoading) {
|
const actionItems = {
|
||||||
return (
|
items: [
|
||||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
{
|
||||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
label: 'Reload Printer',
|
||||||
</div>
|
key: 'reload',
|
||||||
)
|
icon: <ReloadIcon />
|
||||||
|
}
|
||||||
|
],
|
||||||
|
onClick: ({ key }) => {
|
||||||
|
if (key === 'reload') {
|
||||||
|
fetchPrinterDetails()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !printerData) {
|
const getViewDropdownItems = () => {
|
||||||
|
const sections = [
|
||||||
|
{ key: 'info', label: 'Printer Information' },
|
||||||
|
{ key: 'jobs', label: 'Printer Jobs' },
|
||||||
|
{ key: 'notes', label: 'Notes' },
|
||||||
|
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space
|
<Flex vertical>
|
||||||
direction='vertical'
|
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
|
||||||
style={{ width: '100%', textAlign: 'center' }}
|
{sections.map((section) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={collapseState[section.key]}
|
||||||
|
key={section.key}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateCollapseState(section.key, e.target.checked)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<p>{error || 'Printer not found'}</p>
|
{section.label}
|
||||||
<Button icon={<ReloadIcon />} onClick={fetchPrinterDetails}>
|
</Checkbox>
|
||||||
Retry
|
))}
|
||||||
</Button>
|
</Flex>
|
||||||
</Space>
|
</Flex>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
<>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<Flex vertical gap={'large'}>
|
|
||||||
<Collapse
|
|
||||||
ghost
|
|
||||||
collapsible='icon'
|
|
||||||
activeKey={collapseState.info ? ['1'] : []}
|
|
||||||
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
|
|
||||||
expandIcon={({ isActive }) => (
|
|
||||||
<CaretRightOutlined
|
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '9px' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
className='no-h-padding-collapse no-t-padding-collapse'
|
|
||||||
>
|
|
||||||
<Collapse.Panel
|
|
||||||
header={
|
|
||||||
<Flex
|
<Flex
|
||||||
align='center'
|
gap='large'
|
||||||
justify='space-between'
|
vertical='true'
|
||||||
style={{ width: '100%' }}
|
style={{ height: '100%', minHeight: 0 }}
|
||||||
>
|
>
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Flex justify={'space-between'}>
|
||||||
Printer Information
|
<Space size='small'>
|
||||||
</Title>
|
<Dropdown menu={actionItems}>
|
||||||
|
<Button>Actions</Button>
|
||||||
|
</Dropdown>
|
||||||
|
<Popover
|
||||||
|
content={getViewDropdownItems()}
|
||||||
|
placement='bottomLeft'
|
||||||
|
arrow={false}
|
||||||
|
>
|
||||||
|
<Button>View</Button>
|
||||||
|
</Popover>
|
||||||
|
</Space>
|
||||||
<Space>
|
<Space>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<>
|
<>
|
||||||
@ -232,12 +255,13 @@ const PrinterInfo = () => {
|
|||||||
icon={<CheckIcon />}
|
icon={<CheckIcon />}
|
||||||
type='primary'
|
type='primary'
|
||||||
onClick={updatePrinterInfo}
|
onClick={updatePrinterInfo}
|
||||||
loading={loading}
|
loading={editLoading}
|
||||||
|
disabled={editLoading}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon={<XMarkIcon />}
|
icon={<XMarkIcon />}
|
||||||
onClick={cancelEditing}
|
onClick={cancelEditing}
|
||||||
disabled={loading}
|
disabled={editLoading}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -245,23 +269,61 @@ const PrinterInfo = () => {
|
|||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Space
|
||||||
|
direction='vertical'
|
||||||
|
style={{ width: '100%', textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
<p>{error || 'Print job not found'}</p>
|
||||||
|
<Button icon={<ReloadIcon />} onClick={fetchPrinterDetails}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||||
|
<Flex vertical gap={'large'}>
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
expandIconPosition='end'
|
||||||
|
activeKey={collapseState.info ? ['info'] : []}
|
||||||
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('info', keys.length > 0)
|
||||||
}
|
}
|
||||||
key='1'
|
expandIcon={({ isActive }) => (
|
||||||
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
|
)}
|
||||||
|
className='no-h-padding-collapse no-t-padding-collapse'
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<InfoCircleIcon />
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Printer Information
|
||||||
|
</Title>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
key='info'
|
||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout='vertical'
|
layout='vertical'
|
||||||
initialValues={{
|
initialValues={{
|
||||||
name: printerData.name || '',
|
name: printerData?.name || '',
|
||||||
vendor: printerData.vendor || { id: null, name: '' },
|
vendor: printerData?.vendor || { id: null, name: '' },
|
||||||
moonraker: {
|
moonraker: {
|
||||||
host: printerData.moonraker?.host || '',
|
host: printerData?.moonraker?.host || '',
|
||||||
port: printerData.moonraker?.port || null,
|
port: printerData?.moonraker?.port || null,
|
||||||
protocol: printerData.moonraker?.protocol || 'ws',
|
protocol: printerData?.moonraker?.protocol || 'ws',
|
||||||
apiKey: printerData.moonraker?.apiKey || ''
|
apiKey: printerData?.moonraker?.apiKey || ''
|
||||||
},
|
},
|
||||||
tags: printerData.tags || []
|
tags: printerData?.tags || []
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<Spin
|
||||||
|
spinning={fetchLoading}
|
||||||
|
indicator={<LoadingOutlined />}
|
||||||
>
|
>
|
||||||
<Descriptions
|
<Descriptions
|
||||||
bordered
|
bordered
|
||||||
@ -276,13 +338,21 @@ const PrinterInfo = () => {
|
|||||||
>
|
>
|
||||||
{/* Read-only fields */}
|
{/* Read-only fields */}
|
||||||
<Descriptions.Item label='ID'>
|
<Descriptions.Item label='ID'>
|
||||||
<IdText id={printerData._id} type='printer' />
|
{printerData?._id ? (
|
||||||
|
<IdText id={printerData._id} type={'printer'} />
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label='Connected At'>
|
<Descriptions.Item label='Connected At'>
|
||||||
|
{printerData?.connectedAt ? (
|
||||||
<TimeDisplay
|
<TimeDisplay
|
||||||
dateTime={printerData.connectedAt}
|
dateTime={printerData.connectedAt}
|
||||||
showSince={true}
|
showSince={true}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
{/* Editable fields */}
|
{/* Editable fields */}
|
||||||
@ -304,8 +374,10 @@ const PrinterInfo = () => {
|
|||||||
>
|
>
|
||||||
<Input placeholder='Enter printer name' />
|
<Input placeholder='Enter printer name' />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
) : printerData?.name ? (
|
||||||
|
<Text>{printerData.name}</Text>
|
||||||
) : (
|
) : (
|
||||||
printerData.name || 'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
@ -314,19 +386,25 @@ const PrinterInfo = () => {
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
name={['moonraker', 'host']}
|
name={['moonraker', 'host']}
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: 'Please enter a host' },
|
{
|
||||||
|
required: true,
|
||||||
|
message: 'Please enter a host'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
pattern:
|
pattern:
|
||||||
/^[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$|\b(?:\d{1,3}\.){3}\d{1,3}\b/,
|
/^[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$|\b(?:\d{1,3}\.){3}\d{1,3}\b/,
|
||||||
message: 'Please enter a valid hostname or IP address'
|
message:
|
||||||
|
'Please enter a valid hostname or IP address'
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
style={{ margin: 0 }}
|
style={{ margin: 0 }}
|
||||||
>
|
>
|
||||||
<Input placeholder='Enter host (e.g., 192.168.1.100)' />
|
<Input placeholder='Enter host (e.g., 192.168.1.100)' />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
) : printerData?.moonraker?.host ? (
|
||||||
|
<Text>{printerData.moonraker.host}</Text>
|
||||||
) : (
|
) : (
|
||||||
printerData.moonraker?.host || 'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
@ -335,17 +413,22 @@ const PrinterInfo = () => {
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
name='vendor'
|
name='vendor'
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: 'Please enter a vendor' }
|
{
|
||||||
|
required: true,
|
||||||
|
message: 'Please enter a vendor'
|
||||||
|
}
|
||||||
]}
|
]}
|
||||||
style={{ margin: 0 }}
|
style={{ margin: 0 }}
|
||||||
>
|
>
|
||||||
<VendorSelect />
|
<VendorSelect />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : (
|
) : printerData?.vendor?.name ? (
|
||||||
<Space>
|
<Space>
|
||||||
<VendorIcon />
|
<VendorIcon />
|
||||||
{printerData?.vendor?.name || 'n/a'}
|
{printerData?.vendor?.name || 'n/a'}
|
||||||
</Space>
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
@ -357,7 +440,7 @@ const PrinterInfo = () => {
|
|||||||
showHyperlink={true}
|
showHyperlink={true}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
'n/a'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
@ -386,8 +469,10 @@ const PrinterInfo = () => {
|
|||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
) : printerData?.moonraker?.port ? (
|
||||||
|
<Text>{printerData.moonraker.port}</Text>
|
||||||
) : (
|
) : (
|
||||||
printerData.moonraker.port
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
@ -395,7 +480,9 @@ const PrinterInfo = () => {
|
|||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name={['moonraker', 'protocol']}
|
name={['moonraker', 'protocol']}
|
||||||
rules={[{ required: true, message: 'Port is required' }]}
|
rules={[
|
||||||
|
{ required: true, message: 'Port is required' }
|
||||||
|
]}
|
||||||
style={{ margin: 0 }}
|
style={{ margin: 0 }}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
@ -406,10 +493,12 @@ const PrinterInfo = () => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : printerData.moonraker.protocol == 'ws' ? (
|
) : printerData?.moonraker?.protocol == 'ws' ? (
|
||||||
'Websocket'
|
<Text>Websocket</Text>
|
||||||
|
) : printerData?.moonraker?.protocol == 'wss' ? (
|
||||||
|
<Text>Websocket Secure</Text>
|
||||||
) : (
|
) : (
|
||||||
'Websocket Secure'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
@ -421,20 +510,24 @@ const PrinterInfo = () => {
|
|||||||
>
|
>
|
||||||
<Input.Password placeholder='Enter API key' />
|
<Input.Password placeholder='Enter API key' />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : printerData.moonraker?.apiKey ? (
|
) : printerData?.moonraker?.apiKey ? (
|
||||||
'Configured'
|
<Text>Configured</Text>
|
||||||
) : (
|
) : (
|
||||||
'Not configured'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Status'>
|
<Descriptions.Item label='Status'>
|
||||||
|
{printerData?.state ? (
|
||||||
<PrinterState
|
<PrinterState
|
||||||
printer={printerData}
|
printer={printerData}
|
||||||
showPrinterName={false}
|
showPrinterName={false}
|
||||||
showControls={false}
|
showControls={false}
|
||||||
showProgress={false}
|
showProgress={false}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Tags'>
|
<Descriptions.Item label='Tags'>
|
||||||
@ -461,10 +554,13 @@ const PrinterInfo = () => {
|
|||||||
<Form.Item name='newTag' noStyle>
|
<Form.Item name='newTag' noStyle>
|
||||||
<Input placeholder='Add new tag' />
|
<Input placeholder='Add new tag' />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button onClick={handleTagAdd} icon={<PlusIcon />} />
|
<Button
|
||||||
|
onClick={handleTagAdd}
|
||||||
|
icon={<PlusIcon />}
|
||||||
|
/>
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : printerData.tags?.length > 0 ? (
|
) : printerData?.tags?.length > 0 ? (
|
||||||
<Space
|
<Space
|
||||||
size={[0, 2]}
|
size={[0, 2]}
|
||||||
wrap
|
wrap
|
||||||
@ -477,73 +573,117 @@ const PrinterInfo = () => {
|
|||||||
))}
|
))}
|
||||||
</Space>
|
</Space>
|
||||||
) : (
|
) : (
|
||||||
'No tags'
|
<Text>n/a</Text>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
||||||
<Descriptions.Item label='Firmware Version'>
|
<Descriptions.Item label='Firmware Version'>
|
||||||
{printerData.firmware || 'Unknown'}
|
{printerData?.firmware ? (
|
||||||
|
<Text>{printerData.firmware}</Text>
|
||||||
|
) : (
|
||||||
|
<Text>n/a</Text>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
</Spin>
|
||||||
</Form>
|
</Form>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
collapsible='icon'
|
expandIconPosition='end'
|
||||||
activeKey={collapseState.jobs ? ['2'] : []}
|
activeKey={collapseState.jobs ? ['jobs'] : []}
|
||||||
onChange={(keys) => updateCollapseState('jobs', keys.length > 0)}
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('jobs', keys.length > 0)
|
||||||
|
}
|
||||||
expandIcon={({ isActive }) => (
|
expandIcon={({ isActive }) => (
|
||||||
<CaretRightOutlined
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '2px' }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
className='no-h-padding-collapse'
|
className='no-h-padding-collapse'
|
||||||
>
|
>
|
||||||
<Collapse.Panel
|
<Collapse.Panel
|
||||||
header={
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<PrinterIcon />
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
Printer Jobs
|
Printer Jobs
|
||||||
</Title>
|
</Title>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
key='2'
|
key='jobs'
|
||||||
>
|
>
|
||||||
<PrinterSubJobsList subJobs={printerData.subJobs} />
|
<PrinterSubJobsList
|
||||||
|
subJobs={printerData?.subJobs}
|
||||||
|
loading={fetchLoading}
|
||||||
|
/>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
collapsible='icon'
|
expandIconPosition='end'
|
||||||
activeKey={collapseState.auditLogs ? ['3'] : []}
|
activeKey={collapseState.notes ? ['notes'] : []}
|
||||||
onChange={(keys) => updateCollapseState('auditLogs', keys.length > 0)}
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('notes', keys.length > 0)
|
||||||
|
}
|
||||||
expandIcon={({ isActive }) => (
|
expandIcon={({ isActive }) => (
|
||||||
<CaretRightOutlined
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '2px' }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
className='no-h-padding-collapse'
|
className='no-h-padding-collapse'
|
||||||
>
|
>
|
||||||
<Collapse.Panel
|
<Collapse.Panel
|
||||||
header={
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<NoteIcon />
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Notes
|
||||||
|
</Title>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
key='notes'
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<DashboardNotes _id={printerId} />
|
||||||
|
</Card>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
expandIconPosition='end'
|
||||||
|
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
||||||
|
onChange={(keys) =>
|
||||||
|
updateCollapseState('auditLogs', keys.length > 0)
|
||||||
|
}
|
||||||
|
expandIcon={({ isActive }) => (
|
||||||
|
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||||
|
)}
|
||||||
|
className='no-h-padding-collapse'
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header={
|
||||||
|
<Flex align='center' gap={'middle'}>
|
||||||
|
<AuditLogIcon />
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
Audit Log
|
Audit Log
|
||||||
</Title>
|
</Title>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
key='3'
|
key='auditLogs'
|
||||||
>
|
>
|
||||||
<AuditLogTable
|
<AuditLogTable
|
||||||
items={printerData.auditLogs || []}
|
items={printerData?.auditLogs || []}
|
||||||
loading={false}
|
loading={fetchLoading}
|
||||||
showTargetColumn={false}
|
showTargetColumn={false}
|
||||||
/>
|
/>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
56
src/components/Dashboard/Production/ProductionSidebar.jsx
Normal 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
|
||||||
@ -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)) {
|
||||||
|
|||||||
41
src/components/Dashboard/common/BoolDisplay.jsx
Normal 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
|
||||||
@ -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 = () => {
|
||||||
|
|||||||
@ -9,7 +9,8 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
Divider
|
Divider,
|
||||||
|
Badge
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import {
|
import {
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
@ -20,6 +21,7 @@ import {
|
|||||||
import { AuthContext } from '../context/AuthContext'
|
import { AuthContext } from '../context/AuthContext'
|
||||||
import { SocketContext } from '../context/SocketContext'
|
import { SocketContext } from '../context/SocketContext'
|
||||||
import { SpotlightContext } from '../context/SpotlightContext'
|
import { SpotlightContext } from '../context/SpotlightContext'
|
||||||
|
import { NotificationContext } from '../context/NotificationContext'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { Header } from 'antd/es/layout/layout'
|
import { Header } from 'antd/es/layout/layout'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
@ -33,12 +35,15 @@ import CloudIcon from '../../Icons/CloudIcon'
|
|||||||
import BellIcon from '../../Icons/BellIcon'
|
import BellIcon from '../../Icons/BellIcon'
|
||||||
import SearchIcon from '../../Icons/SearchIcon'
|
import SearchIcon from '../../Icons/SearchIcon'
|
||||||
import SettingsIcon from '../../Icons/SettingsIcon'
|
import SettingsIcon from '../../Icons/SettingsIcon'
|
||||||
|
import DeveloperIcon from '../../Icons/DeveloperIcon'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
const DashboardNavigation = () => {
|
const DashboardNavigation = () => {
|
||||||
const { logout, userProfile } = useContext(AuthContext)
|
const { logout, userProfile } = useContext(AuthContext)
|
||||||
const { showSpotlight } = useContext(SpotlightContext)
|
const { showSpotlight } = useContext(SpotlightContext)
|
||||||
|
const { toggleNotificationCenter, unreadCount } =
|
||||||
|
useContext(NotificationContext)
|
||||||
const { socket } = useContext(SocketContext)
|
const { socket } = useContext(SocketContext)
|
||||||
const [socketState, setSocketState] = useState('disconnected')
|
const [socketState, setSocketState] = useState('disconnected')
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@ -168,12 +173,14 @@ const DashboardNavigation = () => {
|
|||||||
onClick={() => showSpotlight()}
|
onClick={() => showSpotlight()}
|
||||||
></Button>
|
></Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Badge count={unreadCount} size='small'>
|
||||||
<Button
|
<Button
|
||||||
icon={<BellIcon />}
|
icon={<BellIcon />}
|
||||||
type='text'
|
type='text'
|
||||||
style={{ marginTop: '2px' }}
|
style={{ marginTop: '2px' }}
|
||||||
onClick={() => showSpotlight()}
|
onClick={toggleNotificationCenter}
|
||||||
></Button>
|
></Button>
|
||||||
|
</Badge>
|
||||||
</Space>
|
</Space>
|
||||||
<Space>
|
<Space>
|
||||||
{socketState === 'connected' ? (
|
{socketState === 'connected' ? (
|
||||||
@ -206,10 +213,15 @@ const DashboardNavigation = () => {
|
|||||||
</Space>
|
</Space>
|
||||||
{process.env.NODE_ENV === 'development' && (
|
{process.env.NODE_ENV === 'development' && (
|
||||||
<Space>
|
<Space>
|
||||||
<Tooltip title='Development Environment' arrow={false}>
|
<Tooltip title='Developer' arrow={false}>
|
||||||
<Tag color='yellow' style={{ marginRight: 0 }}>
|
<Tag
|
||||||
Dev
|
color='yellow'
|
||||||
</Tag>
|
style={{ marginRight: 0 }}
|
||||||
|
icon={<DeveloperIcon />}
|
||||||
|
onClick={() => {
|
||||||
|
navigate('/dashboard/developer/sessionstorage')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useCallback, useContext, useEffect, useState } from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@ -9,31 +9,464 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
Select,
|
Switch,
|
||||||
Switch
|
Spin,
|
||||||
|
Alert,
|
||||||
|
message,
|
||||||
|
Divider,
|
||||||
|
Tag,
|
||||||
|
Dropdown
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
|
import { CaretLeftFilled, LoadingOutlined } from '@ant-design/icons'
|
||||||
import PlusIcon from '../../Icons/PlusIcon'
|
import PlusIcon from '../../Icons/PlusIcon'
|
||||||
|
import BinIcon from '../../Icons/BinIcon'
|
||||||
|
import PersonIcon from '../../Icons/PersonIcon'
|
||||||
import TimeDisplay from './TimeDisplay'
|
import TimeDisplay from './TimeDisplay'
|
||||||
import MarkdownDisplay from './MarkdownDisplay'
|
import MarkdownDisplay from './MarkdownDisplay'
|
||||||
|
import axios from 'axios'
|
||||||
|
import config from '../../../config'
|
||||||
|
import { AuthContext } from '../context/AuthContext'
|
||||||
|
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||||
|
import NoteTypeSelect from './NoteTypeSelect'
|
||||||
|
import IdText from './IdText'
|
||||||
|
import ReloadIcon from '../../Icons/ReloadIcon'
|
||||||
|
import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text, Title } = Typography
|
||||||
const { TextArea } = Input
|
const { TextArea } = Input
|
||||||
|
|
||||||
const DashboardNotes = ({ notes = [], onNewNote }) => {
|
const NoteItem = ({
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
note,
|
||||||
const [showMarkdown, setShowMarkdown] = useState(false)
|
expandedNotes,
|
||||||
const [form] = Form.useForm()
|
setExpandedNotes,
|
||||||
|
fetchData,
|
||||||
|
onNewNote,
|
||||||
|
onDeleteNote,
|
||||||
|
userProfile,
|
||||||
|
onChildNoteAdded
|
||||||
|
}) => {
|
||||||
|
const [childNotes, setChildNotes] = useState({})
|
||||||
|
const [loadingChildNotes, setLoadingChildNotes] = useState(null)
|
||||||
|
|
||||||
const handleNewNote = () => {
|
const isExpanded = expandedNotes[note._id]
|
||||||
setIsModalOpen(true)
|
const hasChildNotes = childNotes[note._id] && childNotes[note._id].length > 0
|
||||||
|
const isThisNoteLoading = loadingChildNotes === note._id
|
||||||
|
|
||||||
|
let transformValue = 'rotate(0deg)'
|
||||||
|
if (isExpanded) {
|
||||||
|
transformValue = 'rotate(-90deg)'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleNoteExpand = async (noteId) => {
|
||||||
|
const newExpandedState = !expandedNotes[noteId]
|
||||||
|
|
||||||
|
setExpandedNotes((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[noteId]: newExpandedState
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (newExpandedState && !childNotes[noteId]) {
|
||||||
|
setLoadingChildNotes(noteId)
|
||||||
|
try {
|
||||||
|
const childNotesData = await fetchData(noteId)
|
||||||
|
setChildNotes((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[noteId]: childNotesData
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching child notes:', error)
|
||||||
|
} finally {
|
||||||
|
setLoadingChildNotes(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNewChildNote = () => {
|
||||||
|
if (onNewNote) {
|
||||||
|
onNewNote(note._id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteNote = () => {
|
||||||
|
if (onDeleteNote) {
|
||||||
|
onDeleteNote(note._id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload child notes when a new child note is added
|
||||||
|
const reloadChildNotes = async () => {
|
||||||
|
// Always fetch child notes when this function is called
|
||||||
|
// This ensures child notes are loaded even if the parent wasn't expanded before
|
||||||
|
setLoadingChildNotes(note._id)
|
||||||
|
try {
|
||||||
|
const childNotesData = await fetchData(note._id)
|
||||||
|
setChildNotes((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[note._id]: childNotesData
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching child notes:', error)
|
||||||
|
} finally {
|
||||||
|
setLoadingChildNotes(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for child note additions
|
||||||
|
useEffect(() => {
|
||||||
|
if (onChildNoteAdded) {
|
||||||
|
onChildNoteAdded(note._id, reloadChildNotes)
|
||||||
|
}
|
||||||
|
}, [note._id, onChildNoteAdded])
|
||||||
|
|
||||||
|
// Check if the current user can delete this note
|
||||||
|
const canDeleteNote = userProfile && userProfile._id === note.user._id
|
||||||
|
|
||||||
|
const dropdownItems = [
|
||||||
|
{
|
||||||
|
key: 'new',
|
||||||
|
icon: <PlusIcon />,
|
||||||
|
label: 'New Note',
|
||||||
|
onClick: handleNewChildNote
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Only add delete option if user owns the note
|
||||||
|
if (canDeleteNote) {
|
||||||
|
dropdownItems.push({
|
||||||
|
key: 'delete',
|
||||||
|
label: 'Delete Note',
|
||||||
|
icon: <BinIcon />,
|
||||||
|
onClick: handleDeleteNote,
|
||||||
|
danger: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={note._id}
|
||||||
|
size='small'
|
||||||
|
style={{
|
||||||
|
backgroundColor: note.noteType.color + '26',
|
||||||
|
textAlign: 'left'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex vertical gap={'small'}>
|
||||||
|
<Flex gap={'middle'} align='start'>
|
||||||
|
<Space>
|
||||||
|
<PersonIcon />
|
||||||
|
<Text style={{ whiteSpace: 'nowrap' }}>{note.user.name}:</Text>
|
||||||
|
</Space>
|
||||||
|
<div style={{ marginBottom: '4px' }}>
|
||||||
|
<MarkdownDisplay content={note.content} />
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
<Divider style={{ margin: 0 }} />
|
||||||
|
<Flex wrap gap={'small'}>
|
||||||
|
<Dropdown
|
||||||
|
menu={{ items: dropdownItems }}
|
||||||
|
trigger={['hover']}
|
||||||
|
placement='bottomLeft'
|
||||||
|
>
|
||||||
|
<Button size='small'>Actions</Button>
|
||||||
|
</Dropdown>
|
||||||
|
<Space size={'small'} style={{ marginRight: 8 }}>
|
||||||
|
<Text type='secondary'>Type:</Text>
|
||||||
|
<Tag color={note.noteType.color} style={{ margin: 0 }}>
|
||||||
|
{note.noteType.name}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
<Space size={'small'} style={{ marginRight: 8 }}>
|
||||||
|
<Text type='secondary'>User ID:</Text>
|
||||||
|
<IdText
|
||||||
|
longId={false}
|
||||||
|
id={note.user._id}
|
||||||
|
type={'user'}
|
||||||
|
showHyperlink={true}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Space size={'small'} style={{ marginRight: 8 }}>
|
||||||
|
<Text type='secondary'>Created At:</Text>
|
||||||
|
<TimeDisplay dateTime={note.createdAt} showSince={true} />
|
||||||
|
</Space>
|
||||||
|
<Flex style={{ flexGrow: 1 }} justify='end'>
|
||||||
|
<Space size={'small'}>
|
||||||
|
<Button
|
||||||
|
icon={
|
||||||
|
isThisNoteLoading ? <LoadingOutlined /> : <CaretLeftFilled />
|
||||||
|
}
|
||||||
|
size='small'
|
||||||
|
type='text'
|
||||||
|
loading={isThisNoteLoading}
|
||||||
|
disabled={isThisNoteLoading}
|
||||||
|
style={{
|
||||||
|
transform: transformValue,
|
||||||
|
transition: 'transform 0.2s ease'
|
||||||
|
}}
|
||||||
|
onClick={() => handleNoteExpand(note._id)}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
{isExpanded && (
|
||||||
|
<>
|
||||||
|
<Flex vertical gap={'small'} style={{ flexGrow: 1 }}>
|
||||||
|
{hasChildNotes ? (
|
||||||
|
childNotes[note._id].map((childNote) => (
|
||||||
|
<NoteItem
|
||||||
|
key={childNote._id}
|
||||||
|
note={childNote}
|
||||||
|
expandedNotes={expandedNotes}
|
||||||
|
setExpandedNotes={setExpandedNotes}
|
||||||
|
fetchData={fetchData}
|
||||||
|
onNewNote={onNewNote}
|
||||||
|
onDeleteNote={onDeleteNote}
|
||||||
|
userProfile={userProfile}
|
||||||
|
onChildNoteAdded={onChildNoteAdded}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : !isThisNoteLoading ? (
|
||||||
|
<Card size='small'>
|
||||||
|
<Flex
|
||||||
|
justify='center'
|
||||||
|
gap={'small'}
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
align='center'
|
||||||
|
>
|
||||||
|
<Text type='secondary'>
|
||||||
|
<InfoCircleIcon />
|
||||||
|
</Text>
|
||||||
|
<Text type='secondary'>No child notes.</Text>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Flex vertical gap={'middle'}></Flex>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
NoteItem.propTypes = {
|
||||||
|
note: PropTypes.object.isRequired,
|
||||||
|
expandedNotes: PropTypes.object.isRequired,
|
||||||
|
setExpandedNotes: PropTypes.func.isRequired,
|
||||||
|
fetchData: PropTypes.func.isRequired,
|
||||||
|
onNewNote: PropTypes.func,
|
||||||
|
onDeleteNote: PropTypes.func,
|
||||||
|
userProfile: PropTypes.object,
|
||||||
|
onChildNoteAdded: PropTypes.func
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardNotes = ({ _id, onNewNote }) => {
|
||||||
|
const [newNoteOpen, setNewNoteOpen] = useState(false)
|
||||||
|
const [showMarkdown, setShowMarkdown] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
|
const [newNoteFormLoading, setNewNoteFormLoading] = useState(false)
|
||||||
|
const [newNoteFormValues, setNewNoteFormValues] = useState({})
|
||||||
|
const [deleteNoteLoading, setDeleteNoteLoading] = useState(false)
|
||||||
|
const [doneEnabled, setDoneEnabled] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [notes, setNotes] = useState(null)
|
||||||
|
const [expandedNotes, setExpandedNotes] = useState({})
|
||||||
|
const [newNoteForm] = Form.useForm()
|
||||||
|
const [selectedParentId, setSelectedParentId] = useState(null)
|
||||||
|
const [childNoteCallbacks, setChildNoteCallbacks] = useState({})
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||||
|
const [noteToDelete, setNoteToDelete] = useState(null)
|
||||||
|
|
||||||
|
const newNoteFormUpdateValues = Form.useWatch([], newNoteForm)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
newNoteForm
|
||||||
|
.validateFields({
|
||||||
|
validateOnly: true
|
||||||
|
})
|
||||||
|
.then(() => setDoneEnabled(true))
|
||||||
|
.catch(() => setDoneEnabled(false))
|
||||||
|
}, [newNoteForm, newNoteFormUpdateValues])
|
||||||
|
|
||||||
|
const { authenticated, userProfile } = useContext(AuthContext)
|
||||||
|
|
||||||
|
const fetchData = useCallback(async (id) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${config.backendUrl}/notes`, {
|
||||||
|
params: {
|
||||||
|
parent: id,
|
||||||
|
sort: 'createdAt',
|
||||||
|
order: 'ascend'
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
withCredentials: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const newData = response.data
|
||||||
|
setLoading(false)
|
||||||
|
return newData
|
||||||
|
} catch (error) {
|
||||||
|
setNotes([])
|
||||||
|
setError(error)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const generateNotes = useCallback(
|
||||||
|
async (id) => {
|
||||||
|
const notesData = await fetchData(id)
|
||||||
|
|
||||||
|
if (notesData.length <= 0) {
|
||||||
|
return (
|
||||||
|
<Spin indicator={<LoadingOutlined />} spinning={loading}>
|
||||||
|
<Card>
|
||||||
|
<Flex
|
||||||
|
justify='center'
|
||||||
|
gap={'small'}
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
align='center'
|
||||||
|
>
|
||||||
|
<Text type='secondary'>
|
||||||
|
<InfoCircleIcon />
|
||||||
|
</Text>
|
||||||
|
<Text type='secondary'>No notes added.</Text>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Spin>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return notesData.map((note) => (
|
||||||
|
<NoteItem
|
||||||
|
key={note._id}
|
||||||
|
note={note}
|
||||||
|
expandedNotes={expandedNotes}
|
||||||
|
setExpandedNotes={setExpandedNotes}
|
||||||
|
fetchData={fetchData}
|
||||||
|
onNewNote={handleNewNoteFromDropdown}
|
||||||
|
onDeleteNote={handleDeleteNote}
|
||||||
|
userProfile={userProfile}
|
||||||
|
onChildNoteAdded={(noteId, callback) => {
|
||||||
|
setChildNoteCallbacks((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[noteId]: callback
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
},
|
||||||
|
[loading, fetchData, expandedNotes, userProfile]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleNewNote = async () => {
|
||||||
|
setNewNoteFormLoading(true)
|
||||||
|
try {
|
||||||
|
await axios.post(
|
||||||
|
`${config.backendUrl}/notes`,
|
||||||
|
{ ...newNoteFormValues, parent: selectedParentId || _id },
|
||||||
|
{
|
||||||
|
withCredentials: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
setNewNoteOpen(false)
|
||||||
|
messageApi.success('Added a new note.')
|
||||||
|
|
||||||
|
// If this is a child note, expand the parent and reload child notes
|
||||||
|
if (selectedParentId) {
|
||||||
|
// Ensure parent is expanded
|
||||||
|
setExpandedNotes((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[selectedParentId]: true
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Add a small delay to ensure state update has taken effect
|
||||||
|
setTimeout(() => {
|
||||||
|
// Reload child notes for the parent
|
||||||
|
if (childNoteCallbacks[selectedParentId]) {
|
||||||
|
childNoteCallbacks[selectedParentId]()
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
} else {
|
||||||
|
// If it's a top-level note, reload all notes
|
||||||
|
setLoading(true)
|
||||||
|
handleReloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedParentId(null)
|
||||||
|
} catch (error) {
|
||||||
|
messageApi.error('Error creating new note: ' + error.message)
|
||||||
|
} finally {
|
||||||
|
setNewNoteFormLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNewNoteFromDropdown = (parentId) => {
|
||||||
|
setSelectedParentId(parentId)
|
||||||
|
setNewNoteOpen(true)
|
||||||
|
newNoteForm.resetFields()
|
||||||
|
setNewNoteFormValues({})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteNote = async (noteId) => {
|
||||||
|
setNoteToDelete(noteId)
|
||||||
|
setDeleteConfirmOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDeleteNote = async () => {
|
||||||
|
if (!noteToDelete) return
|
||||||
|
setDeleteNoteLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.delete(`${config.backendUrl}/notes/${noteToDelete}`, {
|
||||||
|
withCredentials: true
|
||||||
|
})
|
||||||
|
messageApi.success('Note deleted successfully.')
|
||||||
|
|
||||||
|
// Reload all top-level notes
|
||||||
|
setLoading(true)
|
||||||
|
handleReloadData()
|
||||||
|
|
||||||
|
// Reload child notes for all expanded parents to ensure UI stays in sync
|
||||||
|
const expandedNoteIds = Object.keys(expandedNotes).filter(
|
||||||
|
(id) => expandedNotes[id]
|
||||||
|
)
|
||||||
|
for (const parentId of expandedNoteIds) {
|
||||||
|
if (childNoteCallbacks[parentId]) {
|
||||||
|
childNoteCallbacks[parentId]()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
messageApi.error('Error deleting note: ' + error.message)
|
||||||
|
} finally {
|
||||||
|
setDeleteNoteLoading(false)
|
||||||
|
setDeleteConfirmOpen(false)
|
||||||
|
setNoteToDelete(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelDeleteNote = () => {
|
||||||
|
setDeleteConfirmOpen(false)
|
||||||
|
setNoteToDelete(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReloadData = useCallback(async () => {
|
||||||
|
setNotes(await generateNotes(_id))
|
||||||
|
}, [_id, generateNotes])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authenticated) {
|
||||||
|
handleReloadData()
|
||||||
|
}
|
||||||
|
}, [authenticated, handleReloadData])
|
||||||
|
|
||||||
const handleModalOk = async () => {
|
const handleModalOk = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields()
|
const values = await newNoteForm.validateFields()
|
||||||
onNewNote(values)
|
onNewNote(values)
|
||||||
form.resetFields()
|
newNoteForm.resetFields()
|
||||||
setIsModalOpen(false)
|
setNewNoteOpen(false)
|
||||||
setShowMarkdown(false)
|
setShowMarkdown(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Validation failed:', error)
|
console.error('Validation failed:', error)
|
||||||
@ -41,87 +474,113 @@ const DashboardNotes = ({ notes = [], onNewNote }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleModalCancel = () => {
|
const handleModalCancel = () => {
|
||||||
form.resetFields()
|
newNoteForm.resetFields()
|
||||||
setIsModalOpen(false)
|
setNewNoteOpen(false)
|
||||||
setShowMarkdown(false)
|
setShowMarkdown(false)
|
||||||
|
setSelectedParentId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionItems = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'New Note',
|
||||||
|
key: 'newNote',
|
||||||
|
icon: <PlusIcon />
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
label: 'Reload Notes',
|
||||||
|
key: 'reloadNotes',
|
||||||
|
icon: <ReloadIcon />
|
||||||
|
}
|
||||||
|
],
|
||||||
|
onClick: ({ key }) => {
|
||||||
|
if (key === 'reloadNotes') {
|
||||||
|
setLoading(true)
|
||||||
|
handleReloadData()
|
||||||
|
} else if (key === 'newNote') {
|
||||||
|
setSelectedParentId(null)
|
||||||
|
setNewNoteOpen(true)
|
||||||
|
newNoteForm.resetFields()
|
||||||
|
setNewNoteFormValues({})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space direction='vertical' size='large' style={{ width: '100%' }}>
|
<Flex vertical gap='large' style={{ width: '100%' }}>
|
||||||
|
{contextHolder}
|
||||||
<Flex justify='space-between'>
|
<Flex justify='space-between'>
|
||||||
<Space size={'small'}>
|
<Space size={'small'}>
|
||||||
<Button>Actions</Button>
|
<Dropdown menu={actionItems} disabled={loading}>
|
||||||
|
<Button disabled={loading}>Actions</Button>
|
||||||
|
</Dropdown>
|
||||||
</Space>
|
</Space>
|
||||||
<Space size={'small'}>
|
<Space size={'small'}>
|
||||||
<Button type='primary' icon={<PlusIcon />} onClick={handleNewNote} />
|
<Button
|
||||||
|
type='primary'
|
||||||
|
icon={<PlusIcon />}
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedParentId(null)
|
||||||
|
setNewNoteOpen(true)
|
||||||
|
newNoteForm.resetFields()
|
||||||
|
setNewNoteFormValues({})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<Space direction='vertical' size='middle' style={{ width: '100%' }}>
|
<Space direction='vertical' size='middle' style={{ width: '100%' }}>
|
||||||
{notes.map((note) => (
|
<Spin indicator={<LoadingOutlined />} spinning={loading}>
|
||||||
<Card key={note._id} size='small'>
|
{error ? (
|
||||||
<Space direction='vertical' style={{ width: '100%' }}>
|
<Alert message={error?.message} type='error' showIcon={true} />
|
||||||
<Flex justify='space-between' align='center'>
|
) : (
|
||||||
<Text type='secondary'>
|
<Flex vertical gap={'middle'}>
|
||||||
<TimeDisplay dateTime={note.createdAt} showSince={true} />
|
{notes}
|
||||||
</Text>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
<Text>{note.content}</Text>
|
)}
|
||||||
</Space>
|
</Spin>
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title='New Note'
|
open={newNoteOpen}
|
||||||
open={isModalOpen}
|
|
||||||
onOk={handleModalOk}
|
onOk={handleModalOk}
|
||||||
onCancel={handleModalCancel}
|
onCancel={handleModalCancel}
|
||||||
width={800}
|
width={800}
|
||||||
|
closeIcon={false}
|
||||||
|
destroyOnHidden={true}
|
||||||
|
footer={false}
|
||||||
>
|
>
|
||||||
|
<Flex vertical gap='large'>
|
||||||
|
<Flex vertical gap='middle'>
|
||||||
|
<Flex align='center' justify='space-between'>
|
||||||
|
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
|
||||||
|
New Note
|
||||||
|
</Title>
|
||||||
|
<Space gap={'small'}>
|
||||||
|
<Text type='secondary'>Markdown:</Text>
|
||||||
|
<Switch onChange={setShowMarkdown} size='small' />
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={newNoteForm}
|
||||||
layout='vertical'
|
layout='vertical'
|
||||||
initialValues={{
|
onFinish={handleNewNote}
|
||||||
type: 'general',
|
initialValues={{ content: '' }}
|
||||||
showMarkdown: false
|
onValuesChange={(changedValues) =>
|
||||||
}}
|
setNewNoteFormValues((prevValues) => ({
|
||||||
|
...prevValues,
|
||||||
|
...changedValues
|
||||||
|
}))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Form.Item
|
<Flex vertical gap={'large'}>
|
||||||
name='type'
|
<Flex gap='middle' wrap>
|
||||||
label='Note Type'
|
|
||||||
rules={[{ required: true, message: 'Please select a note type' }]}
|
|
||||||
>
|
|
||||||
<Select>
|
|
||||||
<Select.Option value='general'>General</Select.Option>
|
|
||||||
<Select.Option value='task'>Task</Select.Option>
|
|
||||||
<Select.Option value='idea'>Idea</Select.Option>
|
|
||||||
<Select.Option value='bug'>Bug</Select.Option>
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name='title'
|
|
||||||
label='Title'
|
|
||||||
rules={[{ required: true, message: 'Please enter a title' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder='Enter note title' />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name='showMarkdown' valuePropName='checked'>
|
|
||||||
<Switch
|
|
||||||
checkedChildren='Show Markdown'
|
|
||||||
unCheckedChildren='Hide Markdown'
|
|
||||||
onChange={setShowMarkdown}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Flex gap='middle'>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name='content'
|
name='content'
|
||||||
label='Content'
|
rules={[{ required: true, message: '' }]}
|
||||||
rules={[{ required: true, message: 'Please enter note content' }]}
|
style={{ margin: 0, flexGrow: 1, minWidth: '300px' }}
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
>
|
||||||
<TextArea
|
<TextArea
|
||||||
rows={6}
|
rows={6}
|
||||||
@ -131,47 +590,105 @@ const DashboardNotes = ({ notes = [], onNewNote }) => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{showMarkdown && (
|
{showMarkdown && (
|
||||||
<div style={{ flex: 1 }}>
|
<Card
|
||||||
<Text
|
|
||||||
type='secondary'
|
|
||||||
style={{ marginBottom: 8, display: 'block' }}
|
|
||||||
>
|
|
||||||
Preview
|
|
||||||
</Text>
|
|
||||||
<div
|
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid #d9d9d9',
|
flexGrow: 1,
|
||||||
borderRadius: '6px',
|
minWidth: '300px',
|
||||||
padding: '8px',
|
backgroundColor: () => {
|
||||||
minHeight: '150px',
|
if (newNoteFormValues?.noteType?.color) {
|
||||||
maxHeight: '300px',
|
return newNoteFormValues.noteType.color + '26'
|
||||||
overflow: 'auto'
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MarkdownDisplay
|
<MarkdownDisplay
|
||||||
content={form.getFieldValue('content') || ''}
|
content={newNoteForm.getFieldValue('content') || ''}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
<Form.Item
|
||||||
|
name='noteType'
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: 'Please select a note type' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<NoteTypeSelect />
|
||||||
|
</Form.Item>
|
||||||
|
</Flex>
|
||||||
</Form>
|
</Form>
|
||||||
|
</Flex>
|
||||||
|
<Flex justify='end'>
|
||||||
|
<Button
|
||||||
|
style={{ margin: '0 8px' }}
|
||||||
|
disabled={newNoteFormLoading}
|
||||||
|
onClick={() => {
|
||||||
|
setNewNoteOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='primary'
|
||||||
|
loading={newNoteFormLoading}
|
||||||
|
onClick={() => {
|
||||||
|
newNoteForm.submit()
|
||||||
|
}}
|
||||||
|
disabled={newNoteFormLoading || !doneEnabled}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={deleteConfirmOpen}
|
||||||
|
title={
|
||||||
|
<Space size={'middle'}>
|
||||||
|
<ExclamationOctagonIcon />
|
||||||
|
Confirm Delete
|
||||||
</Space>
|
</Space>
|
||||||
|
}
|
||||||
|
onOk={confirmDeleteNote}
|
||||||
|
onCancel={cancelDeleteNote}
|
||||||
|
okText='Delete'
|
||||||
|
cancelText='Cancel'
|
||||||
|
okType='danger'
|
||||||
|
closable={false}
|
||||||
|
centered
|
||||||
|
maskClosable={false}
|
||||||
|
footer={[
|
||||||
|
<Button
|
||||||
|
key='cancel'
|
||||||
|
onClick={cancelDeleteNote}
|
||||||
|
disabled={deleteNoteLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key='delete'
|
||||||
|
type='primary'
|
||||||
|
danger
|
||||||
|
onClick={confirmDeleteNote}
|
||||||
|
loading={deleteNoteLoading}
|
||||||
|
disabled={deleteNoteLoading}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text>Are you sure you want to delete this note?</Text>
|
||||||
|
</Modal>
|
||||||
|
</Flex>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
DashboardNotes.propTypes = {
|
DashboardNotes.propTypes = {
|
||||||
notes: PropTypes.arrayOf(
|
|
||||||
PropTypes.shape({
|
|
||||||
_id: PropTypes.string.isRequired,
|
_id: PropTypes.string.isRequired,
|
||||||
content: PropTypes.string.isRequired,
|
onNewNote: PropTypes.func
|
||||||
createdAt: PropTypes.string.isRequired,
|
|
||||||
type: PropTypes.string.isRequired,
|
|
||||||
user: PropTypes.object.isRequired
|
|
||||||
})
|
|
||||||
),
|
|
||||||
onNewNote: PropTypes.func.isRequired
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DashboardNotes
|
export default DashboardNotes
|
||||||
|
|||||||
@ -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
|
||||||
@ -6,7 +6,17 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
useCallback
|
useCallback
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { Table, message, Skeleton } from 'antd'
|
import {
|
||||||
|
Table,
|
||||||
|
message,
|
||||||
|
Skeleton,
|
||||||
|
Card,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Descriptions,
|
||||||
|
Flex,
|
||||||
|
Spin
|
||||||
|
} from 'antd'
|
||||||
import { LoadingOutlined } from '@ant-design/icons'
|
import { LoadingOutlined } from '@ant-design/icons'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
@ -18,15 +28,22 @@ const DashboardTable = forwardRef(
|
|||||||
columns,
|
columns,
|
||||||
url,
|
url,
|
||||||
pageSize = 25,
|
pageSize = 25,
|
||||||
scrollHeight = 'calc(100vh - 270px)',
|
scrollHeight = 'calc(var(--unit-100vh) - 270px)',
|
||||||
onDataChange,
|
onDataChange,
|
||||||
authenticated,
|
authenticated,
|
||||||
initialPage = 1
|
initialPage = 1,
|
||||||
|
cards = false
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
const adjustedScrollHeight = isMobile ? 'calc(100vh - 316px)' : scrollHeight
|
var adjustedScrollHeight = scrollHeight
|
||||||
|
if (isMobile) {
|
||||||
|
adjustedScrollHeight = 'calc(var(--unit-100vh) - 316px)'
|
||||||
|
}
|
||||||
|
if (cards) {
|
||||||
|
adjustedScrollHeight = 'calc(var(--unit-100vh) - 210px)'
|
||||||
|
}
|
||||||
const [, contextHolder] = message.useMessage()
|
const [, contextHolder] = message.useMessage()
|
||||||
const tableRef = useRef(null)
|
const tableRef = useRef(null)
|
||||||
const [filters, setFilters] = useState({})
|
const [filters, setFilters] = useState({})
|
||||||
@ -96,6 +113,7 @@ const DashboardTable = forwardRef(
|
|||||||
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setLazyLoading(false)
|
setLazyLoading(false)
|
||||||
|
return newData
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPages((prev) =>
|
setPages((prev) =>
|
||||||
prev.map((page) => ({
|
prev.map((page) => ({
|
||||||
@ -223,15 +241,19 @@ const DashboardTable = forwardRef(
|
|||||||
[fetchData, totalPages]
|
[fetchData, totalPages]
|
||||||
)
|
)
|
||||||
|
|
||||||
const loadInitialPage = useCallback(() => {
|
const loadInitialPage = useCallback(async () => {
|
||||||
// Create initial page with skeletons
|
// Create initial page with skeletons
|
||||||
setPages([
|
setPages([{ pageNum: initialPage, items: createSkeletonData() }])
|
||||||
{ pageNum: initialPage, items: createSkeletonData() },
|
|
||||||
|
const items = await fetchData(initialPage)
|
||||||
|
|
||||||
|
if (items.length >= 25) {
|
||||||
|
setPages((prev) => [
|
||||||
|
...prev,
|
||||||
{ pageNum: initialPage + 1, items: createSkeletonData() }
|
{ pageNum: initialPage + 1, items: createSkeletonData() }
|
||||||
])
|
])
|
||||||
|
await fetchData(initialPage + 1)
|
||||||
// Fetch both pages
|
}
|
||||||
return Promise.all([fetchData(initialPage), fetchData(initialPage + 1)])
|
|
||||||
}, [initialPage, createSkeletonData, fetchData])
|
}, [initialPage, createSkeletonData, fetchData])
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
@ -281,9 +303,91 @@ const DashboardTable = forwardRef(
|
|||||||
// Flatten pages array for table display
|
// Flatten pages array for table display
|
||||||
const tableData = pages.flatMap((page) => page.items)
|
const tableData = pages.flatMap((page) => page.items)
|
||||||
|
|
||||||
|
// Card view rendering
|
||||||
|
const renderCards = () => {
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
gutter={[16, 16]}
|
||||||
|
style={{ overflowY: 'auto', maxHeight: adjustedScrollHeight }}
|
||||||
|
>
|
||||||
|
{tableData.map((record) => {
|
||||||
|
// Special case for columns[0] if needed
|
||||||
|
let icon = null
|
||||||
|
if (columns[0].key === 'icon' && columns[0].render) {
|
||||||
|
const renderedIcon = columns[0].render()
|
||||||
|
icon = React.cloneElement(renderedIcon, {
|
||||||
|
style: {
|
||||||
|
fontSize: 32,
|
||||||
|
...(renderedIcon.props.style || {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let actions = null
|
||||||
|
const endColumn = columns.length - 1
|
||||||
|
if (
|
||||||
|
columns[endColumn].key === 'actions' &&
|
||||||
|
columns[endColumn].render
|
||||||
|
) {
|
||||||
|
actions = columns[endColumn].render(record)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Col
|
||||||
|
xs={24}
|
||||||
|
sm={12}
|
||||||
|
md={12}
|
||||||
|
lg={8}
|
||||||
|
xl={6}
|
||||||
|
xxl={6}
|
||||||
|
key={record._id}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
style={{ width: '100%', overflow: 'hidden' }}
|
||||||
|
loading={record.isSkeleton}
|
||||||
|
>
|
||||||
|
<Flex align={'center'} vertical gap={'middle'}>
|
||||||
|
{icon}
|
||||||
|
<Descriptions column={1} size='small' bordered={false}>
|
||||||
|
{columns
|
||||||
|
.filter(
|
||||||
|
(col) => col.key !== 'icon' && col.key !== 'actions'
|
||||||
|
)
|
||||||
|
.map((col) => {
|
||||||
|
let value
|
||||||
|
if (col.render && col.dataIndex) {
|
||||||
|
value = col.render(record[col.dataIndex], record)
|
||||||
|
} else if (col.render && !col.dataIndex) {
|
||||||
|
value = col.render(record)
|
||||||
|
} else {
|
||||||
|
value = String(record[col.dataIndex] ?? '')
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Descriptions.Item
|
||||||
|
label={col.title}
|
||||||
|
key={col.key || col.dataIndex}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Descriptions>
|
||||||
|
{actions}
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
|
{cards ? (
|
||||||
|
<Spin indicator={<LoadingOutlined />} spinning={loading}>
|
||||||
|
{renderCards()}
|
||||||
|
</Spin>
|
||||||
|
) : (
|
||||||
<Table
|
<Table
|
||||||
ref={tableRef}
|
ref={tableRef}
|
||||||
dataSource={tableData}
|
dataSource={tableData}
|
||||||
@ -298,6 +402,7 @@ const DashboardTable = forwardRef(
|
|||||||
showSorterTooltip={false}
|
showSorterTooltip={false}
|
||||||
style={{ height: '100%' }}
|
style={{ height: '100%' }}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -312,7 +417,9 @@ DashboardTable.propTypes = {
|
|||||||
scrollHeight: PropTypes.string,
|
scrollHeight: PropTypes.string,
|
||||||
onDataChange: PropTypes.func,
|
onDataChange: PropTypes.func,
|
||||||
authenticated: PropTypes.bool.isRequired,
|
authenticated: PropTypes.bool.isRequired,
|
||||||
initialPage: PropTypes.number
|
initialPage: PropTypes.number,
|
||||||
|
cards: PropTypes.bool,
|
||||||
|
cardRenderer: PropTypes.func
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DashboardTable
|
export default DashboardTable
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 }}>
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +1,20 @@
|
|||||||
// PrinterSelect.js
|
// PrinterSelect.js
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { Flex, Typography, Button, Tooltip, message, Space } from 'antd'
|
import {
|
||||||
|
Flex,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Tooltip,
|
||||||
|
message,
|
||||||
|
Space,
|
||||||
|
Popover
|
||||||
|
} from 'antd'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
import CopyIcon from '../../Icons/CopyIcon'
|
import CopyIcon from '../../Icons/CopyIcon'
|
||||||
import PrinterIcon from '../../Icons/PrinterIcon'
|
import SpotlightTooltip from './SpotlightTooltip'
|
||||||
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
|
import { getTypeMeta } from '../utils/Utils'
|
||||||
import FilamentIcon from '../../Icons/FilamentIcon'
|
|
||||||
import FilamentStockIcon from '../../Icons/FilamentStockIcon'
|
|
||||||
import GCodeFileIcon from '../../Icons/GCodeFileIcon'
|
|
||||||
import JobIcon from '../../Icons/JobIcon'
|
|
||||||
import PartIcon from '../../Icons/PartIcon'
|
|
||||||
import ProductIcon from '../../Icons/ProductIcon'
|
|
||||||
import VendorIcon from '../../Icons/VendorIcon'
|
|
||||||
import SubJobIcon from '../../Icons/SubJobIcon'
|
|
||||||
import StockEventIcon from '../../Icons/StockEventIcon'
|
|
||||||
import StockAuditIcon from '../../Icons/StockAuditIcon'
|
|
||||||
import PartStockIcon from '../../Icons/PartStockIcon'
|
|
||||||
import ProductStockIcon from '../../Icons/ProductStockIcon'
|
|
||||||
import AuditLogIcon from '../../Icons/AuditLogIcon'
|
|
||||||
import PersonIcon from '../../Icons/PersonIcon'
|
|
||||||
import NoteTypeIcon from '../../Icons/NoteTypeIcon'
|
|
||||||
|
|
||||||
const { Text, Link } = Typography
|
const { Text, Link } = Typography
|
||||||
|
|
||||||
@ -30,107 +23,18 @@ const IdText = ({
|
|||||||
type,
|
type,
|
||||||
showCopy = true,
|
showCopy = true,
|
||||||
longId = true,
|
longId = true,
|
||||||
showHyperlink = false
|
showHyperlink = false,
|
||||||
|
showSpotlight = true
|
||||||
}) => {
|
}) => {
|
||||||
const [messageApi, contextHolder] = message.useMessage()
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
|
|
||||||
var prefix = 'UNK'
|
const meta = getTypeMeta(type)
|
||||||
var hyperlink = '#'
|
const prefix = meta.prefix
|
||||||
var icon = <QuestionCircleIcon style={{ paddingTop: '4px' }} />
|
const hyperlink = meta.url(id)
|
||||||
|
const IconComponent = meta.icon
|
||||||
switch (type) {
|
const icon = <IconComponent style={{ paddingTop: '4px' }} />
|
||||||
case 'printer':
|
|
||||||
prefix = 'PRN'
|
|
||||||
hyperlink = `/dashboard/production/printers/info?printerId=${id}`
|
|
||||||
icon = <PrinterIcon style={{ paddingTop: '4px' }} />
|
|
||||||
break
|
|
||||||
case 'filament':
|
|
||||||
prefix = 'FIL'
|
|
||||||
hyperlink = `/dashboard/management/filaments/info?filamentId=${id}`
|
|
||||||
icon = <FilamentIcon style={{ paddingTop: '4px' }} />
|
|
||||||
break
|
|
||||||
case 'spool':
|
|
||||||
prefix = 'SPL'
|
|
||||||
hyperlink = `/dashboard/inventory/spool/info?spoolId=${id}`
|
|
||||||
icon = <FilamentIcon style={{ paddingTop: '4px' }} />
|
|
||||||
break
|
|
||||||
case 'gcodefile':
|
|
||||||
prefix = 'GCF'
|
|
||||||
hyperlink = `/dashboard/production/gcodefiles/info?gcodeFileId=${id}`
|
|
||||||
icon = <GCodeFileIcon style={{ paddingTop: '4px' }} />
|
|
||||||
break
|
|
||||||
case 'job':
|
|
||||||
prefix = 'JOB'
|
|
||||||
hyperlink = `/dashboard/production/jobs/info?jobId=${id}`
|
|
||||||
icon = <JobIcon style={{ paddingTop: '4px' }} />
|
|
||||||
break
|
|
||||||
case 'part':
|
|
||||||
prefix = 'PRT'
|
|
||||||
hyperlink = `/dashboard/management/parts/info?partId=${id}`
|
|
||||||
icon = <PartIcon style={{ paddingTop: '4px' }} />
|
|
||||||
break
|
|
||||||
case 'product':
|
|
||||||
prefix = 'PRD'
|
|
||||||
hyperlink = `/dashboard/management/products/info?productId=${id}`
|
|
||||||
icon = <ProductIcon style={{ paddingTop: '4px' }} />
|
|
||||||
break
|
|
||||||
case 'vendor':
|
|
||||||
prefix = 'VEN'
|
|
||||||
hyperlink = `/dashboard/management/vendors/info?vendorId=${id}`
|
|
||||||
icon = <VendorIcon style={{ paddingTop: '4px' }} />
|
|
||||||
break
|
|
||||||
case 'subjob':
|
|
||||||
prefix = 'SJB'
|
|
||||||
hyperlink = `#`
|
|
||||||
icon = <SubJobIcon style={{ paddingTop: '4px' }} />
|
|
||||||
break
|
|
||||||
case 'filamentstock':
|
|
||||||
prefix = 'FLS'
|
|
||||||
hyperlink = `/dashboard/inventory/filamentstocks/info?filamentStockId=${id}`
|
|
||||||
icon = <FilamentStockIcon style={{ paddingTop: '4px' }} />
|
|
||||||
break
|
|
||||||
case 'stockevent':
|
|
||||||
prefix = 'SEV'
|
|
||||||
hyperlink = `#`
|
|
||||||
icon = <StockEventIcon style={{ paddingTop: '4px' }} />
|
|
||||||
break
|
|
||||||
case 'stockaudit':
|
|
||||||
prefix = 'SAU'
|
|
||||||
hyperlink = `/dashboard/inventory/stockaudits/info?stockAuditId=${id}`
|
|
||||||
icon = <StockAuditIcon style={{ paddingTop: '4px' }} />
|
|
||||||
break
|
|
||||||
case 'partstock':
|
|
||||||
prefix = 'PTS'
|
|
||||||
hyperlink = `/dashboard/management/partstocks/info?partStockId=${id}`
|
|
||||||
icon = <PartStockIcon style={{ paddingTop: '4px' }} />
|
|
||||||
break
|
|
||||||
case 'productstock':
|
|
||||||
prefix = 'PDS'
|
|
||||||
hyperlink = `/dashboard/management/productstocks/info?productStockId=${id}`
|
|
||||||
icon = <ProductStockIcon style={{ paddingTop: '4px' }} />
|
|
||||||
break
|
|
||||||
case 'auditlog':
|
|
||||||
prefix = 'ADL'
|
|
||||||
hyperlink = `#`
|
|
||||||
icon = <AuditLogIcon style={{ paddingTop: '4px' }} />
|
|
||||||
break
|
|
||||||
case 'user':
|
|
||||||
prefix = 'USR'
|
|
||||||
hyperlink = `#`
|
|
||||||
icon = <PersonIcon style={{ paddingTop: '4px' }} />
|
|
||||||
break
|
|
||||||
case 'notetype':
|
|
||||||
prefix = 'NTY'
|
|
||||||
hyperlink = `/dashboard/management/notetypes/info?noteTypeId=${id}`
|
|
||||||
icon = <NoteTypeIcon style={{ paddingTop: '4px' }} />
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
hyperlink = `#`
|
|
||||||
prefix = 'UNK'
|
|
||||||
icon = <QuestionCircleIcon style={{ paddingTop: '4px' }} />
|
|
||||||
}
|
|
||||||
|
|
||||||
id = typeof id === 'string' ? id.toString().toUpperCase() : 'XXXXXX'
|
id = typeof id === 'string' ? id.toString().toUpperCase() : 'XXXXXX'
|
||||||
var displayId = prefix + ':' + id
|
var displayId = prefix + ':' + id
|
||||||
@ -141,10 +45,22 @@ const IdText = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex align={'center'} gap={'small'}>
|
<Flex align={'center'} gap={'small'} className='idtext'>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
|
|
||||||
{showHyperlink && (
|
{showHyperlink &&
|
||||||
|
(showSpotlight ? (
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
id && type ? (
|
||||||
|
<SpotlightTooltip query={prefix + ':' + id} type={type} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
trigger={['hover', 'click']}
|
||||||
|
placement='topLeft'
|
||||||
|
arrow={false}
|
||||||
|
style={{ padding: 0 }}
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (showHyperlink) {
|
if (showHyperlink) {
|
||||||
@ -159,16 +75,51 @@ const IdText = ({
|
|||||||
</Space>
|
</Space>
|
||||||
</Text>
|
</Text>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
</Popover>
|
||||||
|
) : (
|
||||||
{!showHyperlink && (
|
<Link
|
||||||
|
onClick={() => {
|
||||||
|
if (showHyperlink) {
|
||||||
|
navigate(hyperlink)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Text code ellipsis>
|
<Text code ellipsis>
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
{icon}
|
{icon}
|
||||||
{displayId}
|
{displayId}
|
||||||
</Space>
|
</Space>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!showHyperlink &&
|
||||||
|
(showSpotlight ? (
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
id && type ? (
|
||||||
|
<SpotlightTooltip query={prefix + ':' + id} type={type} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
trigger={['hover', 'click']}
|
||||||
|
placement='topLeft'
|
||||||
|
arrow={false}
|
||||||
|
>
|
||||||
|
<Text code ellipsis>
|
||||||
|
<Space size={4}>
|
||||||
|
{icon}
|
||||||
|
{displayId}
|
||||||
|
</Space>
|
||||||
|
</Text>
|
||||||
|
</Popover>
|
||||||
|
) : (
|
||||||
|
<Text code ellipsis>
|
||||||
|
<Space size={4}>
|
||||||
|
{icon}
|
||||||
|
{displayId}
|
||||||
|
</Space>
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
{showCopy && (
|
{showCopy && (
|
||||||
<Tooltip title='Copy ID' arrow={false}>
|
<Tooltip title='Copy ID' arrow={false}>
|
||||||
<Button
|
<Button
|
||||||
@ -176,14 +127,44 @@ const IdText = ({
|
|||||||
type='text'
|
type='text'
|
||||||
style={{ height: '22px' }}
|
style={{ height: '22px' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
const doCopy = (text) => {
|
||||||
|
if (
|
||||||
|
navigator &&
|
||||||
|
navigator.clipboard &&
|
||||||
|
navigator.clipboard.writeText
|
||||||
|
) {
|
||||||
navigator.clipboard
|
navigator.clipboard
|
||||||
.writeText(copyId)
|
.writeText(text)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
messageApi.success('ID copied to clipboard')
|
messageApi.success('ID copied to clipboard')
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
messageApi.error('Failed to copy ID')
|
messageApi.error('Failed to copy ID')
|
||||||
})
|
})
|
||||||
|
} else if (
|
||||||
|
document.queryCommandSupported &&
|
||||||
|
document.queryCommandSupported('copy')
|
||||||
|
) {
|
||||||
|
// Legacy fallback
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = text
|
||||||
|
textarea.setAttribute('readonly', '')
|
||||||
|
textarea.style.position = 'absolute'
|
||||||
|
textarea.style.left = '-9999px'
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
try {
|
||||||
|
document.execCommand('copy')
|
||||||
|
messageApi.success('ID copied to clipboard')
|
||||||
|
} catch (err) {
|
||||||
|
messageApi.error('Failed to copy ID')
|
||||||
|
}
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
} else {
|
||||||
|
messageApi.error('Copy not supported in this browser')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doCopy(copyId)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@ -197,7 +178,8 @@ IdText.propTypes = {
|
|||||||
type: PropTypes.string,
|
type: PropTypes.string,
|
||||||
showCopy: PropTypes.bool,
|
showCopy: PropTypes.bool,
|
||||||
longId: PropTypes.bool,
|
longId: PropTypes.bool,
|
||||||
showHyperlink: PropTypes.bool
|
showHyperlink: PropTypes.bool,
|
||||||
|
showSpotlight: PropTypes.bool
|
||||||
}
|
}
|
||||||
|
|
||||||
export default IdText
|
export default IdText
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
92
src/components/Dashboard/common/NoteTypeSelect.jsx
Normal 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
|
||||||
114
src/components/Dashboard/common/Notification.jsx
Normal 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
|
||||||
@ -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
|
||||||
476
src/components/Dashboard/common/ObjectSelect.jsx
Normal 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
|
||||||
@ -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'
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,136 +1,36 @@
|
|||||||
// PrinterSelect.js
|
// PrinterSelect.js
|
||||||
|
import React from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { TreeSelect, message, Tag } from 'antd'
|
import { Tag } from 'antd'
|
||||||
import React, { useEffect, useState, useContext, useCallback } from 'react'
|
|
||||||
import axios from 'axios'
|
|
||||||
import PrinterState from './PrinterState'
|
|
||||||
import { AuthContext } from '../context/AuthContext'
|
|
||||||
import config from '../../../config'
|
import config from '../../../config'
|
||||||
|
import ObjectSelect from './ObjectSelect'
|
||||||
|
import PrinterState from './PrinterState'
|
||||||
|
|
||||||
const PrinterSelect = ({ onChange, disabled, checkable, value = [] }) => {
|
const PrinterSelect = ({ onChange, disabled }) => {
|
||||||
const [printersTreeData, setPrintersTreeData] = useState([])
|
// getTitle: if isLeaf, render PrinterState, else render Tag or 'Untagged'
|
||||||
const [printersData, setPrintersData] = useState([])
|
const getTitle = (item, isLeaf) =>
|
||||||
const [loading, setLoading] = useState(true)
|
isLeaf ? (
|
||||||
const [messageApi] = message.useMessage()
|
<PrinterState printer={item} showProgress={false} showControls={false} />
|
||||||
const [defaultValue, setDefaultValue] = useState(value)
|
) : item === 'Untagged' ? (
|
||||||
|
'Untagged'
|
||||||
const { authenticated } = useContext(AuthContext)
|
) : (
|
||||||
|
<Tag color='blue'>{item}</Tag>
|
||||||
const fetchPrintersTreeData = useCallback(async () => {
|
|
||||||
if (!authenticated) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`${config.backendUrl}/printers`, {
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json'
|
|
||||||
},
|
|
||||||
withCredentials: true // Important for including cookies
|
|
||||||
})
|
|
||||||
setLoading(false)
|
|
||||||
return response.data
|
|
||||||
} catch (error) {
|
|
||||||
if (error.response) {
|
|
||||||
// For other errors, show a message
|
|
||||||
messageApi.error('Error fetching printers data:', error.response.status)
|
|
||||||
} else {
|
|
||||||
messageApi.error(
|
|
||||||
'An unexpected error occurred. Please try again later.'
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [authenticated, messageApi])
|
|
||||||
|
|
||||||
const generatePrinterItems = useCallback(async () => {
|
// getValue/getKey: for leaf, use _id; for tag, use tag string
|
||||||
const printerData = await fetchPrintersTreeData()
|
const getValue = (item, isLeaf) => (isLeaf ? item._id : item)
|
||||||
setPrintersData(printerData)
|
const getKey = (item, isLeaf) => (isLeaf ? item._id : item)
|
||||||
|
|
||||||
// Create a map to store tags and their printers
|
|
||||||
const tagMap = new Map()
|
|
||||||
|
|
||||||
// Add printers to their respective tag groups
|
|
||||||
printerData.forEach((printer) => {
|
|
||||||
if (printer.tags && printer.tags.length > 0) {
|
|
||||||
printer.tags.forEach((tag) => {
|
|
||||||
if (!tagMap.has(tag)) {
|
|
||||||
tagMap.set(tag, [])
|
|
||||||
}
|
|
||||||
tagMap.get(tag).push(printer)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// If no tags, add to "Untagged" group
|
|
||||||
if (!tagMap.has('Untagged')) {
|
|
||||||
tagMap.set('Untagged', [])
|
|
||||||
}
|
|
||||||
tagMap.get('Untagged').push(printer)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Convert the map to tree data structure
|
|
||||||
Array.from(tagMap.entries()).map(([tag, printers]) => {
|
|
||||||
const newNode = {
|
|
||||||
title: tag === 'Untagged' ? tag : <Tag color='blue'>{tag}</Tag>,
|
|
||||||
value: `tag-${tag}`,
|
|
||||||
key: `tag-${tag}`,
|
|
||||||
children: printers.map((printer) => ({
|
|
||||||
title: (
|
|
||||||
<PrinterState
|
|
||||||
printer={printer}
|
|
||||||
showProgress={false}
|
|
||||||
showControls={false}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
value: printer._id,
|
|
||||||
key: printer._id
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
setPrintersTreeData((prev) => {
|
|
||||||
const filtered = prev.filter((node) => node.key !== newNode.key)
|
|
||||||
return [...filtered, newNode]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}, [fetchPrintersTreeData])
|
|
||||||
|
|
||||||
const handleOnChange = (value, selectedOptions) => {
|
|
||||||
if (checkable) {
|
|
||||||
// Multiple selection mode
|
|
||||||
const newValue = printersData.filter((printer) =>
|
|
||||||
value.includes(printer._id)
|
|
||||||
)
|
|
||||||
setDefaultValue(newValue)
|
|
||||||
onChange(newValue, selectedOptions)
|
|
||||||
} else {
|
|
||||||
// Single selection mode
|
|
||||||
const selectedPrinter = printersData.find(
|
|
||||||
(printer) => printer._id === value
|
|
||||||
)
|
|
||||||
setDefaultValue(selectedPrinter ? [selectedPrinter] : [])
|
|
||||||
onChange(selectedPrinter, selectedOptions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (authenticated) {
|
|
||||||
generatePrinterItems()
|
|
||||||
}
|
|
||||||
}, [authenticated, generatePrinterItems])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TreeSelect
|
<ObjectSelect
|
||||||
treeData={printersTreeData}
|
endpoint={`${config.backendUrl}/printers`}
|
||||||
onChange={handleOnChange}
|
propertyOrder={['tags']}
|
||||||
loading={loading}
|
getTitle={getTitle}
|
||||||
|
getValue={getValue}
|
||||||
|
getKey={getKey}
|
||||||
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
treeDefaultExpandAll
|
|
||||||
treeCheckable={checkable}
|
|
||||||
treeNodeFilterProp='title'
|
|
||||||
placeholder='Select Printer'
|
placeholder='Select Printer'
|
||||||
style={{ width: '100%' }}
|
|
||||||
value={
|
|
||||||
checkable ? defaultValue.map((item) => item._id) : defaultValue?._id
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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' }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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
|
|
||||||
256
src/components/Dashboard/common/SpotlightTooltip.jsx
Normal 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
|
||||||
@ -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 = [
|
||||||
|
|||||||
@ -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' }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
0
src/components/Dashboard/common/TypeDisplay.jsx
Normal 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
212
src/components/Dashboard/context/NotificationContext.js
Normal 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 }
|
||||||
@ -1,26 +1,40 @@
|
|||||||
import { Input, Flex, List, Typography, Modal, Spin, message, Form } from 'antd'
|
import {
|
||||||
|
Input,
|
||||||
|
Flex,
|
||||||
|
List,
|
||||||
|
Typography,
|
||||||
|
Modal,
|
||||||
|
Spin,
|
||||||
|
message,
|
||||||
|
Form,
|
||||||
|
Button
|
||||||
|
} from 'antd'
|
||||||
import React, { createContext, useEffect, useState, useRef } from 'react'
|
import React, { createContext, useEffect, useState, useRef } from 'react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { LoadingOutlined } from '@ant-design/icons'
|
import { LoadingOutlined } from '@ant-design/icons'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import PrinterState from '../common/PrinterState'
|
import PrinterState from '../common/PrinterState'
|
||||||
import JobState from '../common/JobState'
|
import JobState from '../common/JobState'
|
||||||
import IdText from '../common/IdText'
|
import IdText from '../common/IdText'
|
||||||
|
|
||||||
import config from '../../../config'
|
import config from '../../../config'
|
||||||
import JobIcon from '../../Icons/JobIcon'
|
import { getTypeMeta, getPrefixMeta } from '../utils/Utils'
|
||||||
import PrinterIcon from '../../Icons/PrinterIcon'
|
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||||
|
import FilamentStockState from '../common/FilamentStockState'
|
||||||
|
import SubJobState from '../common/SubJobState'
|
||||||
|
|
||||||
const SpotlightContext = createContext()
|
const SpotlightContext = createContext()
|
||||||
|
|
||||||
const SpotlightProvider = ({ children }) => {
|
const SpotlightProvider = ({ children }) => {
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
const navigate = useNavigate()
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [listData, setListData] = useState([])
|
const [listData, setListData] = useState([])
|
||||||
const [messageApi, contextHolder] = message.useMessage()
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
const [inputPrefix, setInputPrefix] = useState('')
|
const [inputPrefix, setInputPrefix] = useState({ prefix: '', mode: null })
|
||||||
|
|
||||||
// Refs for throttling/debouncing
|
// Refs for throttling/debouncing
|
||||||
const lastFetchTime = useRef(0)
|
const lastFetchTime = useRef(0)
|
||||||
@ -35,15 +49,55 @@ const SpotlightProvider = ({ children }) => {
|
|||||||
|
|
||||||
// Set prefix based on default query if provided
|
// Set prefix based on default query if provided
|
||||||
if (defaultQuery) {
|
if (defaultQuery) {
|
||||||
detectAndSetPrefix(defaultQuery)
|
// Check if the default query contains a prefix
|
||||||
|
const upperQuery = defaultQuery.toUpperCase()
|
||||||
|
const prefixInfo = parsePrefix(upperQuery)
|
||||||
|
|
||||||
|
if (prefixInfo) {
|
||||||
|
setInputPrefix(prefixInfo)
|
||||||
|
// Set the query to only the part after the prefix and mode character
|
||||||
|
const remainingValue = defaultQuery.substring(
|
||||||
|
prefixInfo.prefix.length + 1
|
||||||
|
)
|
||||||
|
setQuery(remainingValue)
|
||||||
checkAndFetchData(defaultQuery)
|
checkAndFetchData(defaultQuery)
|
||||||
} else {
|
} else {
|
||||||
setInputPrefix('')
|
setInputPrefix(null)
|
||||||
|
checkAndFetchData(defaultQuery)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setInputPrefix(null)
|
||||||
|
// Only clear data if we're opening with an empty query and no existing data
|
||||||
|
if (listData.length === 0) {
|
||||||
|
setListData([])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Focus will be handled in useEffect for proper timing after modal renders
|
// Focus will be handled in useEffect for proper timing after modal renders
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to parse prefix and mode from query
|
||||||
|
const parsePrefix = (query) => {
|
||||||
|
// Check for prefix format: XXX: or XXX? or XXX^
|
||||||
|
if (query.length >= 4) {
|
||||||
|
const potentialPrefix = query.substring(0, 3)
|
||||||
|
const modeChar = query[3]
|
||||||
|
|
||||||
|
// Check if it's a valid mode character
|
||||||
|
if ([':', '?', '^'].includes(modeChar)) {
|
||||||
|
const prefixMeta = getPrefixMeta(potentialPrefix)
|
||||||
|
|
||||||
|
if (prefixMeta.prefix === potentialPrefix) {
|
||||||
|
return {
|
||||||
|
...prefixMeta,
|
||||||
|
mode: modeChar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const fetchData = async (searchQuery) => {
|
const fetchData = async (searchQuery) => {
|
||||||
if (!searchQuery || !searchQuery.trim()) return
|
if (!searchQuery || !searchQuery.trim()) return
|
||||||
|
|
||||||
@ -55,7 +109,29 @@ const SpotlightProvider = ({ children }) => {
|
|||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setListData([])
|
setListData([])
|
||||||
const response = await axios.get(
|
|
||||||
|
let response
|
||||||
|
|
||||||
|
// Check if we have a prefix with ? mode (filter mode)
|
||||||
|
if (inputPrefix && inputPrefix.mode === '?') {
|
||||||
|
// For filter mode, parse the searchQuery which is in format like "VEN?name=Tom"
|
||||||
|
const queryParts = searchQuery.split('?')
|
||||||
|
const prefix = queryParts[0]
|
||||||
|
const queryParams = queryParts[1] || ''
|
||||||
|
|
||||||
|
// Parse the query parameters
|
||||||
|
const params = new URLSearchParams(queryParams)
|
||||||
|
|
||||||
|
response = await axios.get(`${config.backendUrl}/spotlight/${prefix}`, {
|
||||||
|
params: params,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
withCredentials: true
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// For other modes (:, ^), use the original behavior
|
||||||
|
response = await axios.get(
|
||||||
`${config.backendUrl}/spotlight/${encodeURIComponent(searchQuery.trim())}`,
|
`${config.backendUrl}/spotlight/${encodeURIComponent(searchQuery.trim())}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
@ -64,8 +140,20 @@ const SpotlightProvider = ({ children }) => {
|
|||||||
withCredentials: true
|
withCredentials: true
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
// If the query contains a prefix mode character, and the response is an object, wrap it in an array
|
||||||
|
if (
|
||||||
|
/[:?^]/.test(searchQuery) &&
|
||||||
|
response.data &&
|
||||||
|
!Array.isArray(response.data) &&
|
||||||
|
typeof response.data === 'object'
|
||||||
|
) {
|
||||||
|
setListData([response.data])
|
||||||
|
} else {
|
||||||
setListData(response.data)
|
setListData(response.data)
|
||||||
|
}
|
||||||
|
|
||||||
// Check if there's a pending query after this fetch completes
|
// Check if there's a pending query after this fetch completes
|
||||||
if (pendingQuery.current !== null) {
|
if (pendingQuery.current !== null) {
|
||||||
@ -121,44 +209,18 @@ const SpotlightProvider = ({ children }) => {
|
|||||||
}, delay)
|
}, delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect and set the appropriate prefix based on input
|
|
||||||
const detectAndSetPrefix = (text) => {
|
|
||||||
if (!text || text.trim() === '') {
|
|
||||||
setInputPrefix('')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Detecting prefix')
|
|
||||||
const upperText = text.toUpperCase()
|
|
||||||
|
|
||||||
if (upperText.startsWith('JOB:')) {
|
|
||||||
setInputPrefix('JOB:')
|
|
||||||
return true
|
|
||||||
} else if (upperText.startsWith('PRN:')) {
|
|
||||||
setInputPrefix('PRN:')
|
|
||||||
return true
|
|
||||||
} else if (upperText.startsWith('FIL:')) {
|
|
||||||
setInputPrefix('FIL')
|
|
||||||
return true
|
|
||||||
} else if (upperText.startsWith('GCF:')) {
|
|
||||||
setInputPrefix('GCF:')
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default behavior if no match
|
|
||||||
setInputPrefix('')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSpotlightChange = (formData) => {
|
const handleSpotlightChange = (formData) => {
|
||||||
const newQuery = formData.query || ''
|
const newQuery = formData.query || ''
|
||||||
setQuery(newQuery)
|
setQuery(newQuery)
|
||||||
|
|
||||||
// Detect and set the appropriate prefix
|
// Build the full search query with prefix if available
|
||||||
detectAndSetPrefix(inputPrefix + newQuery)
|
let fullQuery = newQuery
|
||||||
|
if (inputPrefix) {
|
||||||
|
fullQuery = inputPrefix.prefix + inputPrefix.mode + newQuery
|
||||||
|
}
|
||||||
|
|
||||||
// Check if we need to fetch data
|
// Check if we need to fetch data
|
||||||
checkAndFetchData(inputPrefix + newQuery)
|
checkAndFetchData(fullQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Focus the input element
|
// Focus the input element
|
||||||
@ -181,46 +243,105 @@ const SpotlightProvider = ({ children }) => {
|
|||||||
if (!value || value.trim() === '') {
|
if (!value || value.trim() === '') {
|
||||||
// Only clear the prefix if the input is completely empty
|
// Only clear the prefix if the input is completely empty
|
||||||
if (value === '') {
|
if (value === '') {
|
||||||
console.log('Clearning prefix')
|
console.log('Clearing prefix')
|
||||||
setInputPrefix('')
|
setInputPrefix(null)
|
||||||
}
|
}
|
||||||
if (formRef.current) {
|
if (formRef.current) {
|
||||||
formRef.current.setFieldsValue({ query: value })
|
formRef.current.setFieldsValue({ query: value })
|
||||||
}
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
// If the user is typing and it doesn't have a prefix yet
|
|
||||||
else if (!inputPrefix) {
|
// Check if the input contains a prefix (format: XXX:, XXX?, or XXX^)
|
||||||
console.log('No prefix')
|
|
||||||
// Check for prefixes at the beginning of the input
|
|
||||||
const upperValue = value.toUpperCase()
|
const upperValue = value.toUpperCase()
|
||||||
|
const prefixInfo = parsePrefix(upperValue)
|
||||||
|
|
||||||
if (upperValue.startsWith('JOB:') || upperValue.startsWith('PRN:')) {
|
// If it's a valid prefix
|
||||||
const parts = upperValue.split(':')
|
if (prefixInfo) {
|
||||||
const prefix = parts[0] + ':'
|
setInputPrefix(prefixInfo)
|
||||||
const restOfInput = value.substring(prefix.length)
|
// Remove the prefix from the input value, keeping only what comes after the mode character
|
||||||
|
const remainingValue = value.substring(4)
|
||||||
// Set the prefix and update the input without the prefix
|
|
||||||
setInputPrefix(prefix)
|
|
||||||
if (formRef.current) {
|
if (formRef.current) {
|
||||||
formRef.current.setFieldsValue({ query: restOfInput })
|
formRef.current.setFieldsValue({ query: remainingValue })
|
||||||
// Ensure input gets focus after prefix is set
|
|
||||||
focusInput()
|
|
||||||
}
|
}
|
||||||
|
setQuery(remainingValue)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the form value normally
|
||||||
|
if (formRef.current) {
|
||||||
|
formRef.current.setFieldsValue({ query: value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle key down events for backspace behavior and navigation
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
// If backspace is pressed and there's a prefix but the input is empty
|
||||||
|
if (e.key === 'Backspace' && inputPrefix && query === '') {
|
||||||
|
console.log('Clearing prefix on backspace')
|
||||||
|
// Clear the prefix
|
||||||
|
setInputPrefix(null)
|
||||||
|
// Prevent the default backspace behavior in this case
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle navigation shortcuts
|
||||||
|
if (listData.length > 0) {
|
||||||
|
// Enter key - navigate to first item
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
navigateToItem(listData[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number keys 0-9 - navigate to corresponding item
|
||||||
|
if (/^[0-9]$/.test(e.key)) {
|
||||||
|
e.preventDefault()
|
||||||
|
const index = parseInt(e.key)
|
||||||
|
// 0-9 keys map to items 1-9 (indices 0-8)
|
||||||
|
const itemIndex = index + 1
|
||||||
|
if (itemIndex < listData.length) {
|
||||||
|
navigateToItem(listData[itemIndex])
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle key down events for backspace behavior
|
// Function to navigate to item URL
|
||||||
const handleKeyDown = (e) => {
|
const navigateToItem = (item) => {
|
||||||
// If backspace is pressed and there's a prefix but the input is empty
|
// Determine type for meta lookup
|
||||||
|
let type = item.type || inputPrefix?.type
|
||||||
|
// Fallback: try to infer type from known keys
|
||||||
|
if (!type) {
|
||||||
|
if (item.printer) type = 'printer'
|
||||||
|
else if (item.job) type = 'job'
|
||||||
|
// Add more inference as needed
|
||||||
|
}
|
||||||
|
|
||||||
if (e.key === 'Backspace' && inputPrefix && query == inputPrefix) {
|
const meta = getTypeMeta(type)
|
||||||
console.log('Query', query)
|
|
||||||
// Clear the prefix
|
// Get the appropriate ID for the item
|
||||||
setInputPrefix('')
|
let itemId = item._id || item.id
|
||||||
// Prevent the default backspace behavior in this case
|
|
||||||
e.preventDefault()
|
// For printers, use the printer's _id
|
||||||
|
if (type === 'printer' && item._id) {
|
||||||
|
itemId = item._id
|
||||||
|
}
|
||||||
|
|
||||||
|
// For jobs, use the job's id
|
||||||
|
if (type === 'job' && item.job && item.job.id) {
|
||||||
|
itemId = item.job.id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemId && meta.url) {
|
||||||
|
const url = meta.url(itemId)
|
||||||
|
if (url && url !== '#') {
|
||||||
|
navigate(url)
|
||||||
|
setShowModal(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -259,10 +380,21 @@ const SpotlightProvider = ({ children }) => {
|
|||||||
// Focus input when inputPrefix changes
|
// Focus input when inputPrefix changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showModal) {
|
if (showModal) {
|
||||||
|
// Only clear data if there's no existing data and no current query
|
||||||
|
if (listData.length === 0 && !query) {
|
||||||
|
setListData([])
|
||||||
|
}
|
||||||
focusInput()
|
focusInput()
|
||||||
}
|
}
|
||||||
}, [inputPrefix, showModal])
|
}, [inputPrefix, showModal])
|
||||||
|
|
||||||
|
// Update form value when query changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (showModal && formRef.current) {
|
||||||
|
formRef.current.setFieldsValue({ query: query })
|
||||||
|
}
|
||||||
|
}, [query, showModal])
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@ -272,6 +404,20 @@ const SpotlightProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Helper function to get mode description
|
||||||
|
const getModeDescription = (mode) => {
|
||||||
|
switch (mode) {
|
||||||
|
case ':':
|
||||||
|
return 'ID lookup'
|
||||||
|
case '?':
|
||||||
|
return 'Filter'
|
||||||
|
case '^':
|
||||||
|
return 'Search'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SpotlightContext.Provider value={{ showSpotlight }}>
|
<SpotlightContext.Provider value={{ showSpotlight }}>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
@ -280,22 +426,39 @@ const SpotlightProvider = ({ children }) => {
|
|||||||
onCancel={() => setShowModal(false)}
|
onCancel={() => setShowModal(false)}
|
||||||
closeIcon={null}
|
closeIcon={null}
|
||||||
footer={null}
|
footer={null}
|
||||||
styles={{ content: { backgroundColor: 'transparent' } }}
|
styles={{ content: { padding: 0 } }}
|
||||||
|
destroyOnHidden={true}
|
||||||
>
|
>
|
||||||
<Flex vertical>
|
<Flex vertical>
|
||||||
<Form ref={formRef} onValuesChange={handleSpotlightChange}>
|
<Form ref={formRef} onValuesChange={handleSpotlightChange}>
|
||||||
<Form.Item name='query' initialValue={query}>
|
<Form.Item name='query' initialValue={query} style={{ margin: 0 }}>
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder='Enter a query or scan a barcode...'
|
placeholder='Enter a query or scan a barcode...'
|
||||||
size='large'
|
size='large'
|
||||||
addonBefore={inputPrefix || undefined}
|
addonBefore={
|
||||||
|
inputPrefix ? (
|
||||||
|
<Flex align='center' gap='small'>
|
||||||
|
<Text style={{ fontSize: 20 }}>{inputPrefix.prefix}</Text>
|
||||||
|
<Text style={{ fontSize: 20 }} type='secondary'>
|
||||||
|
{inputPrefix.mode}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
suffix={
|
suffix={
|
||||||
|
<Flex align='center' gap='small'>
|
||||||
|
{inputPrefix?.mode && (
|
||||||
|
<Text type='secondary' style={{ fontSize: '12px' }}>
|
||||||
|
{getModeDescription(inputPrefix.mode)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Spin
|
<Spin
|
||||||
indicator={<LoadingOutlined />}
|
indicator={<LoadingOutlined />}
|
||||||
spinning={loading}
|
spinning={loading}
|
||||||
size='small'
|
size='small'
|
||||||
/>
|
/>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
@ -304,63 +467,100 @@ const SpotlightProvider = ({ children }) => {
|
|||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
{listData.length > 0 && (
|
{listData.length > 0 && (
|
||||||
|
<div style={{ marginLeft: '18px', marginRight: '14px' }}>
|
||||||
<List
|
<List
|
||||||
bordered
|
|
||||||
dataSource={listData}
|
dataSource={listData}
|
||||||
renderItem={(item) => (
|
renderItem={(item, index) => {
|
||||||
|
// Determine type for meta lookup
|
||||||
|
let type = item.type || inputPrefix?.type
|
||||||
|
// Fallback: try to infer type from known keys
|
||||||
|
if (!type) {
|
||||||
|
if (item.printer) type = 'printer'
|
||||||
|
else if (item.job) type = 'job'
|
||||||
|
// Add more inference as needed
|
||||||
|
}
|
||||||
|
const meta = getTypeMeta(type)
|
||||||
|
console.log('meta', inputPrefix?.type)
|
||||||
|
const Icon = meta.icon
|
||||||
|
|
||||||
|
// Determine shortcut text
|
||||||
|
let shortcutText = ''
|
||||||
|
if (index === 0) {
|
||||||
|
shortcutText = 'ENTER'
|
||||||
|
} else if (index <= 10) {
|
||||||
|
shortcutText = (index - 1).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Item.Meta
|
<List.Item.Meta
|
||||||
description={
|
description={
|
||||||
<Flex gap={'middle'} align='center'>
|
<Flex gap={'middle'} align='center'>
|
||||||
<Text>
|
<Text>
|
||||||
{item.printer ? (
|
{Icon ? (
|
||||||
<PrinterIcon style={{ fontSize: '20px' }} />
|
<Icon style={{ fontSize: '20px' }} />
|
||||||
) : null}
|
|
||||||
{item.job ? (
|
|
||||||
<JobIcon style={{ fontSize: '20px' }} />
|
|
||||||
) : null}
|
) : null}
|
||||||
</Text>
|
</Text>
|
||||||
<Flex
|
<Flex gap={'small'} style={{ marginBottom: '2px' }}>
|
||||||
vertical
|
{item.name ? <Text>{item.name}</Text> : null}
|
||||||
gap={'6px'}
|
|
||||||
style={{ marginBottom: '2px' }}
|
|
||||||
>
|
|
||||||
<Text>{item.name}</Text>
|
|
||||||
|
|
||||||
{item.printer ? (
|
{meta.type == 'printer' ? (
|
||||||
<Flex gap={'small'}>
|
|
||||||
<PrinterState
|
<PrinterState
|
||||||
printer={item.printer}
|
printer={item}
|
||||||
showPrinterName={false}
|
showPrinterName={false}
|
||||||
|
showProgress={false}
|
||||||
|
showId={false}
|
||||||
/>
|
/>
|
||||||
<IdText
|
|
||||||
id={item.id}
|
|
||||||
longId={false}
|
|
||||||
type='printer'
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
) : null}
|
) : null}
|
||||||
{item.job ? (
|
{meta.type == 'job' ? (
|
||||||
<Flex gap={'small'}>
|
|
||||||
{item.job.state.type ? (
|
|
||||||
<JobState
|
<JobState
|
||||||
job={item.job}
|
job={item}
|
||||||
showQuantity={false}
|
showQuantity={false}
|
||||||
|
showProgress={false}
|
||||||
showId={false}
|
showId={false}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<IdText id={item.id} longId={false} type='job' />
|
{meta.type == 'subjob' ? (
|
||||||
|
<SubJobState
|
||||||
|
subJob={item}
|
||||||
|
showProgress={false}
|
||||||
|
showId={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{meta.type == 'filamentstock' ? (
|
||||||
|
<Flex gap={'small'}>
|
||||||
|
<FilamentStockState
|
||||||
|
filamentStock={item}
|
||||||
|
showId={false}
|
||||||
|
showProgress={false}
|
||||||
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
) : null}
|
) : null}
|
||||||
|
<IdText
|
||||||
|
id={item._id}
|
||||||
|
type={meta.type}
|
||||||
|
longId={false}
|
||||||
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Text keyboard>ENTER</Text>
|
<Flex gap={'small'}>
|
||||||
|
{shortcutText && <Text keyboard>{shortcutText}</Text>}
|
||||||
|
<Button
|
||||||
|
icon={<InfoCircleIcon />}
|
||||||
|
size={'small'}
|
||||||
|
type={'text'}
|
||||||
|
onClick={() => navigateToItem(item)}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)
|
||||||
|
}}
|
||||||
></List>
|
></List>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
18
src/components/Dashboard/hooks/useViewMode.js
Normal 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
|
||||||
@ -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: () => '#'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
7
src/components/Icons/DeveloperIcon.jsx
Normal 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
|
||||||
7
src/components/Icons/GridIcon.jsx
Normal 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
|
||||||
7
src/components/Icons/ListIcon.jsx
Normal 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
|
||||||
7
src/components/Icons/NoteIcon.jsx
Normal 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
|
||||||
7
src/components/Icons/ThreeDotsIcon.jsx
Normal 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
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||