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;
|
||||
}
|
||||
|
||||
code {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
@ -53,3 +57,28 @@
|
||||
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 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 './App.css'
|
||||
import { SocketProvider } from './components/Dashboard/context/SocketContext.js'
|
||||
@ -59,6 +59,12 @@ import {
|
||||
import AppError from './components/App/AppError'
|
||||
import NoteTypes from './components/Dashboard/Management/NoteTypes.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 { themeConfig } = useThemeContext()
|
||||
@ -67,9 +73,10 @@ const AppContent = () => {
|
||||
<ConfigProvider theme={themeConfig}>
|
||||
<App>
|
||||
<AuthProvider>
|
||||
<SocketProvider>
|
||||
<SpotlightProvider>
|
||||
<Router>
|
||||
<SocketProvider>
|
||||
<NotificationProvider>
|
||||
<SpotlightProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path='/'
|
||||
@ -93,7 +100,10 @@ const AppContent = () => {
|
||||
path='production/overview'
|
||||
element={<ProductionOverview />}
|
||||
/>
|
||||
<Route path='production/printers' element={<Printers />} />
|
||||
<Route
|
||||
path='production/printers'
|
||||
element={<Printers />}
|
||||
/>
|
||||
<Route
|
||||
path='production/printers/control'
|
||||
element={<ControlPrinter />}
|
||||
@ -103,7 +113,10 @@ const AppContent = () => {
|
||||
element={<PrinterInfo />}
|
||||
/>
|
||||
<Route path='production/jobs' element={<Jobs />} />
|
||||
<Route path='production/jobs/info' element={<JobInfo />} />
|
||||
<Route
|
||||
path='production/jobs/info'
|
||||
element={<JobInfo />}
|
||||
/>
|
||||
<Route
|
||||
path='production/gcodefiles'
|
||||
element={<GCodeFiles />}
|
||||
@ -153,12 +166,19 @@ const AppContent = () => {
|
||||
path='management/parts/info'
|
||||
element={<PartInfo />}
|
||||
/>
|
||||
<Route path='management/products' element={<Products />} />
|
||||
<Route
|
||||
path='management/products'
|
||||
element={<Products />}
|
||||
/>
|
||||
<Route
|
||||
path='management/products/info'
|
||||
element={<ProductInfo />}
|
||||
/>
|
||||
<Route path='management/vendors' element={<Vendors />} />
|
||||
<Route
|
||||
path='management/users/info'
|
||||
element={<UserInfo />}
|
||||
/>
|
||||
<Route
|
||||
path='management/vendors/info'
|
||||
element={<VendorInfo />}
|
||||
@ -171,15 +191,31 @@ const AppContent = () => {
|
||||
path='management/notetypes'
|
||||
element={<NoteTypes />}
|
||||
/>
|
||||
<Route path='management/users' element={<Users />} />
|
||||
<Route
|
||||
path='management/notetypes/info'
|
||||
element={<NoteTypeInfo />}
|
||||
/>
|
||||
<Route path='management/settings' element={<Settings />} />
|
||||
<Route
|
||||
path='management/settings'
|
||||
element={<Settings />}
|
||||
/>
|
||||
<Route
|
||||
path='management/auditlogs'
|
||||
element={<AuditLogs />}
|
||||
/>
|
||||
<Route
|
||||
path='developer/sessionstorage'
|
||||
element={<SessionStorage />}
|
||||
/>
|
||||
<Route
|
||||
path='developer/authcontextdebug'
|
||||
element={<AuthContextDebug />}
|
||||
/>
|
||||
<Route
|
||||
path='developer/socketcontextdebug'
|
||||
element={<SocketContextDebug />}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path='*'
|
||||
@ -191,9 +227,10 @@ const AppContent = () => {
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Router>
|
||||
</SpotlightProvider>
|
||||
</NotificationProvider>
|
||||
</SocketProvider>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</App>
|
||||
</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"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(0.587492,0,0,0.587492,32,25.3907)">
|
||||
<rect x="0" y="0" width="54.469" height="65.719" style="fill-opacity:0;"/>
|
||||
<g transform="matrix(1,0,0,1,0,-0.603074)">
|
||||
<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;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.952541,0,0,0.952541,0.142378,0.192271)">
|
||||
<path d="M10.65,56.399C10.65,54.429 9.1,52.819 7.081,52.819C5.148,52.819 3.561,54.45 3.561,56.399C3.561,58.34 5.148,59.949 7.081,59.949C9.057,59.949 10.65,58.34 10.65,56.399ZM31.147,53.788L17.05,53.788C15.433,53.788 14.226,54.904 14.226,56.399C14.226,57.888 15.433,59.035 17.05,59.035L35.425,59.035C33.338,57.368 31.955,55.709 31.147,53.788ZM10.65,44.582C10.65,42.617 9.1,41.032 7.081,41.032C5.148,41.032 3.561,42.633 3.561,44.582C3.561,46.523 5.148,48.107 7.081,48.107C9.057,48.107 10.65,46.523 10.65,44.582ZM30.242,41.97L17.05,41.97C15.433,41.97 14.226,43.087 14.226,44.582C14.226,46.076 15.433,47.218 17.05,47.218L30.242,47.218L30.242,41.97ZM30.242,35.341L11.704,35.341C5.368,35.341 3,32.505 3,26.425L3,12.967C3,6.882 5.368,4.051 11.704,4.051L52.296,4.051C58.632,4.051 61,6.882 61,12.967L61,26.425C61,26.988 60.98,27.522 60.938,28.03C59.436,27.47 57.18,26.685 55.137,25.987L55.137,12.807C55.137,10.413 54.318,9.337 51.948,9.337L12.052,9.337C9.682,9.337 8.863,10.413 8.863,12.807L8.863,26.585C8.863,28.98 9.682,30.055 12.052,30.055L31.321,30.055C30.566,30.894 30.242,31.995 30.242,33.666L30.242,35.341Z" style="fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
</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
|
||||
import React from 'react'
|
||||
import DashboardLayout from './DashboardLayout'
|
||||
import Layout from './Layout'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
|
||||
const Dashboard = () => {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Layout>
|
||||
<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 useColumnVisibility from '../hooks/useColumnVisibility'
|
||||
import DashboardTable from '../common/DashboardTable'
|
||||
import ListIcon from '../../Icons/ListIcon'
|
||||
import GridIcon from '../../Icons/GridIcon'
|
||||
import useViewMode from '../hooks/useViewMode'
|
||||
|
||||
import config from '../../../config'
|
||||
|
||||
@ -46,6 +49,8 @@ const FilamentStocks = () => {
|
||||
|
||||
const { authenticated } = useContext(AuthContext)
|
||||
|
||||
const [viewMode, setViewMode] = useViewMode('FilamentStocks')
|
||||
|
||||
const getFilterDropdown = ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
@ -85,12 +90,11 @@ const FilamentStocks = () => {
|
||||
// Column definitions
|
||||
const columns = [
|
||||
{
|
||||
title: '',
|
||||
dataIndex: '',
|
||||
title: <FilamentStockIcon />,
|
||||
key: 'icon',
|
||||
width: 40,
|
||||
fixed: 'left',
|
||||
render: () => <FilamentStockIcon></FilamentStockIcon>
|
||||
render: () => <FilamentStockIcon />
|
||||
},
|
||||
{
|
||||
title: 'Filament Name',
|
||||
@ -112,7 +116,7 @@ const FilamentStocks = () => {
|
||||
clearFilters,
|
||||
propertyName: 'filament name'
|
||||
}),
|
||||
render: (filament) => <Text ellipsis>{filament.name}</Text>
|
||||
render: (filament) => <Text ellipsis>{filament?.name}</Text>
|
||||
},
|
||||
{
|
||||
title: 'ID',
|
||||
@ -136,7 +140,7 @@ const FilamentStocks = () => {
|
||||
width: 140,
|
||||
sorter: true,
|
||||
render: (currentNetWeight) => (
|
||||
<Text ellipsis>{currentNetWeight.toFixed(2) + 'g'}</Text>
|
||||
<Text ellipsis>{currentNetWeight?.toFixed(2) + 'g'}</Text>
|
||||
)
|
||||
},
|
||||
{
|
||||
@ -146,7 +150,7 @@ const FilamentStocks = () => {
|
||||
width: 140,
|
||||
sorter: true,
|
||||
render: (startingNetWeight) => (
|
||||
<Text ellipsis>{startingNetWeight.toFixed(2) + 'g'}</Text>
|
||||
<Text ellipsis>{startingNetWeight?.toFixed(2) + 'g'}</Text>
|
||||
)
|
||||
},
|
||||
{
|
||||
@ -183,7 +187,7 @@ const FilamentStocks = () => {
|
||||
key: 'actions',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (text, record) => {
|
||||
render: (record) => {
|
||||
return (
|
||||
<Space gap='small'>
|
||||
<Button
|
||||
@ -314,6 +318,14 @@ const FilamentStocks = () => {
|
||||
<Button>View</Button>
|
||||
</Popover>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button
|
||||
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
|
||||
onClick={() =>
|
||||
setViewMode(viewMode === 'cards' ? 'list' : 'cards')
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
<DashboardTable
|
||||
@ -321,6 +333,7 @@ const FilamentStocks = () => {
|
||||
columns={visibleColumns}
|
||||
url={`${config.backendUrl}/filamentstocks`}
|
||||
authenticated={authenticated}
|
||||
cards={viewMode === 'cards'}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -11,9 +11,13 @@ import {
|
||||
Form,
|
||||
Badge,
|
||||
Collapse,
|
||||
Flex
|
||||
Flex,
|
||||
Dropdown,
|
||||
Popover,
|
||||
Card,
|
||||
Checkbox
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
||||
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||
import IdText from '../../common/IdText'
|
||||
import { SocketContext } from '../../context/SocketContext'
|
||||
import FilamentStockState from '../../common/FilamentStockState'
|
||||
@ -22,10 +26,16 @@ import useCollapseState from '../../hooks/useCollapseState'
|
||||
import TimeDisplay from '../../common/TimeDisplay'
|
||||
import FilamentIcon from '../../../Icons/FilamentIcon'
|
||||
import ReloadIcon from '../../../Icons/ReloadIcon'
|
||||
import AuditLogTable from '../../common/AuditLogTable'
|
||||
import DashboardNotes from '../../common/DashboardNotes'
|
||||
|
||||
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 [filamentStockData, setFilamentStockData] = useState(null)
|
||||
@ -43,7 +53,9 @@ const FilamentStockInfo = () => {
|
||||
'FilamentStockInfo',
|
||||
{
|
||||
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 (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
||||
</div>
|
||||
<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 || !filamentStockData) {
|
||||
if (error) {
|
||||
return (
|
||||
<Space
|
||||
direction='vertical'
|
||||
@ -131,27 +177,61 @@ const FilamentStockInfo = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
<>
|
||||
{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'}>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.info ? ['1'] : []}
|
||||
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('info', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
<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 Stock Information
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='1'
|
||||
>
|
||||
@ -159,8 +239,12 @@ const FilamentStockInfo = () => {
|
||||
form={form}
|
||||
layout='vertical'
|
||||
initialValues={{
|
||||
filament: filamentStockData.filament || {}
|
||||
filament: filamentStockData?.filament || {}
|
||||
}}
|
||||
>
|
||||
<Spin
|
||||
indicator={<LoadingOutlined />}
|
||||
spinning={fetchLoading}
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
@ -175,32 +259,49 @@ const FilamentStockInfo = () => {
|
||||
>
|
||||
{/* Read-only fields */}
|
||||
<Descriptions.Item label='ID' span={1}>
|
||||
{filamentStockData.id ? (
|
||||
<IdText id={filamentStockData.id} type={'filamentstock'} />
|
||||
{filamentStockData?.id ? (
|
||||
<IdText
|
||||
id={filamentStockData.id}
|
||||
type={'filamentstock'}
|
||||
/>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Created At'>
|
||||
{filamentStockData?.createdAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={filamentStockData.createdAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='State'>
|
||||
<FilamentStockState filamentStock={filamentStockData} />
|
||||
{filamentStockData ? (
|
||||
<FilamentStockState
|
||||
filamentStock={filamentStockData}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Updated At'>
|
||||
{filamentStockData?.updatedAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={filamentStockData.updatedAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Filament Name'>
|
||||
{filamentStockData.filament ? (
|
||||
{filamentStockData?.filament ? (
|
||||
<Space>
|
||||
<FilamentIcon />
|
||||
<Badge
|
||||
@ -209,83 +310,162 @@ const FilamentStockInfo = () => {
|
||||
/>
|
||||
</Space>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Filament ID' span={1}>
|
||||
{filamentStockData.filament ? (
|
||||
{filamentStockData?.filament ? (
|
||||
<IdText
|
||||
id={filamentStockData.filament.id}
|
||||
type={'filament'}
|
||||
showHyperlink={true}
|
||||
/>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Current Weight'>
|
||||
{filamentStockData.currentGrossWeight ? (
|
||||
{filamentStockData?.currentGrossWeight ? (
|
||||
<Descriptions style={{ width: '250px' }} column={2}>
|
||||
<Descriptions.Item label='Net'>
|
||||
{filamentStockData.currentNetWeight.toFixed(2) + 'g'}
|
||||
{filamentStockData.currentNetWeight.toFixed(2) +
|
||||
'g'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Gross'>
|
||||
{filamentStockData.currentGrossWeight.toFixed(2) + 'g'}
|
||||
{filamentStockData.currentGrossWeight.toFixed(
|
||||
2
|
||||
) + 'g'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Starting Weight'>
|
||||
{filamentStockData.startingGrossWeight ? (
|
||||
{filamentStockData?.startingGrossWeight ? (
|
||||
<Space>
|
||||
<Descriptions style={{ width: '250px' }} column={2}>
|
||||
<Descriptions
|
||||
style={{ width: '250px' }}
|
||||
column={2}
|
||||
>
|
||||
<Descriptions.Item label='Net'>
|
||||
{filamentStockData.startingNetWeight.toFixed(2) + 'g'}
|
||||
{filamentStockData.startingNetWeight.toFixed(
|
||||
2
|
||||
) + 'g'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Gross'>
|
||||
{filamentStockData.startingGrossWeight.toFixed(2) +
|
||||
'g'}
|
||||
{filamentStockData.startingGrossWeight.toFixed(
|
||||
2
|
||||
) + 'g'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Space>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Spin>
|
||||
</Form>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.events ? ['2'] : []}
|
||||
onChange={(keys) => updateCollapseState('events', keys.length > 0)}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('events', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<FilamentStockIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Filament Stock Events
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
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>
|
||||
</Flex>
|
||||
</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,
|
||||
Input
|
||||
} from 'antd'
|
||||
import { AuditOutlined } from '@ant-design/icons'
|
||||
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import { SocketContext } from '../context/SocketContext'
|
||||
@ -17,14 +16,17 @@ import IdText from '../common/IdText'
|
||||
import TimeDisplay from '../common/TimeDisplay'
|
||||
import ReloadIcon from '../../Icons/ReloadIcon'
|
||||
import PlusMinusIcon from '../../Icons/PlusMinusIcon'
|
||||
import SubJobIcon from '../../Icons/SubJobIcon'
|
||||
import PlayCircleIcon from '../../Icons/PlayCircleIcon'
|
||||
import XMarkIcon from '../../Icons/XMarkIcon'
|
||||
import CheckIcon from '../../Icons/CheckIcon'
|
||||
import useColumnVisibility from '../hooks/useColumnVisibility'
|
||||
import DashboardTable from '../common/DashboardTable'
|
||||
import GridIcon from '../../Icons/GridIcon'
|
||||
import ListIcon from '../../Icons/ListIcon'
|
||||
import useViewMode from '../hooks/useViewMode'
|
||||
|
||||
import config from '../../../config'
|
||||
import { getTypeMeta } from '../utils/Utils'
|
||||
import StockEventIcon from '../../Icons/StockEventIcon'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@ -32,26 +34,16 @@ const StockEvents = () => {
|
||||
const { socket } = useContext(SocketContext)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const tableRef = useRef()
|
||||
const [viewMode, setViewMode] = useViewMode('StockEvents')
|
||||
|
||||
// Column definitions for visibility
|
||||
const columns = [
|
||||
{
|
||||
title: '',
|
||||
title: <StockEventIcon />,
|
||||
key: 'icon',
|
||||
width: 40,
|
||||
fixed: 'left',
|
||||
render: (record) => {
|
||||
switch (record.type.toLowerCase()) {
|
||||
case 'subjob':
|
||||
return <SubJobIcon />
|
||||
case 'audit':
|
||||
return <AuditOutlined />
|
||||
case 'initial':
|
||||
return <PlayCircleIcon />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
render: () => <StockEventIcon />
|
||||
},
|
||||
{
|
||||
title: 'Type',
|
||||
@ -60,6 +52,9 @@ const StockEvents = () => {
|
||||
width: 200,
|
||||
fixed: 'left',
|
||||
sorter: true,
|
||||
render: (type) => {
|
||||
return <Text>{getTypeMeta(type?.toLowerCase()).title}</Text>
|
||||
},
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
@ -90,7 +85,7 @@ const StockEvents = () => {
|
||||
width: 100,
|
||||
sorter: true,
|
||||
render: (value, record) => {
|
||||
const formattedValue = value.toFixed(2) + record.unit
|
||||
const formattedValue = value?.toFixed(2) + record?.unit
|
||||
return (
|
||||
<Text type={value < 0 ? 'danger' : 'success'}>
|
||||
{value > 0 ? '+' + formattedValue : formattedValue}
|
||||
@ -122,7 +117,7 @@ const StockEvents = () => {
|
||||
width: 170 * 2,
|
||||
render: (record) => {
|
||||
const ids = (
|
||||
<Space size={'middle'}>
|
||||
<Flex gap={'small'} wrap>
|
||||
{record.job ? (
|
||||
<IdText
|
||||
id={record.job}
|
||||
@ -146,7 +141,7 @@ const StockEvents = () => {
|
||||
showHyperlink={true}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
</Flex>
|
||||
)
|
||||
if (!record.stockAudit && !record.job && !record.subJob) {
|
||||
return 'n/a'
|
||||
@ -307,6 +302,14 @@ const StockEvents = () => {
|
||||
<Button>View</Button>
|
||||
</Popover>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button
|
||||
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
|
||||
onClick={() =>
|
||||
setViewMode(viewMode === 'cards' ? 'list' : 'cards')
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
<DashboardTable
|
||||
@ -314,6 +317,7 @@ const StockEvents = () => {
|
||||
columns={visibleColumns}
|
||||
url={`${config.backendUrl}/stockevents`}
|
||||
authenticated={authenticated}
|
||||
cards={viewMode === 'cards'}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
|
||||
@ -3,12 +3,13 @@ import PropTypes from 'prop-types'
|
||||
import React from 'react'
|
||||
import { Layout, Flex } from 'antd'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import DashboardNavigation from './DashboardNavigation'
|
||||
import ProductionSidebar from './ProductionSidebar'
|
||||
import InventorySidebar from './InventorySidebar'
|
||||
import ManagementSidebar from './ManagementSidebar'
|
||||
import DashboardBreadcrumb from './DashboardBreadcrumb'
|
||||
import './DashboardLayout.css'
|
||||
import ProductionSidebar from './Production/ProductionSidebar'
|
||||
import InventorySidebar from './Inventory/InventorySidebar'
|
||||
import ManagementSidebar from './Management/ManagementSidebar'
|
||||
import DashboardNavigation from './common/DashboardNavigation'
|
||||
import DashboardBreadcrumb from './common/DashboardBreadcrumb'
|
||||
import './Layout.css'
|
||||
import DeveloperSidebar from './Developer/DeveloperSidebar'
|
||||
|
||||
const { Content } = Layout
|
||||
|
||||
@ -17,9 +18,10 @@ const DashboardLayout = ({ children }) => {
|
||||
const isProduction = location.pathname.startsWith('/dashboard/production')
|
||||
const isInventory = location.pathname.startsWith('/dashboard/inventory')
|
||||
const isManagement = location.pathname.startsWith('/dashboard/management')
|
||||
const isDeveloper = location.pathname.startsWith('/dashboard/developer')
|
||||
|
||||
return (
|
||||
<Layout style={{ height: '100vh' }}>
|
||||
<Layout style={{ height: 'var(--unit-100vh)' }}>
|
||||
<DashboardNavigation />
|
||||
<Layout>
|
||||
{isProduction ? (
|
||||
@ -28,6 +30,8 @@ const DashboardLayout = ({ children }) => {
|
||||
<InventorySidebar />
|
||||
) : isManagement ? (
|
||||
<ManagementSidebar />
|
||||
) : isDeveloper ? (
|
||||
<DeveloperSidebar />
|
||||
) : (
|
||||
<ProductionSidebar /> // Default to production sidebar
|
||||
)}
|
||||
@ -7,7 +7,6 @@ import {
|
||||
Popover,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
Tag,
|
||||
Descriptions,
|
||||
Input,
|
||||
Badge
|
||||
@ -24,6 +23,7 @@ import config from '../../../config'
|
||||
import AuditLogIcon from '../../Icons/AuditLogIcon'
|
||||
import XMarkIcon from '../../Icons/XMarkIcon'
|
||||
import CheckIcon from '../../Icons/CheckIcon'
|
||||
import BoolDisplay from '../common/BoolDisplay'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@ -62,11 +62,7 @@ const formatValue = (value, propertyName) => {
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<Tag color={value ? 'success' : 'error'} style={{ margin: 0 }}>
|
||||
{value ? 'Yes' : 'No'}
|
||||
</Tag>
|
||||
)
|
||||
return <BoolDisplay value={value} yesNo={true} />
|
||||
}
|
||||
|
||||
if (isObjectId(value)) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Descriptions,
|
||||
@ -15,14 +15,13 @@ import {
|
||||
InputNumber,
|
||||
ColorPicker,
|
||||
Select,
|
||||
Modal,
|
||||
Collapse
|
||||
Collapse,
|
||||
Dropdown,
|
||||
Popover,
|
||||
Checkbox,
|
||||
Card
|
||||
} from 'antd'
|
||||
import {
|
||||
LoadingOutlined,
|
||||
DeleteOutlined,
|
||||
CaretRightOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||
import IdText from '../../common/IdText'
|
||||
import ReloadIcon from '../../../Icons/ReloadIcon'
|
||||
import EditIcon from '../../../Icons/EditIcon.jsx'
|
||||
@ -31,28 +30,33 @@ import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
||||
import TimeDisplay from '../../common/TimeDisplay.jsx'
|
||||
import VendorSelect from '../../common/VendorSelect'
|
||||
import useCollapseState from '../../hooks/useCollapseState'
|
||||
import AuditLogTable from '../../common/AuditLogTable'
|
||||
import DashboardNotes from '../../common/DashboardNotes'
|
||||
|
||||
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 FilamentInfo = () => {
|
||||
const [filamentData, setFilamentData] = useState(null)
|
||||
const [fetchLoading, setFetchLoading] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const filamentId = new URLSearchParams(location.search).get('filamentId')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const navigate = useNavigate()
|
||||
const [collapseState, updateCollapseState] = useCollapseState(
|
||||
'FilamentInfo',
|
||||
{
|
||||
info: true,
|
||||
details: true
|
||||
details: true,
|
||||
notes: true,
|
||||
auditLogs: true
|
||||
}
|
||||
)
|
||||
|
||||
@ -92,6 +96,7 @@ const FilamentInfo = () => {
|
||||
}
|
||||
)
|
||||
setFilamentData(response.data)
|
||||
form.setFieldsValue(response.data)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError('Failed to fetch filament details')
|
||||
@ -128,7 +133,7 @@ const FilamentInfo = () => {
|
||||
const updateFilamentInfo = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setLoading(true)
|
||||
setEditLoading(true)
|
||||
|
||||
await axios.put(
|
||||
`${config.backendUrl}/filaments/${filamentId}`,
|
||||
@ -165,36 +170,52 @@ const FilamentInfo = () => {
|
||||
messageApi.error('Failed to update filament information')
|
||||
} finally {
|
||||
fetchFilamentDetails()
|
||||
setLoading(false)
|
||||
setEditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
await axios.delete(`${config.backendUrl}/filaments/${filamentId}`, {
|
||||
withCredentials: true
|
||||
})
|
||||
messageApi.success('Filament deleted successfully')
|
||||
navigate('/dashboard/filaments')
|
||||
} catch (err) {
|
||||
console.error('Failed to delete filament:', err)
|
||||
messageApi.error('Failed to delete filament')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setIsDeleteModalOpen(false)
|
||||
const actionItems = {
|
||||
items: [
|
||||
{
|
||||
label: 'Reload Filament',
|
||||
key: 'reload',
|
||||
icon: <ReloadIcon />
|
||||
}
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reload') {
|
||||
fetchFilamentDetails()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fetchLoading) {
|
||||
const getViewDropdownItems = () => {
|
||||
const sections = [
|
||||
{ key: 'info', label: 'Filament Information' },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'auditLogs', label: 'Audit Logs' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
||||
</div>
|
||||
<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 || !filamentData) {
|
||||
if (error) {
|
||||
return (
|
||||
<Space
|
||||
direction='vertical'
|
||||
@ -209,52 +230,40 @@ const FilamentInfo = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
<>
|
||||
{contextHolder}
|
||||
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
activeKey={collapseState.info ? ['1'] : []}
|
||||
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '9px' }}
|
||||
/>
|
||||
)}
|
||||
className='no-h-padding-collapse no-t-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex
|
||||
align='center'
|
||||
justify='space-between'
|
||||
style={{ width: '100%' }}
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Filament Information
|
||||
</Title>
|
||||
<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={<DeleteOutlined />}
|
||||
danger
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
loading={loading}
|
||||
/>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
icon={<CheckIcon />}
|
||||
type='primary'
|
||||
onClick={updateFilamentInfo}
|
||||
loading={loading}
|
||||
loading={editLoading}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
<Button
|
||||
icon={<XMarkIcon />}
|
||||
onClick={cancelEditing}
|
||||
disabled={loading}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@ -262,6 +271,40 @@ const FilamentInfo = () => {
|
||||
)}
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
{error ? (
|
||||
<Space
|
||||
direction='vertical'
|
||||
style={{ width: '100%', textAlign: 'center' }}
|
||||
>
|
||||
<p>{error || 'Print job not found'}</p>
|
||||
<Button icon={<ReloadIcon />} onClick={fetchFilamentDetails}>
|
||||
Retry
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.info ? ['1'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('info', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse no-t-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<InfoCircleIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Filament Information
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='1'
|
||||
>
|
||||
@ -269,16 +312,20 @@ const FilamentInfo = () => {
|
||||
form={form}
|
||||
layout='vertical'
|
||||
initialValues={{
|
||||
name: filamentData.name || '',
|
||||
vendor: filamentData.vendor || { id: null, name: '' },
|
||||
type: filamentData.type || '',
|
||||
cost: filamentData.cost || null,
|
||||
color: filamentData.color || '#000000',
|
||||
diameter: filamentData.diameter || null,
|
||||
density: filamentData.density || null,
|
||||
url: filamentData.url || '',
|
||||
barcode: filamentData.barcode || ''
|
||||
name: filamentData?.name || '',
|
||||
vendor: filamentData?.vendor || { id: null, name: '' },
|
||||
type: filamentData?.type || '',
|
||||
cost: filamentData?.cost || null,
|
||||
color: filamentData?.color || '#000000',
|
||||
diameter: filamentData?.diameter || null,
|
||||
density: filamentData?.density || null,
|
||||
url: filamentData?.url || '',
|
||||
barcode: filamentData?.barcode || ''
|
||||
}}
|
||||
>
|
||||
<Spin
|
||||
indicator={<LoadingOutlined />}
|
||||
spinning={fetchLoading}
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
@ -292,21 +339,24 @@ const FilamentInfo = () => {
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label='ID' span={1}>
|
||||
{filamentData.id ? (
|
||||
<IdText id={filamentData.id} type={'filament'} />
|
||||
{filamentData?._id ? (
|
||||
<IdText id={filamentData._id} type={'filament'} />
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Created At'>
|
||||
{filamentData?.createdAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={filamentData.createdAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Name'>
|
||||
<Flex align={'center'} gap={'small'}>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='name'
|
||||
@ -324,49 +374,58 @@ const FilamentInfo = () => {
|
||||
>
|
||||
<Input placeholder='Enter filament name' />
|
||||
</Form.Item>
|
||||
) : filamentData?.name ? (
|
||||
<Text>{filamentData.name}</Text>
|
||||
) : (
|
||||
filamentData.name || 'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Updated At'>
|
||||
{filamentData?.updatedAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={filamentData.updatedAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Vendor'>
|
||||
<Flex align={'center'} gap={'small'}>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='vendor'
|
||||
rules={[
|
||||
{ required: true, message: 'Please enter a vendor' }
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a vendor'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<VendorSelect />
|
||||
</Form.Item>
|
||||
) : filamentData.vendor.name ? (
|
||||
) : filamentData?.vendor?.name ? (
|
||||
<Text>{filamentData.vendor.name}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Vendor ID'>
|
||||
{filamentData?.vendor?.id ? (
|
||||
<IdText
|
||||
id={filamentData.vendor.id}
|
||||
type={'vendor'}
|
||||
showHyperlink={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Material'>
|
||||
<Flex align={'center'} gap={'small'}>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='type'
|
||||
@ -387,20 +446,23 @@ const FilamentInfo = () => {
|
||||
<Select.Option value='TPU'>TPU</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
) : filamentData?.type ? (
|
||||
<Text>{filamentData.type}</Text>
|
||||
) : (
|
||||
filamentData.type || 'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Cost'>
|
||||
<Flex align={'center'} gap={'small'}>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='cost'
|
||||
style={{ margin: 0 }}
|
||||
rules={[
|
||||
{ required: true, message: 'Please enter a cost' }
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a cost'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
@ -409,12 +471,11 @@ const FilamentInfo = () => {
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : filamentData.cost ? (
|
||||
`£${filamentData.cost}/kg`
|
||||
) : filamentData?.cost ? (
|
||||
<Text>{`£${filamentData.cost}/kg`}</Text>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Color'>
|
||||
@ -424,7 +485,10 @@ const FilamentInfo = () => {
|
||||
name='color'
|
||||
style={{ margin: 0 }}
|
||||
rules={[
|
||||
{ required: true, message: 'Please select a color' }
|
||||
{
|
||||
required: true,
|
||||
message: 'Please select a color'
|
||||
}
|
||||
]}
|
||||
getValueFromEvent={(color) => {
|
||||
return '#' + color.toHex()
|
||||
@ -432,123 +496,160 @@ const FilamentInfo = () => {
|
||||
>
|
||||
<ColorPicker showText disabledAlpha />
|
||||
</Form.Item>
|
||||
) : (
|
||||
) : filamentData?.color ? (
|
||||
<Badge
|
||||
color={filamentData.color}
|
||||
text={filamentData.color}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Diameter'>
|
||||
<Flex align={'center'} gap={'small'}>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='diameter'
|
||||
style={{ margin: 0 }}
|
||||
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>
|
||||
) : filamentData.diameter ? (
|
||||
`${filamentData.diameter}mm`
|
||||
) : filamentData?.diameter ? (
|
||||
<Text>{`${filamentData.diameter}mm`}</Text>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Density'>
|
||||
<Flex align={'center'} gap={'small'}>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='density'
|
||||
style={{ margin: 0 }}
|
||||
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>
|
||||
) : filamentData.density ? (
|
||||
`${filamentData.density}g/cm³`
|
||||
) : filamentData?.density ? (
|
||||
<Text>{`${filamentData.density}g/cm³`}</Text>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='URL'>
|
||||
<Flex align={'center'} gap={'small'}>
|
||||
{isEditing ? (
|
||||
<Form.Item name='url' style={{ margin: 0 }}>
|
||||
<Input placeholder='Enter URL' />
|
||||
</Form.Item>
|
||||
) : filamentData.url ? (
|
||||
) : filamentData?.url ? (
|
||||
<Link href={filamentData.url} target='_blank'>
|
||||
{filamentData.url}
|
||||
</Link>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Barcode'>
|
||||
<Flex align={'center'} gap={'small'}>
|
||||
{isEditing ? (
|
||||
<Form.Item name='barcode' style={{ margin: 0 }}>
|
||||
<Input placeholder='Enter barcode' />
|
||||
</Form.Item>
|
||||
) : filamentData?.barcode ? (
|
||||
<Text>{filamentData.barcode}</Text>
|
||||
) : (
|
||||
filamentData.barcode || 'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Spin>
|
||||
</Form>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
activeKey={collapseState.details ? ['2'] : []}
|
||||
onChange={(keys) => updateCollapseState('details', keys.length > 0)}
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.notes ? ['notes'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('notes', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
<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 }}>
|
||||
Additional Details
|
||||
Notes
|
||||
</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>
|
||||
</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>
|
||||
)}
|
||||
</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,
|
||||
Input,
|
||||
Badge,
|
||||
Tag
|
||||
Typography
|
||||
} from 'antd'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import IdText from '../common/IdText'
|
||||
@ -24,9 +24,15 @@ import XMarkIcon from '../../Icons/XMarkIcon'
|
||||
import CheckIcon from '../../Icons/CheckIcon'
|
||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||
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 NoteTypeIcon from '../../Icons/NoteTypeIcon'
|
||||
import BoolDisplay from '../common/BoolDisplay'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const NoteTypes = () => {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
@ -34,6 +40,7 @@ const NoteTypes = () => {
|
||||
const [newNoteTypeOpen, setNewNoteTypeOpen] = useState(false)
|
||||
const tableRef = useRef()
|
||||
const { authenticated } = useContext(AuthContext)
|
||||
const [viewMode, setViewMode] = useViewMode('NoteTypes')
|
||||
|
||||
const getFilterDropdown = ({
|
||||
setSelectedKeys,
|
||||
@ -114,8 +121,7 @@ const NoteTypes = () => {
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '',
|
||||
dataIndex: '',
|
||||
title: <NoteTypeIcon />,
|
||||
key: 'icon',
|
||||
width: 40,
|
||||
fixed: 'left',
|
||||
@ -172,18 +178,15 @@ const NoteTypes = () => {
|
||||
dataIndex: 'color',
|
||||
key: 'color',
|
||||
width: 120,
|
||||
render: (color) => <Badge color={color} text={color} />
|
||||
render: (color) =>
|
||||
color ? <Badge color={color} text={color} /> : <Text>n/a</Text>
|
||||
},
|
||||
{
|
||||
title: 'Active',
|
||||
dataIndex: 'isActive',
|
||||
key: 'isActive',
|
||||
dataIndex: 'active',
|
||||
key: 'active',
|
||||
width: 100,
|
||||
render: (isActive) => (
|
||||
<Tag color={isActive ? 'success' : 'error'}>
|
||||
{isActive ? 'Yes' : 'No'}
|
||||
</Tag>
|
||||
),
|
||||
render: (active) => <BoolDisplay value={active} yesNo={true} />,
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
@ -221,7 +224,7 @@ const NoteTypes = () => {
|
||||
key: 'actions',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (text, record) => {
|
||||
render: (record) => {
|
||||
return (
|
||||
<Space gap='small'>
|
||||
<Button
|
||||
@ -290,12 +293,21 @@ const NoteTypes = () => {
|
||||
<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}/notetypes`}
|
||||
authenticated={authenticated}
|
||||
cards={viewMode === 'cards'}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -15,11 +15,11 @@ import {
|
||||
ColorPicker,
|
||||
Switch,
|
||||
Badge,
|
||||
Checkbox,
|
||||
Tag
|
||||
Checkbox
|
||||
} from 'antd'
|
||||
|
||||
import config from '../../../../config'
|
||||
import BoolDisplay from '../../common/BoolDisplay'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
@ -73,10 +73,10 @@ const NewNoteType = ({ onOk, reset }) => {
|
||||
{
|
||||
key: 'active',
|
||||
label: 'Active',
|
||||
children: newNoteTypeFormValues.active ? (
|
||||
<Tag color={'success'}>Yes</Tag>
|
||||
children: newNoteTypeFormValues ? (
|
||||
<BoolDisplay value={newNoteTypeFormValues.active} yesNo={true} />
|
||||
) : (
|
||||
<Tag color={'error'}>No</Tag>
|
||||
<Text>n/a</Text>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
@ -15,12 +15,11 @@ import {
|
||||
Switch,
|
||||
ColorPicker,
|
||||
Checkbox,
|
||||
Tag,
|
||||
Dropdown,
|
||||
Popover,
|
||||
Badge
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
||||
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||
import IdText from '../../common/IdText'
|
||||
import TimeDisplay from '../../common/TimeDisplay'
|
||||
import ReloadIcon from '../../../Icons/ReloadIcon'
|
||||
@ -31,8 +30,11 @@ import useCollapseState from '../../hooks/useCollapseState'
|
||||
import AuditLogTable from '../../common/AuditLogTable'
|
||||
|
||||
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 [noteTypeData, setNoteTypeData] = useState(null)
|
||||
@ -224,24 +226,21 @@ const NoteTypeInfo = () => {
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.info ? ['1'] : []}
|
||||
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
<CaretLeftOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '9px' }}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
)}
|
||||
className='no-h-padding-collapse no-t-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex
|
||||
align='center'
|
||||
justify='space-between'
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Flex align='center' gap={'small'}>
|
||||
<InfoCircleIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Note Type Information
|
||||
</Title>
|
||||
@ -337,7 +336,7 @@ const NoteTypeInfo = () => {
|
||||
text={noteTypeData.color}
|
||||
/>
|
||||
) : (
|
||||
'No color set'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
@ -350,10 +349,13 @@ const NoteTypeInfo = () => {
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
) : noteTypeData?.active ? (
|
||||
<Tag color='success'>Yes</Tag>
|
||||
) : noteTypeData ? (
|
||||
<BoolDisplay
|
||||
value={noteTypeData.active}
|
||||
yesNo={true}
|
||||
/>
|
||||
) : (
|
||||
<Tag color='error'>No</Tag>
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
@ -364,13 +366,13 @@ const NoteTypeInfo = () => {
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.auditLogs ? ['2'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('auditLogs', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
<CaretLeftOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
@ -379,9 +381,12 @@ const NoteTypeInfo = () => {
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'small'}>
|
||||
<AuditLogIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Audit Logs
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='2'
|
||||
>
|
||||
|
||||
@ -28,6 +28,9 @@ import XMarkIcon from '../../Icons/XMarkIcon'
|
||||
import CheckIcon from '../../Icons/CheckIcon'
|
||||
import useColumnVisibility from '../hooks/useColumnVisibility'
|
||||
import TimeDisplay from '../common/TimeDisplay'
|
||||
import GridIcon from '../../Icons/GridIcon'
|
||||
import ListIcon from '../../Icons/ListIcon'
|
||||
import useViewMode from '../hooks/useViewMode'
|
||||
|
||||
import config from '../../../config'
|
||||
|
||||
@ -39,16 +42,16 @@ const Parts = () => {
|
||||
const [newProductOpen, setNewProductOpen] = useState(false)
|
||||
const tableRef = useRef()
|
||||
const { authenticated } = useContext(AuthContext)
|
||||
const [viewMode, setViewMode] = useViewMode('Parts')
|
||||
|
||||
// Column definitions
|
||||
const columns = [
|
||||
{
|
||||
title: '',
|
||||
dataIndex: '',
|
||||
key: '',
|
||||
title: <PartIcon />,
|
||||
key: 'icon',
|
||||
width: 40,
|
||||
fixed: 'left',
|
||||
render: () => <PartIcon></PartIcon>
|
||||
render: () => <PartIcon />
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
@ -84,7 +87,7 @@ const Parts = () => {
|
||||
title: 'Product Name',
|
||||
key: 'productName',
|
||||
width: 200,
|
||||
render: (record) => <Text>{record.product.name}</Text>,
|
||||
render: (record) => <Text>{record?.product?.name}</Text>,
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
@ -107,7 +110,7 @@ const Parts = () => {
|
||||
width: 180,
|
||||
render: (record) => (
|
||||
<IdText
|
||||
id={record.product._id}
|
||||
id={record?.product?._id}
|
||||
type={'product'}
|
||||
longId={false}
|
||||
showHyperlink={true}
|
||||
@ -147,7 +150,7 @@ const Parts = () => {
|
||||
key: 'actions',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (text, record) => {
|
||||
render: (record) => {
|
||||
return (
|
||||
<Space gap='small'>
|
||||
<Button
|
||||
@ -298,12 +301,21 @@ const Parts = () => {
|
||||
<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}/parts`}
|
||||
authenticated={authenticated}
|
||||
cards={viewMode === 'cards'}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -16,9 +16,11 @@ import {
|
||||
InputNumber,
|
||||
Switch,
|
||||
Tag,
|
||||
Collapse
|
||||
Collapse,
|
||||
Dropdown,
|
||||
Popover
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
||||
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||
import IdText from '../../common/IdText.jsx'
|
||||
import { StlViewer } from 'react-stl-viewer'
|
||||
import ReloadIcon from '../../../Icons/ReloadIcon'
|
||||
@ -27,10 +29,17 @@ import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
|
||||
import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
||||
import useCollapseState from '../../hooks/useCollapseState'
|
||||
import TimeDisplay from '../../common/TimeDisplay.jsx'
|
||||
import AuditLogTable from '../../common/AuditLogTable'
|
||||
import DashboardNotes from '../../common/DashboardNotes'
|
||||
|
||||
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 [partData, setPartData] = useState(null)
|
||||
@ -43,7 +52,9 @@ const PartInfo = () => {
|
||||
const [useGlobalPricing, setUseGlobalPricing] = useState(true)
|
||||
const [collapseState, updateCollapseState] = useCollapseState('PartInfo', {
|
||||
info: true,
|
||||
preview: true
|
||||
preview: true,
|
||||
notes: true,
|
||||
auditLogs: true
|
||||
})
|
||||
|
||||
const [partForm] = Form.useForm()
|
||||
@ -178,9 +189,16 @@ const PartInfo = () => {
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
// Reset form values to original data
|
||||
if (partData) {
|
||||
partForm.setFieldsValue({
|
||||
name: partData?.name || ''
|
||||
name: partData.name || '',
|
||||
price: partData.price || null,
|
||||
margin: partData.margin || null,
|
||||
marginOrPrice: partData.marginOrPrice,
|
||||
useGlobalPricing: partData.useGlobalPricing
|
||||
})
|
||||
}
|
||||
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 (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
||||
</div>
|
||||
<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 || !partData) {
|
||||
if (error) {
|
||||
return (
|
||||
<Space
|
||||
direction='vertical'
|
||||
@ -236,32 +288,26 @@ const PartInfo = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
<>
|
||||
{contextHolder}
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
activeKey={collapseState.info ? ['1'] : []}
|
||||
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '9px' }}
|
||||
/>
|
||||
)}
|
||||
className='no-h-padding-collapse no-t-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex
|
||||
align='center'
|
||||
justify='space-between'
|
||||
style={{ width: '100%' }}
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Part Information
|
||||
</Title>
|
||||
<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 ? (
|
||||
<>
|
||||
@ -270,6 +316,7 @@ const PartInfo = () => {
|
||||
type='primary'
|
||||
onClick={updateInfo}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
icon={<XMarkIcon />}
|
||||
@ -282,6 +329,40 @@ const PartInfo = () => {
|
||||
)}
|
||||
</Space>
|
||||
</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'
|
||||
>
|
||||
@ -295,10 +376,14 @@ const PartInfo = () => {
|
||||
}))
|
||||
}
|
||||
initialValues={{
|
||||
name: partData.name || '',
|
||||
version: partData.version || '',
|
||||
tags: partData.tags || []
|
||||
name: partData?.name || '',
|
||||
version: partData?.version || '',
|
||||
tags: partData?.tags || []
|
||||
}}
|
||||
>
|
||||
<Spin
|
||||
indicator={<LoadingOutlined />}
|
||||
spinning={fetchLoading}
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
@ -312,14 +397,21 @@ const PartInfo = () => {
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label='ID' span={1}>
|
||||
{partData.id ? (
|
||||
{partData?.id ? (
|
||||
<IdText id={partData.id} type='part'></IdText>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<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 label='Name' span={1}>
|
||||
@ -340,26 +432,41 @@ const PartInfo = () => {
|
||||
>
|
||||
<Input placeholder='Enter product name' />
|
||||
</Form.Item>
|
||||
) : partData?.name ? (
|
||||
<Text>{partData.name}</Text>
|
||||
) : (
|
||||
partData.name || 'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<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 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 label='Product ID' span={1}>
|
||||
{(
|
||||
{partData?.product?._id ? (
|
||||
<IdText
|
||||
id={partData.product._id}
|
||||
type={'product'}
|
||||
showHyperlink={true}
|
||||
/>
|
||||
) || 'n/a'}
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
label={!marginOrPrice ? 'Margin' : 'Price'}
|
||||
@ -412,16 +519,16 @@ const PartInfo = () => {
|
||||
<Checkbox>Price</Checkbox>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
) : partData.margin &&
|
||||
) : partData?.margin &&
|
||||
marginOrPrice == false &&
|
||||
partData.useGlobalPricing == false ? (
|
||||
partData.margin + '%'
|
||||
) : partData.price &&
|
||||
partData?.useGlobalPricing == false ? (
|
||||
<Text>{partData.margin + '%'}</Text>
|
||||
) : partData?.price &&
|
||||
marginOrPrice == true &&
|
||||
partData.useGlobalPricing == false ? (
|
||||
'£' + partData.price
|
||||
partData?.useGlobalPricing == false ? (
|
||||
<Text>{'£' + partData.price}</Text>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Global Pricing'>
|
||||
@ -439,48 +546,57 @@ const PartInfo = () => {
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
) : partData.useGlobalPricing == true ? (
|
||||
<Tag color='success' icon={<CheckIcon />}>
|
||||
Yes
|
||||
</Tag>
|
||||
) : partData.useGlobalPricing == false ? (
|
||||
<Tag icon={<XMarkIcon />}>No</Tag>
|
||||
) : partData ? (
|
||||
<BoolDisplay
|
||||
value={partData.useGlobalPricing}
|
||||
yesNo={true}
|
||||
/>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Version' span={1}>
|
||||
{partData.version || 'n/a'}
|
||||
{partData?.version ? (
|
||||
<Text>{partData.version}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Tags'>
|
||||
{partData.tags &&
|
||||
{partData?.tags && partData.tags.length > 0 ? (
|
||||
partData.tags.map((tag, index) => (
|
||||
<Tag key={index}>{tag}</Tag>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Spin>
|
||||
</Form>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.preview ? ['2'] : []}
|
||||
onChange={(keys) => updateCollapseState('preview', keys.length > 0)}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('preview', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<PartIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Part Preview
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='2'
|
||||
>
|
||||
@ -496,7 +612,9 @@ const PartInfo = () => {
|
||||
}}
|
||||
>
|
||||
<Space direction='vertical' align='center'>
|
||||
<XMarkIcon style={{ fontSize: '24px', color: '#ff4d4f' }} />
|
||||
<XMarkIcon
|
||||
style={{ fontSize: '24px', color: '#ff4d4f' }}
|
||||
/>
|
||||
<Typography.Text type='danger'>
|
||||
{stlLoadError}
|
||||
</Typography.Text>
|
||||
@ -518,8 +636,71 @@ const PartInfo = () => {
|
||||
</Card>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.notes ? ['notes'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('notes', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<NoteIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Notes
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='notes'
|
||||
>
|
||||
<Card>
|
||||
<DashboardNotes _id={partId} />
|
||||
</Card>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('auditLogs', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<AuditLogIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Audit Logs
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='auditLogs'
|
||||
>
|
||||
<AuditLogTable
|
||||
items={partData?.auditLogs || []}
|
||||
loading={fetchLoading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Flex>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -28,6 +28,9 @@ import ReloadIcon from '../../Icons/ReloadIcon'
|
||||
import XMarkIcon from '../../Icons/XMarkIcon'
|
||||
import CheckIcon from '../../Icons/CheckIcon'
|
||||
import useColumnVisibility from '../hooks/useColumnVisibility'
|
||||
import GridIcon from '../../Icons/GridIcon'
|
||||
import ListIcon from '../../Icons/ListIcon'
|
||||
import useViewMode from '../hooks/useViewMode'
|
||||
|
||||
import config from '../../../config'
|
||||
|
||||
@ -37,6 +40,7 @@ const Products = () => {
|
||||
const [newProductOpen, setNewProductOpen] = useState(false)
|
||||
const tableRef = useRef()
|
||||
const { authenticated } = useContext(AuthContext)
|
||||
const [viewMode, setViewMode] = useViewMode('Products')
|
||||
|
||||
const getProductActionItems = (id) => {
|
||||
return {
|
||||
@ -63,12 +67,11 @@ const Products = () => {
|
||||
// Column definitions
|
||||
const columns = [
|
||||
{
|
||||
title: '',
|
||||
dataIndex: '',
|
||||
key: '',
|
||||
title: <ProductIcon />,
|
||||
key: 'icon',
|
||||
width: 40,
|
||||
fixed: 'left',
|
||||
render: () => <ProductIcon></ProductIcon>
|
||||
render: () => <ProductIcon />
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
@ -114,7 +117,7 @@ const Products = () => {
|
||||
propertyName: 'ID'
|
||||
}),
|
||||
onFilter: (value, record) =>
|
||||
record._id.toLowerCase().includes(value.toLowerCase()),
|
||||
record?._id.toLowerCase().includes(value.toLowerCase()),
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
@ -212,7 +215,7 @@ const Products = () => {
|
||||
key: 'actions',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (text, record) => {
|
||||
render: (record) => {
|
||||
return (
|
||||
<Space gap='small'>
|
||||
<Button
|
||||
@ -341,12 +344,21 @@ const Products = () => {
|
||||
<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}/products`}
|
||||
authenticated={authenticated}
|
||||
cards={viewMode === 'cards'}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -14,9 +14,12 @@ import {
|
||||
Tag,
|
||||
Checkbox,
|
||||
InputNumber,
|
||||
Collapse
|
||||
Collapse,
|
||||
Dropdown,
|
||||
Popover,
|
||||
Card
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
||||
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||
import IdText from '../../common/IdText.jsx'
|
||||
import VendorSelect from '../../common/VendorSelect.jsx'
|
||||
import PartsTable from '../../common/PartsTable.jsx'
|
||||
@ -27,10 +30,16 @@ import EditIcon from '../../../Icons/EditIcon.jsx'
|
||||
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
|
||||
import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
||||
import TimeDisplay from '../../common/TimeDisplay.jsx'
|
||||
import AuditLogTable from '../../common/AuditLogTable'
|
||||
import DashboardNotes from '../../common/DashboardNotes'
|
||||
|
||||
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 [productData, setProductData] = useState(null)
|
||||
@ -44,7 +53,9 @@ const ProductInfo = () => {
|
||||
const [marginOrPrice, setMarginOrPrice] = useState(false)
|
||||
const [collapseState, updateCollapseState] = useCollapseState('ProductInfo', {
|
||||
info: true,
|
||||
parts: true
|
||||
parts: true,
|
||||
notes: true,
|
||||
auditLogs: true
|
||||
})
|
||||
|
||||
const [productForm] = Form.useForm()
|
||||
@ -125,17 +136,20 @@ const ProductInfo = () => {
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
// Reset form values to original data
|
||||
if (productData) {
|
||||
productForm.setFieldsValue({
|
||||
name: productData?.name || '',
|
||||
vendor: productData?.vendor || { id: null, name: '' },
|
||||
version: productData?.version || '',
|
||||
tags: productData?.tags || [],
|
||||
cost: productData?.cost || null,
|
||||
price: productData?.price || null,
|
||||
margin: productData?.margin || null,
|
||||
marginOrPrice: productData?.marginOrPrice || null
|
||||
name: productData.name || '',
|
||||
vendor: productData.vendor || { id: null, name: '' },
|
||||
version: productData.version || '',
|
||||
tags: productData.tags || [],
|
||||
cost: productData.cost || null,
|
||||
price: productData.price || null,
|
||||
margin: productData.margin || null,
|
||||
marginOrPrice: productData.marginOrPrice || null
|
||||
})
|
||||
setMarginOrPrice(productData?.marginOrPrice)
|
||||
setMarginOrPrice(productData.marginOrPrice)
|
||||
}
|
||||
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 (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
||||
</div>
|
||||
<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 || !productData) {
|
||||
if (error) {
|
||||
return (
|
||||
<Space
|
||||
direction='vertical'
|
||||
@ -192,32 +240,26 @@ const ProductInfo = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
<>
|
||||
{contextHolder}
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
activeKey={collapseState.info ? ['1'] : []}
|
||||
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '9px' }}
|
||||
/>
|
||||
)}
|
||||
className='no-h-padding-collapse no-t-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex
|
||||
align='center'
|
||||
justify='space-between'
|
||||
style={{ width: '100%' }}
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Product Information
|
||||
</Title>
|
||||
<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 ? (
|
||||
<>
|
||||
@ -226,6 +268,7 @@ const ProductInfo = () => {
|
||||
type='primary'
|
||||
onClick={updateInfo}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
icon={<XMarkIcon />}
|
||||
@ -238,6 +281,40 @@ const ProductInfo = () => {
|
||||
)}
|
||||
</Space>
|
||||
</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'
|
||||
>
|
||||
@ -251,11 +328,15 @@ const ProductInfo = () => {
|
||||
}))
|
||||
}
|
||||
initialValues={{
|
||||
name: productData.name || '',
|
||||
vendor: productData.vendor || { id: null, name: '' },
|
||||
version: productData.version || '',
|
||||
tags: productData.tags || []
|
||||
name: productData?.name || '',
|
||||
vendor: productData?.vendor || { id: null, name: '' },
|
||||
version: productData?.version || '',
|
||||
tags: productData?.tags || []
|
||||
}}
|
||||
>
|
||||
<Spin
|
||||
indicator={<LoadingOutlined />}
|
||||
spinning={fetchLoading}
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
@ -269,18 +350,22 @@ const ProductInfo = () => {
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label='ID' span={1}>
|
||||
{productData.id ? (
|
||||
{productData?.id ? (
|
||||
<IdText id={productData.id} type='product'></IdText>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Created At'>
|
||||
{productData?.createdAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={productData.createdAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Name' span={1}>
|
||||
@ -301,16 +386,22 @@ const ProductInfo = () => {
|
||||
>
|
||||
<Input placeholder='Enter product name' />
|
||||
</Form.Item>
|
||||
) : productData?.name ? (
|
||||
<Text>{productData.name}</Text>
|
||||
) : (
|
||||
productData.name || 'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Updated At'>
|
||||
{productData?.updatedAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={productData.updatedAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Vendor'>
|
||||
@ -318,23 +409,32 @@ const ProductInfo = () => {
|
||||
<Form.Item
|
||||
name='vendor'
|
||||
rules={[
|
||||
{ required: true, message: 'Please enter a vendor' }
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a vendor'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<VendorSelect />
|
||||
</Form.Item>
|
||||
) : productData?.vendor?.name ? (
|
||||
<Text>{productData.vendor.name}</Text>
|
||||
) : (
|
||||
productData.vendor.name || 'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Vendor ID'>
|
||||
{productData?.vendor?.id ? (
|
||||
<IdText
|
||||
id={productData.vendor.id}
|
||||
type={'vendor'}
|
||||
showHyperlink={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item
|
||||
@ -388,12 +488,12 @@ const ProductInfo = () => {
|
||||
<Checkbox>Price</Checkbox>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
) : productData.margin && marginOrPrice == false ? (
|
||||
productData.margin + '%'
|
||||
) : productData.price && marginOrPrice == true ? (
|
||||
'£' + productData.price
|
||||
) : productData?.margin && marginOrPrice == false ? (
|
||||
<Text>{productData.margin + '%'}</Text>
|
||||
) : productData?.price && marginOrPrice == true ? (
|
||||
<Text>{'£' + productData.price}</Text>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
@ -402,10 +502,10 @@ const ProductInfo = () => {
|
||||
<Form.Item name='version' style={{ margin: 0 }}>
|
||||
<Input placeholder='Enter version' />
|
||||
</Form.Item>
|
||||
) : productData.version ? (
|
||||
) : productData?.version ? (
|
||||
<Tag>{productData.version}</Tag>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
@ -417,7 +517,7 @@ const ProductInfo = () => {
|
||||
wrap
|
||||
style={{ marginBottom: 4, maxWidth: '300px' }}
|
||||
>
|
||||
{productData.tags.map((tag) => (
|
||||
{productData?.tags?.map((tag) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
color='blue'
|
||||
@ -433,11 +533,18 @@ const ProductInfo = () => {
|
||||
<Form.Item name='newTag' noStyle>
|
||||
<Input placeholder='Add new tag' />
|
||||
</Form.Item>
|
||||
<Button onClick={handleTagAdd} icon={<PlusIcon />} />
|
||||
<Button
|
||||
onClick={handleTagAdd}
|
||||
icon={<PlusIcon />}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
) : productData.tags?.length > 0 ? (
|
||||
<Space size={[0, 2]} wrap style={{ maxWidth: '300px' }}>
|
||||
) : productData?.tags?.length > 0 ? (
|
||||
<Space
|
||||
size={[0, 2]}
|
||||
wrap
|
||||
style={{ maxWidth: '300px' }}
|
||||
>
|
||||
{productData.tags.map((tag, index) => (
|
||||
<Tag key={index} color='blue'>
|
||||
{tag}
|
||||
@ -445,40 +552,106 @@ const ProductInfo = () => {
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Spin>
|
||||
</Form>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.parts ? ['2'] : []}
|
||||
onChange={(keys) => updateCollapseState('parts', keys.length > 0)}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('parts', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<ProductIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Product Parts
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='2'
|
||||
>
|
||||
<PartsTable data={productData.parts} />
|
||||
<PartsTable data={productData?.parts || []} />
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.notes ? ['notes'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('notes', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<NoteIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Notes
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='notes'
|
||||
>
|
||||
<Card>
|
||||
<DashboardNotes _id={productId} />
|
||||
</Card>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('auditLogs', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<AuditLogIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Audit Logs
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='auditLogs'
|
||||
>
|
||||
<AuditLogTable
|
||||
items={productData?.auditLogs || []}
|
||||
loading={fetchLoading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Flex>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
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 useCollapseState from '../hooks/useCollapseState'
|
||||
|
||||
@ -53,13 +53,13 @@ const Settings = () => {
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.appearance ? ['1'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('appearance', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
<CaretLeftOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
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 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'
|
||||
|
||||
@ -37,6 +40,7 @@ const Vendors = () => {
|
||||
const [newVendorOpen, setNewVendorOpen] = useState(false)
|
||||
const tableRef = useRef()
|
||||
const { authenticated } = useContext(AuthContext)
|
||||
const [viewMode, setViewMode] = useViewMode('Vendors')
|
||||
|
||||
const getFilterDropdown = ({
|
||||
setSelectedKeys,
|
||||
@ -117,9 +121,8 @@ const Vendors = () => {
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '',
|
||||
dataIndex: '',
|
||||
key: '',
|
||||
title: <VendorIcon />,
|
||||
key: 'icon',
|
||||
width: 40,
|
||||
fixed: 'left',
|
||||
render: () => <VendorIcon />
|
||||
@ -282,7 +285,7 @@ const Vendors = () => {
|
||||
key: 'actions',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (text, record) => {
|
||||
render: (record) => {
|
||||
return (
|
||||
<Space gap='small'>
|
||||
<Button
|
||||
@ -351,12 +354,21 @@ const Vendors = () => {
|
||||
<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}/vendors`}
|
||||
authenticated={authenticated}
|
||||
cards={viewMode === 'cards'}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -11,12 +11,16 @@ import {
|
||||
Flex,
|
||||
Form,
|
||||
Input,
|
||||
Collapse
|
||||
Collapse,
|
||||
Dropdown,
|
||||
Popover,
|
||||
Card,
|
||||
Checkbox
|
||||
} from 'antd'
|
||||
import {
|
||||
LoadingOutlined,
|
||||
ExportOutlined,
|
||||
CaretRightOutlined
|
||||
CaretLeftOutlined
|
||||
} from '@ant-design/icons'
|
||||
import IdText from '../../common/IdText'
|
||||
import CountrySelect from '../../common/CountrySelect'
|
||||
@ -27,10 +31,15 @@ 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 } = Typography
|
||||
const { Title, Link, Text } = Typography
|
||||
|
||||
const VendorInfo = () => {
|
||||
const [vendorData, setVendorData] = useState(null)
|
||||
@ -43,7 +52,9 @@ const VendorInfo = () => {
|
||||
const [form] = Form.useForm()
|
||||
const [fetchLoading, setFetchLoading] = useState(true)
|
||||
const [collapseState, updateCollapseState] = useCollapseState('VendorInfo', {
|
||||
info: true
|
||||
info: true,
|
||||
notes: true,
|
||||
auditLogs: true
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@ -93,14 +104,17 @@ const VendorInfo = () => {
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
// Reset form values to original data
|
||||
if (vendorData) {
|
||||
form.setFieldsValue({
|
||||
name: vendorData?.name || '',
|
||||
website: vendorData?.website || '',
|
||||
contact: vendorData?.contact || '',
|
||||
country: vendorData?.country || '',
|
||||
phone: vendorData?.phone || '',
|
||||
email: vendorData?.email || ''
|
||||
name: vendorData.name || '',
|
||||
website: vendorData.website || '',
|
||||
contact: vendorData.contact || '',
|
||||
country: vendorData.country || '',
|
||||
phone: vendorData.phone || '',
|
||||
email: vendorData.email || ''
|
||||
})
|
||||
}
|
||||
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 (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
||||
</div>
|
||||
<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 || !vendorData) {
|
||||
if (error) {
|
||||
return (
|
||||
<Space
|
||||
direction='vertical'
|
||||
@ -154,32 +201,26 @@ const VendorInfo = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
<>
|
||||
{contextHolder}
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
activeKey={collapseState.info ? ['1'] : []}
|
||||
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '9px' }}
|
||||
/>
|
||||
)}
|
||||
className='no-h-padding-collapse no-t-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex
|
||||
align='center'
|
||||
justify='space-between'
|
||||
style={{ width: '100%' }}
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Vendor Information
|
||||
</Title>
|
||||
<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 ? (
|
||||
<>
|
||||
@ -188,6 +229,7 @@ const VendorInfo = () => {
|
||||
type='primary'
|
||||
onClick={updateInfo}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
icon={<XMarkIcon />}
|
||||
@ -200,10 +242,51 @@ const VendorInfo = () => {
|
||||
)}
|
||||
</Space>
|
||||
</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'
|
||||
>
|
||||
<Form form={form} layout='vertical'>
|
||||
<Spin
|
||||
indicator={<LoadingOutlined />}
|
||||
spinning={fetchLoading}
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
column={{
|
||||
@ -216,13 +299,21 @@ const VendorInfo = () => {
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label='ID'>
|
||||
{vendorData?._id ? (
|
||||
<IdText id={vendorData._id} type='vendor' />
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Created At'>
|
||||
{vendorData?.createdAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={vendorData.createdAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Name'>
|
||||
@ -243,16 +334,22 @@ const VendorInfo = () => {
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : vendorData?.name ? (
|
||||
<Text>{vendorData.name}</Text>
|
||||
) : (
|
||||
vendorData.name
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Updated At'>
|
||||
{vendorData?.updatedAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={vendorData.updatedAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Website'>
|
||||
@ -260,17 +357,21 @@ const VendorInfo = () => {
|
||||
<Form.Item
|
||||
name='website'
|
||||
rules={[
|
||||
{ type: 'url', message: 'Please enter a valid URL' },
|
||||
{
|
||||
type: 'url',
|
||||
message: 'Please enter a valid URL'
|
||||
},
|
||||
{
|
||||
max: 200,
|
||||
message: 'Website URL cannot exceed 200 characters'
|
||||
message:
|
||||
'Website URL cannot exceed 200 characters'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : vendorData.website ? (
|
||||
) : vendorData?.website ? (
|
||||
<Link
|
||||
href={vendorData.website}
|
||||
target='_blank'
|
||||
@ -280,19 +381,21 @@ const VendorInfo = () => {
|
||||
<ExportOutlined />
|
||||
</Link>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Country'>
|
||||
{isEditing ? (
|
||||
<Form.Item name='country' style={{ margin: 0 }}>
|
||||
<CountrySelect countryCode={vendorData.country} />
|
||||
<CountrySelect
|
||||
countryCode={vendorData?.country}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : vendorData.country ? (
|
||||
) : vendorData?.country ? (
|
||||
<CountryDisplay countryCode={vendorData.country} />
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
@ -303,17 +406,18 @@ const VendorInfo = () => {
|
||||
rules={[
|
||||
{
|
||||
max: 200,
|
||||
message: 'Contact info cannot exceed 200 characters'
|
||||
message:
|
||||
'Contact info cannot exceed 200 characters'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : vendorData.contact ? (
|
||||
vendorData.contact
|
||||
) : vendorData?.contact ? (
|
||||
<Text>{vendorData.contact}</Text>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
@ -331,10 +435,10 @@ const VendorInfo = () => {
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : vendorData.phone ? (
|
||||
vendorData.phone
|
||||
) : vendorData?.phone ? (
|
||||
<Text>{vendorData.phone}</Text>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
@ -352,21 +456,85 @@ const VendorInfo = () => {
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : vendorData.email ? (
|
||||
) : vendorData?.email ? (
|
||||
<Link href={`mailto:${vendorData.email}`}>
|
||||
{vendorData.email + ' '}
|
||||
<ExportOutlined />
|
||||
</Link>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Spin>
|
||||
</Form>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.notes ? ['notes'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('notes', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<NoteIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Notes
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='notes'
|
||||
>
|
||||
<Card>
|
||||
<DashboardNotes _id={vendorId} />
|
||||
</Card>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('auditLogs', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<AuditLogIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Audit Logs
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='auditLogs'
|
||||
>
|
||||
<AuditLogTable
|
||||
items={vendorData?.auditLogs || []}
|
||||
loading={fetchLoading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Flex>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -31,6 +31,9 @@ import XMarkIcon from '../../Icons/XMarkIcon'
|
||||
import CheckIcon from '../../Icons/CheckIcon'
|
||||
import TimeDisplay from '../common/TimeDisplay'
|
||||
import DashboardTable from '../common/DashboardTable'
|
||||
import ListIcon from '../../Icons/ListIcon'
|
||||
import GridIcon from '../../Icons/GridIcon'
|
||||
import useViewMode from '../hooks/useViewMode'
|
||||
|
||||
import config from '../../../config'
|
||||
|
||||
@ -42,6 +45,7 @@ const GCodeFiles = () => {
|
||||
const [newGCodeFileOpen, setNewGCodeFileOpen] = useState(false)
|
||||
const [showDeleted, setShowDeleted] = useState(false)
|
||||
const tableRef = useRef()
|
||||
const [viewMode, setViewMode] = useViewMode('GCodeFiles')
|
||||
|
||||
const getFilterDropdown = ({
|
||||
setSelectedKeys,
|
||||
@ -82,12 +86,11 @@ const GCodeFiles = () => {
|
||||
// Column definitions
|
||||
const columns = [
|
||||
{
|
||||
title: '',
|
||||
dataIndex: '',
|
||||
key: '',
|
||||
title: <GCodeFileIcon />,
|
||||
key: 'icon',
|
||||
width: 40,
|
||||
fixed: 'left',
|
||||
render: () => <GCodeFileIcon></GCodeFileIcon>
|
||||
render: () => <GCodeFileIcon />
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
@ -121,12 +124,14 @@ const GCodeFiles = () => {
|
||||
},
|
||||
{
|
||||
title: 'Filament',
|
||||
dataIndex: ['filament', 'name'],
|
||||
key: 'filament',
|
||||
width: 200,
|
||||
render: (text, record) => {
|
||||
render: (record) => {
|
||||
return (
|
||||
<Badge color={record.filament.color} text={record.filament.name} />
|
||||
<Badge
|
||||
color={record?.filament?.color}
|
||||
text={record?.filament?.name}
|
||||
/>
|
||||
)
|
||||
},
|
||||
filterDropdown: ({
|
||||
@ -151,17 +156,16 @@ const GCodeFiles = () => {
|
||||
key: 'cost',
|
||||
width: 120,
|
||||
render: (cost) => {
|
||||
return '£' + cost.toFixed(2)
|
||||
return '£' + cost?.toFixed(2)
|
||||
},
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
title: 'Print Time',
|
||||
key: 'estimatedPrintingTimeNormalMode',
|
||||
dataIndex: ['gcodeFileInfo', 'estimatedPrintingTimeNormalMode'],
|
||||
width: 140,
|
||||
render: (text, record) => {
|
||||
return `${record.gcodeFileInfo.estimatedPrintingTimeNormalMode}`
|
||||
render: (record) => {
|
||||
return `${record?.gcodeFileInfo?.estimatedPrintingTimeNormalMode}`
|
||||
},
|
||||
sorter: true
|
||||
},
|
||||
@ -198,7 +202,7 @@ const GCodeFiles = () => {
|
||||
key: 'actions',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (text, record) => {
|
||||
render: (record) => {
|
||||
return (
|
||||
<Space gap='small'>
|
||||
<Button
|
||||
@ -353,6 +357,7 @@ const GCodeFiles = () => {
|
||||
<>
|
||||
<Flex vertical={'true'} gap='large'>
|
||||
{contextHolder}
|
||||
<Flex justify={'space-between'}>
|
||||
<Space>
|
||||
<Dropdown menu={actionItems}>
|
||||
<Button>Actions</Button>
|
||||
@ -365,12 +370,21 @@ const GCodeFiles = () => {
|
||||
<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}/gcodefiles`}
|
||||
authenticated={authenticated}
|
||||
cards={viewMode === 'cards'}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -13,9 +13,12 @@ import {
|
||||
Flex,
|
||||
Input,
|
||||
Card,
|
||||
Collapse
|
||||
Collapse,
|
||||
Dropdown,
|
||||
Popover,
|
||||
Checkbox
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
||||
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||
import IdText from '../../common/IdText.jsx'
|
||||
import { capitalizeFirstLetter } from '../../utils/Utils.js'
|
||||
import FilamentSelect from '../../common/FilamentSelect'
|
||||
@ -28,12 +31,19 @@ import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
|
||||
import CheckIcon from '../../../Icons/CheckIcon.jsx'
|
||||
|
||||
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 [gcodeFileData, setGCodeFileData] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [editLoading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const location = useLocation()
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
@ -99,7 +109,7 @@ const GCodeFileInfo = () => {
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const updateInfo = async () => {
|
||||
const updateGCodeFileInfo = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
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 (
|
||||
<Space
|
||||
direction='vertical'
|
||||
style={{ width: '100%', textAlign: 'center' }}
|
||||
<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)
|
||||
}}
|
||||
>
|
||||
<p>{error || 'GCodeFile not found'}</p>
|
||||
<Button icon={<ReloadIcon />} onClick={fetchGCodeFileDetails}>
|
||||
Retry
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
if (fetchLoading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
||||
</div>
|
||||
{section.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
<>
|
||||
{contextHolder}
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
activeKey={collapseState.info ? ['1'] : []}
|
||||
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '9px' }}
|
||||
/>
|
||||
)}
|
||||
className='no-h-padding-collapse no-t-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex
|
||||
align='center'
|
||||
justify='space-between'
|
||||
style={{ width: '100%' }}
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
GCode File Information
|
||||
</Title>
|
||||
<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}
|
||||
onClick={updateGCodeFileInfo}
|
||||
loading={editLoading}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
<Button
|
||||
icon={<XMarkIcon />}
|
||||
onClick={cancelEditing}
|
||||
disabled={loading}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@ -199,10 +236,48 @@ const GCodeFileInfo = () => {
|
||||
)}
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
{error ? (
|
||||
<Space
|
||||
direction='vertical'
|
||||
style={{ width: '100%', textAlign: 'center' }}
|
||||
>
|
||||
<p>{error || 'Print job not found'}</p>
|
||||
<Button icon={<ReloadIcon />} onClick={fetchGCodeFileDetails}>
|
||||
Retry
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.info ? ['info'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('info', keys.length > 0)
|
||||
}
|
||||
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'>
|
||||
<Spin
|
||||
spinning={fetchLoading}
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
column={{
|
||||
@ -215,17 +290,24 @@ const GCodeFileInfo = () => {
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label='ID' span={1}>
|
||||
{gcodeFileData.id ? (
|
||||
<IdText id={gcodeFileData.id} type='gcodeFile'></IdText>
|
||||
{gcodeFileData?._id ? (
|
||||
<IdText
|
||||
id={gcodeFileData._id}
|
||||
type='gcodefile'
|
||||
></IdText>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Created At'>
|
||||
{gcodeFileData?.createdAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={gcodeFileData.createdAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Name'>
|
||||
@ -246,29 +328,38 @@ const GCodeFileInfo = () => {
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : gcodeFileData?.name ? (
|
||||
<Text>{gcodeFileData.name}</Text>
|
||||
) : (
|
||||
gcodeFileData.name
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Updated At'>
|
||||
{gcodeFileData?.updatedAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={gcodeFileData.updatedAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Filament Name'>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name='filament'
|
||||
rules={[
|
||||
{ required: true, message: 'Please enter a filament' }
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a filament'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<FilamentSelect />
|
||||
</Form.Item>
|
||||
) : gcodeFileData.filament ? (
|
||||
) : gcodeFileData?.filament ? (
|
||||
<Space>
|
||||
<FilamentIcon />
|
||||
<Badge
|
||||
@ -277,142 +368,229 @@ const GCodeFileInfo = () => {
|
||||
/>
|
||||
</Space>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Filament ID'>
|
||||
{gcodeFileData.filament ? (
|
||||
{gcodeFileData?.filament ? (
|
||||
<IdText
|
||||
id={gcodeFileData.filament.id}
|
||||
type={'filament'}
|
||||
showHyperlink={true}
|
||||
/>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Est Print Time'>
|
||||
{gcodeFileData.gcodeFileInfo
|
||||
.estimatedPrintingTimeNormalMode || 'n/a'}
|
||||
{gcodeFileData?.gcodeFileInfo
|
||||
?.estimatedPrintingTimeNormalMode ? (
|
||||
<Text>
|
||||
{
|
||||
gcodeFileData.gcodeFileInfo
|
||||
.estimatedPrintingTimeNormalMode
|
||||
}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<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 label='Infill Density'>
|
||||
{(() => {
|
||||
if (gcodeFileData.gcodeFileInfo.sparseInfillDensity) {
|
||||
return gcodeFileData.gcodeFileInfo.sparseInfillDensity
|
||||
} else {
|
||||
return 'n/a'
|
||||
}
|
||||
})()}
|
||||
{gcodeFileData?.gcodeFileInfo?.sparseInfillDensity ? (
|
||||
<Text>
|
||||
{gcodeFileData.gcodeFileInfo.sparseInfillDensity}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Infill Pattern'>
|
||||
{(() => {
|
||||
if (gcodeFileData.gcodeFileInfo.sparseInfillPattern) {
|
||||
return capitalizeFirstLetter(
|
||||
{gcodeFileData?.gcodeFileInfo?.sparseInfillPattern ? (
|
||||
<Text>
|
||||
{capitalizeFirstLetter(
|
||||
gcodeFileData.gcodeFileInfo.sparseInfillPattern
|
||||
)
|
||||
} else {
|
||||
return 'n/a'
|
||||
}
|
||||
})()}
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Filament Used (mm)'>
|
||||
{(() => {
|
||||
if (gcodeFileData.gcodeFileInfo.filamentUsedMm) {
|
||||
return `${gcodeFileData.gcodeFileInfo.filamentUsedMm}mm`
|
||||
} else {
|
||||
return 'n/a'
|
||||
}
|
||||
})()}
|
||||
{gcodeFileData?.gcodeFileInfo?.filamentUsedMm ? (
|
||||
<Text>
|
||||
{gcodeFileData.gcodeFileInfo.filamentUsedMm}mm
|
||||
</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Filament Used (g)'>
|
||||
{(() => {
|
||||
if (gcodeFileData.gcodeFileInfo.filamentUsedG) {
|
||||
return `${gcodeFileData.gcodeFileInfo.filamentUsedG}g`
|
||||
} else {
|
||||
return 'n/a'
|
||||
}
|
||||
})()}
|
||||
{gcodeFileData?.gcodeFileInfo?.filamentUsedG ? (
|
||||
<Text>
|
||||
{gcodeFileData.gcodeFileInfo.filamentUsedG}g
|
||||
</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Hotend Temperature'>
|
||||
{(() => {
|
||||
if (gcodeFileData.gcodeFileInfo.nozzleTemperature) {
|
||||
return `${gcodeFileData.gcodeFileInfo.nozzleTemperature}°`
|
||||
} else {
|
||||
return 'n/a'
|
||||
}
|
||||
})()}
|
||||
{gcodeFileData?.gcodeFileInfo?.nozzleTemperature ? (
|
||||
<Text>
|
||||
{gcodeFileData.gcodeFileInfo.nozzleTemperature}°
|
||||
</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Bed Temperature'>
|
||||
{(() => {
|
||||
if (gcodeFileData.gcodeFileInfo.hotPlateTemp) {
|
||||
return `${gcodeFileData.gcodeFileInfo.hotPlateTemp}°`
|
||||
} else {
|
||||
return 'n/a'
|
||||
}
|
||||
})()}
|
||||
{gcodeFileData?.gcodeFileInfo?.hotPlateTemp ? (
|
||||
<Text>
|
||||
{gcodeFileData.gcodeFileInfo.hotPlateTemp}°
|
||||
</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Filament Profile'>
|
||||
{(() => {
|
||||
if (gcodeFileData.gcodeFileInfo.filamentSettingsId) {
|
||||
return `${gcodeFileData.gcodeFileInfo.filamentSettingsId.replaceAll('"', '')}`
|
||||
} else {
|
||||
return 'n/a'
|
||||
}
|
||||
})()}
|
||||
{gcodeFileData?.gcodeFileInfo?.filamentSettingsId ? (
|
||||
<Text>
|
||||
{gcodeFileData.gcodeFileInfo.filamentSettingsId.replaceAll(
|
||||
'"',
|
||||
''
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Print Profile'>
|
||||
{(() => {
|
||||
if (gcodeFileData.gcodeFileInfo.printSettingsId) {
|
||||
return `${gcodeFileData.gcodeFileInfo.printSettingsId.replaceAll('"', '')}`
|
||||
} else {
|
||||
return 'n/a'
|
||||
}
|
||||
})()}
|
||||
{gcodeFileData?.gcodeFileInfo?.printSettingsId ? (
|
||||
<Text>
|
||||
{gcodeFileData.gcodeFileInfo.printSettingsId.replaceAll(
|
||||
'"',
|
||||
''
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Spin>
|
||||
</Form>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
activeKey={collapseState.preview ? ['2'] : []}
|
||||
onChange={(keys) => updateCollapseState('preview', keys.length > 0)}
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.preview ? ['preview'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('preview', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<GCodeFileIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
GCode File Preview
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='2'
|
||||
key='preview'
|
||||
>
|
||||
<Spin spinning={fetchLoading} indicator={<LoadingOutlined />}>
|
||||
<Card styles={{ body: { padding: '10px' } }}>
|
||||
{gcodeFileData.gcodeFileInfo.thumbnail ? (
|
||||
{gcodeFileData?.gcodeFileInfo?.thumbnail ? (
|
||||
<img
|
||||
src={`data:image/png;base64,${gcodeFileData.gcodeFileInfo.thumbnail.data}`}
|
||||
alt='GCodeFile'
|
||||
style={{ maxWidth: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Card>
|
||||
</Spin>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.notes ? ['notes'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('notes', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<NoteIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Notes
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='notes'
|
||||
>
|
||||
<Card>
|
||||
<DashboardNotes _id={gcodeFileId} />
|
||||
</Card>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('auditLogs', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<AuditLogIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Audit Log
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='auditLogs'
|
||||
>
|
||||
<AuditLogTable
|
||||
items={gcodeFileData?.auditLogs || []}
|
||||
loading={fetchLoading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Flex>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -37,6 +37,9 @@ import PauseCircleIcon from '../../Icons/PauseCircleIcon.jsx'
|
||||
import XMarkCircleIcon from '../../Icons/XMarkCircleIcon.jsx'
|
||||
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon.jsx'
|
||||
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'
|
||||
|
||||
@ -49,6 +52,7 @@ const Jobs = () => {
|
||||
const navigate = useNavigate()
|
||||
const [newJobOpen, setNewJobOpen] = useState(false)
|
||||
const tableRef = useRef()
|
||||
const [viewMode, setViewMode] = useViewMode('Jobs')
|
||||
|
||||
const getFilterDropdown = ({
|
||||
setSelectedKeys,
|
||||
@ -89,20 +93,18 @@ const Jobs = () => {
|
||||
// Column definitions
|
||||
const columns = [
|
||||
{
|
||||
title: '',
|
||||
dataIndex: '',
|
||||
key: '',
|
||||
title: <JobIcon />,
|
||||
key: 'icon',
|
||||
width: 40,
|
||||
fixed: 'left',
|
||||
render: () => <JobIcon />
|
||||
},
|
||||
{
|
||||
title: 'GCode File Name',
|
||||
dataIndex: 'gcodeFile',
|
||||
key: 'gcodeFileName',
|
||||
width: 200,
|
||||
fixed: 'left',
|
||||
render: (gcodeFile) => <Text ellipsis>{gcodeFile.name}</Text>,
|
||||
render: (record) => <Text ellipsis>{record?.gcodeFile?.name}</Text>,
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
@ -162,7 +164,7 @@ const Jobs = () => {
|
||||
propertyName: 'state'
|
||||
}),
|
||||
onFilter: (value, record) =>
|
||||
record.state.type.toLowerCase().includes(value.toLowerCase())
|
||||
record?.state?.type?.toLowerCase().includes(value.toLowerCase())
|
||||
},
|
||||
{
|
||||
title: <CheckCircleIcon />,
|
||||
@ -226,13 +228,13 @@ const Jobs = () => {
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'operation',
|
||||
key: 'actions',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (record) => {
|
||||
return (
|
||||
<Space size='small'>
|
||||
{record.state.type === 'draft' ? (
|
||||
{record?.state?.type === 'draft' ? (
|
||||
<Button
|
||||
icon={<PlayCircleIcon />}
|
||||
onClick={() => handleDeployJob(record.id)}
|
||||
@ -368,6 +370,7 @@ const Jobs = () => {
|
||||
{notificationContextHolder}
|
||||
<Flex vertical={'true'} gap='large' style={{ height: '100%' }}>
|
||||
{contextHolder}
|
||||
<Flex justify={'space-between'}>
|
||||
<Space size='small'>
|
||||
<Dropdown menu={actionItems}>
|
||||
<Button>Actions</Button>
|
||||
@ -380,18 +383,29 @@ const Jobs = () => {
|
||||
<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}/jobs`}
|
||||
authenticated={authenticated}
|
||||
cards={viewMode === 'cards'}
|
||||
/>
|
||||
</Flex>
|
||||
<Modal
|
||||
open={newJobOpen}
|
||||
footer={null}
|
||||
width={700}
|
||||
width={'auto'}
|
||||
height={'auto'}
|
||||
onCancel={() => {
|
||||
setNewJobOpen(false)
|
||||
}}
|
||||
|
||||
@ -16,7 +16,7 @@ import {
|
||||
Checkbox,
|
||||
Card
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
||||
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||
import TimeDisplay from '../../common/TimeDisplay'
|
||||
import JobState from '../../common/JobState'
|
||||
import IdText from '../../common/IdText'
|
||||
@ -28,12 +28,16 @@ import useCollapseState from '../../hooks/useCollapseState'
|
||||
import config from '../../../../config'
|
||||
import AuditLogTable from '../../common/AuditLogTable'
|
||||
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 JobInfo = () => {
|
||||
const [jobData, setJobData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [fetchLoading, setFetchLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const location = useLocation()
|
||||
const [messageApi] = message.useMessage()
|
||||
@ -77,7 +81,7 @@ const JobInfo = () => {
|
||||
|
||||
const fetchJobDetails = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setFetchLoading(true)
|
||||
const response = await axios.get(`${config.backendUrl}/jobs/${jobId}`, {
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
@ -90,7 +94,7 @@ const JobInfo = () => {
|
||||
setError('Failed to fetch print job details')
|
||||
messageApi.error('Failed to fetch print job details')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setFetchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@ -176,28 +180,28 @@ const JobInfo = () => {
|
||||
<Flex vertical style={{ flexGrow: 1 }} gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.info ? ['info'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('info', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
<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 }}>
|
||||
Job Information
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='info'
|
||||
>
|
||||
<Spin spinning={loading} indicator={<LoadingOutlined />}>
|
||||
<Spin spinning={fetchLoading} indicator={<LoadingOutlined />}>
|
||||
<Descriptions
|
||||
bordered
|
||||
column={{
|
||||
@ -303,86 +307,86 @@ const JobInfo = () => {
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.subJobs ? ['2'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('subJobs', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<JobIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Sub Job Information
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='2'
|
||||
>
|
||||
<SubJobsTree jobData={jobData} loading={loading} />
|
||||
<SubJobsTree jobData={jobData} loading={fetchLoading} />
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.notes ? ['notes'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('notes', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
<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 />
|
||||
<DashboardNotes _id={jobId} />
|
||||
</Card>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('auditLogs', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
<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={jobData?.auditLogs || []}
|
||||
loading={loading}
|
||||
loading={fetchLoading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
|
||||
@ -29,6 +29,9 @@ import CheckIcon from '../../Icons/CheckIcon'
|
||||
import DashboardTable from '../common/DashboardTable'
|
||||
|
||||
import config from '../../../config'
|
||||
import GridIcon from '../../Icons/GridIcon'
|
||||
import ListIcon from '../../Icons/ListIcon'
|
||||
import useViewMode from '../hooks/useViewMode'
|
||||
|
||||
const Printers = () => {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
@ -37,12 +40,14 @@ const Printers = () => {
|
||||
const navigate = useNavigate()
|
||||
const tableRef = useRef()
|
||||
|
||||
// View mode state (cards/list), persisted in sessionStorage via custom hook
|
||||
const [viewMode, setViewMode] = useViewMode('Printers')
|
||||
|
||||
// Column definitions
|
||||
const columns = [
|
||||
{
|
||||
title: '',
|
||||
dataIndex: '',
|
||||
key: '',
|
||||
title: <PrinterIcon />,
|
||||
key: 'icon',
|
||||
width: 40,
|
||||
fixed: 'left',
|
||||
render: () => <PrinterIcon />
|
||||
@ -71,7 +76,7 @@ const Printers = () => {
|
||||
},
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
dataIndex: '_id',
|
||||
key: 'id',
|
||||
width: 180,
|
||||
render: (text) => <IdText id={text} type='printer' longId={false} />
|
||||
@ -129,7 +134,7 @@ const Printers = () => {
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'operation',
|
||||
key: 'actions',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (record) => {
|
||||
@ -139,11 +144,11 @@ const Printers = () => {
|
||||
icon={<ControlIcon />}
|
||||
onClick={() =>
|
||||
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>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
@ -284,6 +289,7 @@ const Printers = () => {
|
||||
<>
|
||||
<Flex vertical={'true'} gap='large'>
|
||||
{contextHolder}
|
||||
<Flex justify={'space-between'}>
|
||||
<Space>
|
||||
<Dropdown menu={actionItems}>
|
||||
<Button>Actions</Button>
|
||||
@ -296,12 +302,22 @@ const Printers = () => {
|
||||
<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}/printers`}
|
||||
authenticated={authenticated}
|
||||
cards={viewMode === 'cards'}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
@ -20,7 +20,7 @@ import {
|
||||
Checkbox,
|
||||
Collapse
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
||||
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||
|
||||
import { SocketContext } from '../../context/SocketContext'
|
||||
|
||||
@ -65,6 +65,7 @@ const ControlPrinter = () => {
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
const [printerData, setPrinterData] = useState(null)
|
||||
const [fetchLoading, setFetchLoading] = useState(true)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const [loadFilamentStockModalOpen, setLoadFilamentStockModalOpen] =
|
||||
useState(false)
|
||||
@ -111,6 +112,7 @@ const ControlPrinter = () => {
|
||||
// Fetch printer details when the component mounts
|
||||
const fetchPrinterDetails = useCallback(async () => {
|
||||
if (printerId) {
|
||||
setFetchLoading(true)
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${config.backendUrl}/printers/${printerId}`,
|
||||
@ -121,9 +123,10 @@ const ControlPrinter = () => {
|
||||
withCredentials: true // Important for including cookies
|
||||
}
|
||||
)
|
||||
|
||||
setFetchLoading(false)
|
||||
setPrinterData(response.data)
|
||||
} catch (error) {
|
||||
setFetchLoading(false)
|
||||
if (error.response) {
|
||||
messageApi.error(
|
||||
'Error fetching printer data:',
|
||||
@ -485,7 +488,6 @@ const ControlPrinter = () => {
|
||||
</Space>
|
||||
</Flex>
|
||||
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||
{printerData ? (
|
||||
<Flex gap={'large'} wrap>
|
||||
<Flex vertical style={{ flexGrow: 1 }} gap={'large'}>
|
||||
<Flex gap={16} vertical style={{ flexGrow: 1 }}>
|
||||
@ -498,16 +500,11 @@ const ControlPrinter = () => {
|
||||
</Flex>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.job ? ['1'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('job', keys.length > 0)
|
||||
}
|
||||
onChange={(keys) => updateCollapseState('job', keys.length > 0)}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
style={{ padding: 0 }}
|
||||
className='no-h-padding-collapse no-t-padding-collapse'
|
||||
@ -525,6 +522,10 @@ const ControlPrinter = () => {
|
||||
</Flex>
|
||||
}
|
||||
key='1'
|
||||
>
|
||||
<Spin
|
||||
indicator={<LoadingOutlined spin />}
|
||||
spinning={fetchLoading}
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
@ -538,11 +539,15 @@ const ControlPrinter = () => {
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label='Printer Name'>
|
||||
{printerData.name}
|
||||
{printerData?.name ? (
|
||||
<Text>{printerData.name}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Printer ID'>
|
||||
{printerData._id ? (
|
||||
{printerData?._id ? (
|
||||
<IdText
|
||||
id={printerData._id}
|
||||
type='printer'
|
||||
@ -550,12 +555,12 @@ const ControlPrinter = () => {
|
||||
showHyperlink={true}
|
||||
/>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='GCode File Name'>
|
||||
{printerData.currentJob?.gcodeFile?.name ? (
|
||||
{printerData?.currentJob?.gcodeFile?.name ? (
|
||||
<Space>
|
||||
<GCodeFileIcon />
|
||||
<Text ellipsis style={{ maxWidth: 200 }}>
|
||||
@ -563,11 +568,11 @@ const ControlPrinter = () => {
|
||||
</Text>
|
||||
</Space>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='GCode File ID'>
|
||||
{printerData.currentJob?.gcodeFile ? (
|
||||
{printerData?.currentJob?.gcodeFile ? (
|
||||
<IdText
|
||||
id={printerData.currentJob.gcodeFile.id}
|
||||
type='gcodeFile'
|
||||
@ -575,12 +580,12 @@ const ControlPrinter = () => {
|
||||
showHyperlink={true}
|
||||
/>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Print Job ID'>
|
||||
{printerData.currentJob?.id ? (
|
||||
{printerData?.currentJob?.id ? (
|
||||
<IdText
|
||||
id={printerData.currentJob.id}
|
||||
type='job'
|
||||
@ -588,12 +593,12 @@ const ControlPrinter = () => {
|
||||
showHyperlink={true}
|
||||
/>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Sub Job ID'>
|
||||
{printerData.currentSubJob?.id ? (
|
||||
{printerData?.currentSubJob?.id ? (
|
||||
<IdText
|
||||
id={printerData.currentSubJob.number
|
||||
.toString()
|
||||
@ -603,11 +608,11 @@ const ControlPrinter = () => {
|
||||
showHyperlink={false}
|
||||
/>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
{printerData?.state.type === 'printing' && (
|
||||
{printerData?.state?.type === 'printing' && (
|
||||
<>
|
||||
<Descriptions.Item label='Progress' span={1}>
|
||||
<Progress
|
||||
@ -618,71 +623,58 @@ const ControlPrinter = () => {
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Started At' span={1}>
|
||||
{printerData.currentSubJob?.startedAt ? (
|
||||
{printerData?.currentSubJob?.startedAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={printerData.currentSubJob.startedAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Descriptions.Item label='Print Profile'>
|
||||
{(() => {
|
||||
if (
|
||||
printerData?.currentJob?.gcodeFile.gcodeFileInfo
|
||||
.printSettingsId
|
||||
) {
|
||||
return (
|
||||
{printerData?.currentJob?.gcodeFile?.gcodeFileInfo
|
||||
?.printSettingsId ? (
|
||||
<Text ellipsis style={{ maxWidth: 200 }}>
|
||||
{printerData.currentJob.gcodeFile.gcodeFileInfo.printSettingsId.replaceAll(
|
||||
'"',
|
||||
''
|
||||
)}
|
||||
</Text>
|
||||
)
|
||||
} else {
|
||||
return 'n/a'
|
||||
}
|
||||
})()}
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Est. Print Time'>
|
||||
{(() => {
|
||||
if (
|
||||
printerData.currentJob?.gcodeFile?.gcodeFileInfo
|
||||
.estimatedPrintingTimeNormalMode
|
||||
) {
|
||||
return (
|
||||
{printerData?.currentJob?.gcodeFile?.gcodeFileInfo
|
||||
?.estimatedPrintingTimeNormalMode ? (
|
||||
<Text ellipsis>
|
||||
{
|
||||
printerData.currentJob.gcodeFile.gcodeFileInfo
|
||||
.estimatedPrintingTimeNormalMode
|
||||
}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return 'n/a'
|
||||
})()}
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Spin>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.filament ? ['1'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('filament', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
style={{ padding: 0 }}
|
||||
className='no-h-padding-collapse'
|
||||
@ -700,6 +692,10 @@ const ControlPrinter = () => {
|
||||
</Flex>
|
||||
}
|
||||
key='1'
|
||||
>
|
||||
<Spin
|
||||
indicator={<LoadingOutlined spin />}
|
||||
spinning={fetchLoading}
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
@ -718,11 +714,11 @@ const ControlPrinter = () => {
|
||||
filamentStock={printerData?.currentFilamentStock}
|
||||
/>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Filament Stock ID'>
|
||||
{printerData.currentFilamentStock ? (
|
||||
{printerData?.currentFilamentStock?._id ? (
|
||||
<IdText
|
||||
id={printerData.currentFilamentStock._id}
|
||||
type='filamentstock'
|
||||
@ -730,11 +726,11 @@ const ControlPrinter = () => {
|
||||
showHyperlink={true}
|
||||
/>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Filament Name'>
|
||||
{printerData.currentFilamentStock?.filament?.name ? (
|
||||
{printerData?.currentFilamentStock?.filament?.name ? (
|
||||
<Space>
|
||||
<FilamentIcon />
|
||||
<Badge
|
||||
@ -747,7 +743,7 @@ const ControlPrinter = () => {
|
||||
></Badge>
|
||||
</Space>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Filament ID'>
|
||||
@ -759,11 +755,11 @@ const ControlPrinter = () => {
|
||||
showHyperlink={true}
|
||||
/>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Weight'>
|
||||
{printerData.currentFilamentStock?.currentNetWeight ? (
|
||||
{printerData?.currentFilamentStock?.currentNetWeight ? (
|
||||
<div>
|
||||
<Descriptions
|
||||
style={{ width: isMobile ? '100%' : '250px' }}
|
||||
@ -783,24 +779,22 @@ const ControlPrinter = () => {
|
||||
</Descriptions>
|
||||
</div>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Spin>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.jobs ? ['1'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('jobs', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
style={{ padding: 0 }}
|
||||
className='no-h-padding-collapse'
|
||||
@ -819,21 +813,32 @@ const ControlPrinter = () => {
|
||||
}
|
||||
key='1'
|
||||
>
|
||||
<PrinterSubJobsTree subJobs={printerData.subJobs} />
|
||||
<PrinterSubJobsTree
|
||||
subJobs={printerData?.subJobs}
|
||||
loading={fetchLoading}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Flex>
|
||||
<Flex gap={'large'} wrap vertical>
|
||||
{componentVisibility.temperature && (
|
||||
<Spin
|
||||
indicator={<LoadingOutlined spin />}
|
||||
spinning={fetchLoading}
|
||||
>
|
||||
<Card>
|
||||
<PrinterTemperaturePanel
|
||||
printerId={printerId}
|
||||
disabled={!printerData.online}
|
||||
></PrinterTemperaturePanel>
|
||||
</Card>
|
||||
</Spin>
|
||||
)}
|
||||
|
||||
{componentVisibility.position && (
|
||||
<Spin
|
||||
indicator={<LoadingOutlined spin />}
|
||||
spinning={fetchLoading}
|
||||
>
|
||||
<Card>
|
||||
<PrinterPositionPanel
|
||||
printerId={printerId}
|
||||
@ -841,26 +846,34 @@ const ControlPrinter = () => {
|
||||
showMoreInfo={true}
|
||||
/>
|
||||
</Card>
|
||||
</Spin>
|
||||
)}
|
||||
|
||||
{componentVisibility.movement && (
|
||||
<Spin
|
||||
indicator={<LoadingOutlined spin />}
|
||||
spinning={fetchLoading}
|
||||
>
|
||||
<Card>
|
||||
<PrinterMovementPanel
|
||||
printerId={printerId}
|
||||
></PrinterMovementPanel>
|
||||
</Card>
|
||||
</Spin>
|
||||
)}
|
||||
|
||||
{componentVisibility.misc && (
|
||||
<Spin
|
||||
indicator={<LoadingOutlined spin />}
|
||||
spinning={fetchLoading}
|
||||
>
|
||||
<Card>
|
||||
<PrinterMiscPanel printerId={printerId} />
|
||||
</Card>
|
||||
</Spin>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
) : (
|
||||
<Spin indicator={<LoadingOutlined spin />} size='large' />
|
||||
)}
|
||||
</div>
|
||||
</Flex>
|
||||
<Modal
|
||||
|
||||
@ -14,9 +14,13 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Collapse
|
||||
Collapse,
|
||||
Dropdown,
|
||||
Popover,
|
||||
Checkbox,
|
||||
Card
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
||||
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||
import PrinterState from '../../common/PrinterState'
|
||||
import TimeDisplay from '../../common/TimeDisplay'
|
||||
import IdText from '../../common/IdText'
|
||||
@ -32,13 +36,18 @@ import useCollapseState from '../../hooks/useCollapseState'
|
||||
|
||||
import config from '../../../../config.js'
|
||||
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 [printerData, setPrinterData] = useState(null)
|
||||
const [fetchLoading, setFetchLoading] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const location = useLocation()
|
||||
const printerId = new URLSearchParams(location.search).get('printerId')
|
||||
@ -121,7 +130,7 @@ const PrinterInfo = () => {
|
||||
const updatePrinterInfo = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setLoading(true)
|
||||
setEditLoading(true)
|
||||
|
||||
await axios.put(
|
||||
`${config.backendUrl}/printers/${printerId}`,
|
||||
@ -156,7 +165,7 @@ const PrinterInfo = () => {
|
||||
console.error('Failed to update printer information:', err)
|
||||
messageApi.error('Failed to update printer information')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setEditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@ -176,55 +185,69 @@ const PrinterInfo = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (fetchLoading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
||||
</div>
|
||||
)
|
||||
const actionItems = {
|
||||
items: [
|
||||
{
|
||||
label: 'Reload Printer',
|
||||
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 (
|
||||
<Space
|
||||
direction='vertical'
|
||||
style={{ width: '100%', textAlign: 'center' }}
|
||||
<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)
|
||||
}}
|
||||
>
|
||||
<p>{error || 'Printer not found'}</p>
|
||||
<Button icon={<ReloadIcon />} onClick={fetchPrinterDetails}>
|
||||
Retry
|
||||
</Button>
|
||||
</Space>
|
||||
{section.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
<>
|
||||
{contextHolder}
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
activeKey={collapseState.info ? ['1'] : []}
|
||||
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '9px' }}
|
||||
/>
|
||||
)}
|
||||
className='no-h-padding-collapse no-t-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex
|
||||
align='center'
|
||||
justify='space-between'
|
||||
style={{ width: '100%' }}
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{ height: '100%', minHeight: 0 }}
|
||||
>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Printer Information
|
||||
</Title>
|
||||
<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 ? (
|
||||
<>
|
||||
@ -232,12 +255,13 @@ const PrinterInfo = () => {
|
||||
icon={<CheckIcon />}
|
||||
type='primary'
|
||||
onClick={updatePrinterInfo}
|
||||
loading={loading}
|
||||
loading={editLoading}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
<Button
|
||||
icon={<XMarkIcon />}
|
||||
onClick={cancelEditing}
|
||||
disabled={loading}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@ -245,23 +269,61 @@ const PrinterInfo = () => {
|
||||
)}
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
{error ? (
|
||||
<Space
|
||||
direction='vertical'
|
||||
style={{ width: '100%', textAlign: 'center' }}
|
||||
>
|
||||
<p>{error || 'Print job not found'}</p>
|
||||
<Button icon={<ReloadIcon />} onClick={fetchPrinterDetails}>
|
||||
Retry
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||
<Flex vertical gap={'large'}>
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.info ? ['info'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('info', keys.length > 0)
|
||||
}
|
||||
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}
|
||||
layout='vertical'
|
||||
initialValues={{
|
||||
name: printerData.name || '',
|
||||
vendor: printerData.vendor || { id: null, name: '' },
|
||||
name: printerData?.name || '',
|
||||
vendor: printerData?.vendor || { id: null, name: '' },
|
||||
moonraker: {
|
||||
host: printerData.moonraker?.host || '',
|
||||
port: printerData.moonraker?.port || null,
|
||||
protocol: printerData.moonraker?.protocol || 'ws',
|
||||
apiKey: printerData.moonraker?.apiKey || ''
|
||||
host: printerData?.moonraker?.host || '',
|
||||
port: printerData?.moonraker?.port || null,
|
||||
protocol: printerData?.moonraker?.protocol || 'ws',
|
||||
apiKey: printerData?.moonraker?.apiKey || ''
|
||||
},
|
||||
tags: printerData.tags || []
|
||||
tags: printerData?.tags || []
|
||||
}}
|
||||
>
|
||||
<Spin
|
||||
spinning={fetchLoading}
|
||||
indicator={<LoadingOutlined />}
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
@ -276,13 +338,21 @@ const PrinterInfo = () => {
|
||||
>
|
||||
{/* Read-only fields */}
|
||||
<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 label='Connected At'>
|
||||
{printerData?.connectedAt ? (
|
||||
<TimeDisplay
|
||||
dateTime={printerData.connectedAt}
|
||||
showSince={true}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
{/* Editable fields */}
|
||||
@ -304,8 +374,10 @@ const PrinterInfo = () => {
|
||||
>
|
||||
<Input placeholder='Enter printer name' />
|
||||
</Form.Item>
|
||||
) : printerData?.name ? (
|
||||
<Text>{printerData.name}</Text>
|
||||
) : (
|
||||
printerData.name || 'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
@ -314,19 +386,25 @@ const PrinterInfo = () => {
|
||||
<Form.Item
|
||||
name={['moonraker', 'host']}
|
||||
rules={[
|
||||
{ required: true, message: 'Please enter a host' },
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a host'
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/^[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$|\b(?:\d{1,3}\.){3}\d{1,3}\b/,
|
||||
message: 'Please enter a valid hostname or IP address'
|
||||
message:
|
||||
'Please enter a valid hostname or IP address'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Input placeholder='Enter host (e.g., 192.168.1.100)' />
|
||||
</Form.Item>
|
||||
) : printerData?.moonraker?.host ? (
|
||||
<Text>{printerData.moonraker.host}</Text>
|
||||
) : (
|
||||
printerData.moonraker?.host || 'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
@ -335,17 +413,22 @@ const PrinterInfo = () => {
|
||||
<Form.Item
|
||||
name='vendor'
|
||||
rules={[
|
||||
{ required: true, message: 'Please enter a vendor' }
|
||||
{
|
||||
required: true,
|
||||
message: 'Please enter a vendor'
|
||||
}
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<VendorSelect />
|
||||
</Form.Item>
|
||||
) : (
|
||||
) : printerData?.vendor?.name ? (
|
||||
<Space>
|
||||
<VendorIcon />
|
||||
{printerData?.vendor?.name || 'n/a'}
|
||||
</Space>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
@ -357,7 +440,7 @@ const PrinterInfo = () => {
|
||||
showHyperlink={true}
|
||||
/>
|
||||
) : (
|
||||
'n/a'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
@ -386,8 +469,10 @@ const PrinterInfo = () => {
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : printerData?.moonraker?.port ? (
|
||||
<Text>{printerData.moonraker.port}</Text>
|
||||
) : (
|
||||
printerData.moonraker.port
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
@ -395,7 +480,9 @@ const PrinterInfo = () => {
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name={['moonraker', 'protocol']}
|
||||
rules={[{ required: true, message: 'Port is required' }]}
|
||||
rules={[
|
||||
{ required: true, message: 'Port is required' }
|
||||
]}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<Select
|
||||
@ -406,10 +493,12 @@ const PrinterInfo = () => {
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : printerData.moonraker.protocol == 'ws' ? (
|
||||
'Websocket'
|
||||
) : printerData?.moonraker?.protocol == 'ws' ? (
|
||||
<Text>Websocket</Text>
|
||||
) : printerData?.moonraker?.protocol == 'wss' ? (
|
||||
<Text>Websocket Secure</Text>
|
||||
) : (
|
||||
'Websocket Secure'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
@ -421,20 +510,24 @@ const PrinterInfo = () => {
|
||||
>
|
||||
<Input.Password placeholder='Enter API key' />
|
||||
</Form.Item>
|
||||
) : printerData.moonraker?.apiKey ? (
|
||||
'Configured'
|
||||
) : printerData?.moonraker?.apiKey ? (
|
||||
<Text>Configured</Text>
|
||||
) : (
|
||||
'Not configured'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Status'>
|
||||
{printerData?.state ? (
|
||||
<PrinterState
|
||||
printer={printerData}
|
||||
showPrinterName={false}
|
||||
showControls={false}
|
||||
showProgress={false}
|
||||
/>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Tags'>
|
||||
@ -461,10 +554,13 @@ const PrinterInfo = () => {
|
||||
<Form.Item name='newTag' noStyle>
|
||||
<Input placeholder='Add new tag' />
|
||||
</Form.Item>
|
||||
<Button onClick={handleTagAdd} icon={<PlusIcon />} />
|
||||
<Button
|
||||
onClick={handleTagAdd}
|
||||
icon={<PlusIcon />}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
) : printerData.tags?.length > 0 ? (
|
||||
) : printerData?.tags?.length > 0 ? (
|
||||
<Space
|
||||
size={[0, 2]}
|
||||
wrap
|
||||
@ -477,73 +573,117 @@ const PrinterInfo = () => {
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
'No tags'
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label='Firmware Version'>
|
||||
{printerData.firmware || 'Unknown'}
|
||||
{printerData?.firmware ? (
|
||||
<Text>{printerData.firmware}</Text>
|
||||
) : (
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Spin>
|
||||
</Form>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
activeKey={collapseState.jobs ? ['2'] : []}
|
||||
onChange={(keys) => updateCollapseState('jobs', keys.length > 0)}
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.jobs ? ['jobs'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('jobs', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<PrinterIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Printer Jobs
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='2'
|
||||
key='jobs'
|
||||
>
|
||||
<PrinterSubJobsList subJobs={printerData.subJobs} />
|
||||
<PrinterSubJobsList
|
||||
subJobs={printerData?.subJobs}
|
||||
loading={fetchLoading}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
activeKey={collapseState.auditLogs ? ['3'] : []}
|
||||
onChange={(keys) => updateCollapseState('auditLogs', keys.length > 0)}
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.notes ? ['notes'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('notes', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<NoteIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Notes
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='notes'
|
||||
>
|
||||
<Card>
|
||||
<DashboardNotes _id={printerId} />
|
||||
</Card>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('auditLogs', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex align='center' gap={'middle'}>
|
||||
<AuditLogIcon />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Audit Log
|
||||
</Title>
|
||||
</Flex>
|
||||
}
|
||||
key='3'
|
||||
key='auditLogs'
|
||||
>
|
||||
<AuditLogTable
|
||||
items={printerData.auditLogs || []}
|
||||
loading={false}
|
||||
items={printerData?.auditLogs || []}
|
||||
loading={fetchLoading}
|
||||
showTargetColumn={false}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Flex>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
Segmented,
|
||||
Card
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
||||
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||
import { Line } from '@ant-design/charts'
|
||||
import axios from 'axios'
|
||||
import PrinterIcon from '../../Icons/PrinterIcon'
|
||||
@ -151,11 +151,11 @@ const ProductionOverview = () => {
|
||||
<Flex gap='large' vertical>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.overview ? ['1'] : []}
|
||||
onChange={(keys) => updateCollapseState('overview', keys.length > 0)}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
<CaretLeftOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
@ -275,13 +275,13 @@ const ProductionOverview = () => {
|
||||
<Flex flex={1} vertical>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.printerStats ? ['2'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('printerStats', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
<CaretLeftOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
@ -357,13 +357,13 @@ const ProductionOverview = () => {
|
||||
<Flex flex={1} vertical>
|
||||
<Collapse
|
||||
ghost
|
||||
collapsible='icon'
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.jobStats ? ['3'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('jobStats', keys.length > 0)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined
|
||||
<CaretLeftOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
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 { Typography, Space, Descriptions, Badge, Tag, Table } from 'antd'
|
||||
import { Typography, Space, Descriptions, Badge, Table } from 'antd'
|
||||
import PropTypes from 'prop-types'
|
||||
import IdText from './IdText'
|
||||
import { AuditOutlined, LoadingOutlined } from '@ant-design/icons'
|
||||
import TimeDisplay from '../common/TimeDisplay'
|
||||
import BoolDisplay from './BoolDisplay'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@ -19,7 +20,11 @@ const isObjectId = (value) => {
|
||||
|
||||
const formatValue = (value, propertyName) => {
|
||||
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
|
||||
@ -42,11 +47,7 @@ const formatValue = (value, propertyName) => {
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean' || value === true || value === false) {
|
||||
return (
|
||||
<Tag color={value ? 'success' : 'error'} style={{ margin: 0 }}>
|
||||
{value ? 'Yes' : 'No'}
|
||||
</Tag>
|
||||
)
|
||||
return <BoolDisplay value={value} yesNo={true} />
|
||||
}
|
||||
|
||||
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/inventory': 'Inventory',
|
||||
'/dashboard/management': 'Management',
|
||||
'/dashboard/developer': 'Developer',
|
||||
'/dashboard/production/overview': 'Overview',
|
||||
'/dashboard/production/printers': 'Printers',
|
||||
'/dashboard/production/printers/control': 'Control',
|
||||
@ -29,6 +30,8 @@ const breadcrumbNameMap = {
|
||||
'/dashboard/management/materials/info': 'Info',
|
||||
'/dashboard/management/notetypes': 'Note Types',
|
||||
'/dashboard/management/notetypes/info': 'Info',
|
||||
'/dashboard/management/users': 'Users',
|
||||
'/dashboard/management/users/info': 'Info',
|
||||
'/dashboard/management/settings': 'Settings',
|
||||
'/dashboard/management/auditlogs': 'Audit Logs',
|
||||
'/dashboard/inventory/filamentstocks': 'Filament Stocks',
|
||||
@ -40,7 +43,10 @@ const breadcrumbNameMap = {
|
||||
'/dashboard/inventory/stockevents': 'Stock Events',
|
||||
'/dashboard/inventory/stockevents/info': 'Info',
|
||||
'/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 = () => {
|
||||
|
||||
@ -9,7 +9,8 @@ import {
|
||||
Button,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Divider
|
||||
Divider,
|
||||
Badge
|
||||
} from 'antd'
|
||||
import {
|
||||
LogoutOutlined,
|
||||
@ -20,6 +21,7 @@ import {
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import { SocketContext } from '../context/SocketContext'
|
||||
import { SpotlightContext } from '../context/SpotlightContext'
|
||||
import { NotificationContext } from '../context/NotificationContext'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Header } from 'antd/es/layout/layout'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
@ -33,12 +35,15 @@ import CloudIcon from '../../Icons/CloudIcon'
|
||||
import BellIcon from '../../Icons/BellIcon'
|
||||
import SearchIcon from '../../Icons/SearchIcon'
|
||||
import SettingsIcon from '../../Icons/SettingsIcon'
|
||||
import DeveloperIcon from '../../Icons/DeveloperIcon'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const DashboardNavigation = () => {
|
||||
const { logout, userProfile } = useContext(AuthContext)
|
||||
const { showSpotlight } = useContext(SpotlightContext)
|
||||
const { toggleNotificationCenter, unreadCount } =
|
||||
useContext(NotificationContext)
|
||||
const { socket } = useContext(SocketContext)
|
||||
const [socketState, setSocketState] = useState('disconnected')
|
||||
const navigate = useNavigate()
|
||||
@ -168,12 +173,14 @@ const DashboardNavigation = () => {
|
||||
onClick={() => showSpotlight()}
|
||||
></Button>
|
||||
</Tooltip>
|
||||
<Badge count={unreadCount} size='small'>
|
||||
<Button
|
||||
icon={<BellIcon />}
|
||||
type='text'
|
||||
style={{ marginTop: '2px' }}
|
||||
onClick={() => showSpotlight()}
|
||||
onClick={toggleNotificationCenter}
|
||||
></Button>
|
||||
</Badge>
|
||||
</Space>
|
||||
<Space>
|
||||
{socketState === 'connected' ? (
|
||||
@ -206,10 +213,15 @@ const DashboardNavigation = () => {
|
||||
</Space>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<Space>
|
||||
<Tooltip title='Development Environment' arrow={false}>
|
||||
<Tag color='yellow' style={{ marginRight: 0 }}>
|
||||
Dev
|
||||
</Tag>
|
||||
<Tooltip title='Developer' arrow={false}>
|
||||
<Tag
|
||||
color='yellow'
|
||||
style={{ marginRight: 0 }}
|
||||
icon={<DeveloperIcon />}
|
||||
onClick={() => {
|
||||
navigate('/dashboard/developer/sessionstorage')
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import {
|
||||
Card,
|
||||
@ -9,31 +9,464 @@ import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Switch
|
||||
Switch,
|
||||
Spin,
|
||||
Alert,
|
||||
message,
|
||||
Divider,
|
||||
Tag,
|
||||
Dropdown
|
||||
} from 'antd'
|
||||
import { CaretLeftFilled, LoadingOutlined } from '@ant-design/icons'
|
||||
import PlusIcon from '../../Icons/PlusIcon'
|
||||
import BinIcon from '../../Icons/BinIcon'
|
||||
import PersonIcon from '../../Icons/PersonIcon'
|
||||
import TimeDisplay from './TimeDisplay'
|
||||
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 DashboardNotes = ({ notes = [], onNewNote }) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [showMarkdown, setShowMarkdown] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const NoteItem = ({
|
||||
note,
|
||||
expandedNotes,
|
||||
setExpandedNotes,
|
||||
fetchData,
|
||||
onNewNote,
|
||||
onDeleteNote,
|
||||
userProfile,
|
||||
onChildNoteAdded
|
||||
}) => {
|
||||
const [childNotes, setChildNotes] = useState({})
|
||||
const [loadingChildNotes, setLoadingChildNotes] = useState(null)
|
||||
|
||||
const handleNewNote = () => {
|
||||
setIsModalOpen(true)
|
||||
const isExpanded = expandedNotes[note._id]
|
||||
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 () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
const values = await newNoteForm.validateFields()
|
||||
onNewNote(values)
|
||||
form.resetFields()
|
||||
setIsModalOpen(false)
|
||||
newNoteForm.resetFields()
|
||||
setNewNoteOpen(false)
|
||||
setShowMarkdown(false)
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error)
|
||||
@ -41,87 +474,113 @@ const DashboardNotes = ({ notes = [], onNewNote }) => {
|
||||
}
|
||||
|
||||
const handleModalCancel = () => {
|
||||
form.resetFields()
|
||||
setIsModalOpen(false)
|
||||
newNoteForm.resetFields()
|
||||
setNewNoteOpen(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 (
|
||||
<Space direction='vertical' size='large' style={{ width: '100%' }}>
|
||||
<Flex vertical gap='large' style={{ width: '100%' }}>
|
||||
{contextHolder}
|
||||
<Flex justify='space-between'>
|
||||
<Space size={'small'}>
|
||||
<Button>Actions</Button>
|
||||
<Dropdown menu={actionItems} disabled={loading}>
|
||||
<Button disabled={loading}>Actions</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
<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>
|
||||
</Flex>
|
||||
|
||||
<Space direction='vertical' size='middle' style={{ width: '100%' }}>
|
||||
{notes.map((note) => (
|
||||
<Card key={note._id} size='small'>
|
||||
<Space direction='vertical' style={{ width: '100%' }}>
|
||||
<Flex justify='space-between' align='center'>
|
||||
<Text type='secondary'>
|
||||
<TimeDisplay dateTime={note.createdAt} showSince={true} />
|
||||
</Text>
|
||||
<Spin indicator={<LoadingOutlined />} spinning={loading}>
|
||||
{error ? (
|
||||
<Alert message={error?.message} type='error' showIcon={true} />
|
||||
) : (
|
||||
<Flex vertical gap={'middle'}>
|
||||
{notes}
|
||||
</Flex>
|
||||
<Text>{note.content}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
)}
|
||||
</Spin>
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
title='New Note'
|
||||
open={isModalOpen}
|
||||
open={newNoteOpen}
|
||||
onOk={handleModalOk}
|
||||
onCancel={handleModalCancel}
|
||||
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={newNoteForm}
|
||||
layout='vertical'
|
||||
initialValues={{
|
||||
type: 'general',
|
||||
showMarkdown: false
|
||||
}}
|
||||
onFinish={handleNewNote}
|
||||
initialValues={{ content: '' }}
|
||||
onValuesChange={(changedValues) =>
|
||||
setNewNoteFormValues((prevValues) => ({
|
||||
...prevValues,
|
||||
...changedValues
|
||||
}))
|
||||
}
|
||||
>
|
||||
<Form.Item
|
||||
name='type'
|
||||
label='Note Type'
|
||||
rules={[{ required: true, message: 'Please select a note type' }]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value='general'>General</Select.Option>
|
||||
<Select.Option value='task'>Task</Select.Option>
|
||||
<Select.Option value='idea'>Idea</Select.Option>
|
||||
<Select.Option value='bug'>Bug</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name='title'
|
||||
label='Title'
|
||||
rules={[{ required: true, message: 'Please enter a title' }]}
|
||||
>
|
||||
<Input placeholder='Enter note title' />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name='showMarkdown' valuePropName='checked'>
|
||||
<Switch
|
||||
checkedChildren='Show Markdown'
|
||||
unCheckedChildren='Hide Markdown'
|
||||
onChange={setShowMarkdown}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Flex gap='middle'>
|
||||
<Flex vertical gap={'large'}>
|
||||
<Flex gap='middle' wrap>
|
||||
<Form.Item
|
||||
name='content'
|
||||
label='Content'
|
||||
rules={[{ required: true, message: 'Please enter note content' }]}
|
||||
style={{ flex: 1 }}
|
||||
rules={[{ required: true, message: '' }]}
|
||||
style={{ margin: 0, flexGrow: 1, minWidth: '300px' }}
|
||||
>
|
||||
<TextArea
|
||||
rows={6}
|
||||
@ -131,47 +590,105 @@ const DashboardNotes = ({ notes = [], onNewNote }) => {
|
||||
</Form.Item>
|
||||
|
||||
{showMarkdown && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text
|
||||
type='secondary'
|
||||
style={{ marginBottom: 8, display: 'block' }}
|
||||
>
|
||||
Preview
|
||||
</Text>
|
||||
<div
|
||||
<Card
|
||||
style={{
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '6px',
|
||||
padding: '8px',
|
||||
minHeight: '150px',
|
||||
maxHeight: '300px',
|
||||
overflow: 'auto'
|
||||
flexGrow: 1,
|
||||
minWidth: '300px',
|
||||
backgroundColor: () => {
|
||||
if (newNoteFormValues?.noteType?.color) {
|
||||
return newNoteFormValues.noteType.color + '26'
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MarkdownDisplay
|
||||
content={form.getFieldValue('content') || ''}
|
||||
content={newNoteForm.getFieldValue('content') || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</Flex>
|
||||
<Form.Item
|
||||
name='noteType'
|
||||
style={{ margin: 0 }}
|
||||
rules={[
|
||||
{ required: true, message: 'Please select a note type' }
|
||||
]}
|
||||
>
|
||||
<NoteTypeSelect />
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
</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
|
||||
open={deleteConfirmOpen}
|
||||
title={
|
||||
<Space size={'middle'}>
|
||||
<ExclamationOctagonIcon />
|
||||
Confirm Delete
|
||||
</Space>
|
||||
}
|
||||
onOk={confirmDeleteNote}
|
||||
onCancel={cancelDeleteNote}
|
||||
okText='Delete'
|
||||
cancelText='Cancel'
|
||||
okType='danger'
|
||||
closable={false}
|
||||
centered
|
||||
maskClosable={false}
|
||||
footer={[
|
||||
<Button
|
||||
key='cancel'
|
||||
onClick={cancelDeleteNote}
|
||||
disabled={deleteNoteLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
key='delete'
|
||||
type='primary'
|
||||
danger
|
||||
onClick={confirmDeleteNote}
|
||||
loading={deleteNoteLoading}
|
||||
disabled={deleteNoteLoading}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<Text>Are you sure you want to delete this note?</Text>
|
||||
</Modal>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
DashboardNotes.propTypes = {
|
||||
notes: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
_id: PropTypes.string.isRequired,
|
||||
content: PropTypes.string.isRequired,
|
||||
createdAt: PropTypes.string.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
user: PropTypes.object.isRequired
|
||||
})
|
||||
),
|
||||
onNewNote: PropTypes.func.isRequired
|
||||
onNewNote: PropTypes.func
|
||||
}
|
||||
|
||||
export default DashboardNotes
|
||||
|
||||
@ -1,70 +1,60 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { Layout, Menu, Flex, Button } from 'antd'
|
||||
import { DashboardOutlined, CaretDownFilled } from '@ant-design/icons'
|
||||
import GCodeFileIcon from '../../Icons/GCodeFileIcon'
|
||||
import JobIcon from '../../Icons/JobIcon'
|
||||
import PrinterIcon from '../../Icons/PrinterIcon'
|
||||
import { CaretDownFilled } from '@ant-design/icons'
|
||||
import CollapseSidebarIcon from '../../Icons/CollapseSidebarIcon'
|
||||
import ExpandSidebarIcon from '../../Icons/ExpandSidebarIcon'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const { Sider } = Layout
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'sidebar_collapsed'
|
||||
|
||||
const ProductionSidebar = () => {
|
||||
const location = useLocation()
|
||||
const [selectedKey, setSelectedKey] = useState('production')
|
||||
const DashboardSidebar = ({
|
||||
items = [],
|
||||
selectedKey = '',
|
||||
onCollapse,
|
||||
collapsed: collapsedProp,
|
||||
key = 'DashboardSidebar_collapseState'
|
||||
}) => {
|
||||
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
|
||||
})
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const pathParts = location.pathname.split('/').filter(Boolean)
|
||||
if (pathParts.length > 2) {
|
||||
setSelectedKey(pathParts[2]) // Return the section (production/management)
|
||||
if (typeof collapsedProp === 'boolean') {
|
||||
setCollapsed(collapsedProp)
|
||||
}
|
||||
}, [location.pathname])
|
||||
}, [collapsedProp])
|
||||
|
||||
const handleCollapse = (newCollapsed) => {
|
||||
setCollapsed(newCollapsed)
|
||||
sessionStorage.setItem(SIDEBAR_COLLAPSED_KEY, JSON.stringify(newCollapsed))
|
||||
sessionStorage.setItem(key, JSON.stringify(newCollapsed))
|
||||
if (onCollapse) onCollapse(newCollapsed)
|
||||
}
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: 'overview',
|
||||
label: <Link to='/dashboard/production/overview'>Overview</Link>,
|
||||
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 />
|
||||
// Add onClick to each item
|
||||
const _items = items.map((item) => {
|
||||
if (item?.type == 'divider') {
|
||||
return item
|
||||
}
|
||||
]
|
||||
return {
|
||||
key: item.key,
|
||||
icon: item.icon,
|
||||
label: item.label,
|
||||
onClick: () => navigate(item.path)
|
||||
}
|
||||
})
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Menu
|
||||
mode='horizontal'
|
||||
selectedKeys={[selectedKey]}
|
||||
defaultSelectedKeys={['overview']}
|
||||
items={items}
|
||||
items={_items}
|
||||
_internalDisableMenuItemTitleTooltip
|
||||
overflowedIndicator={<Button type='text' icon={<CaretDownFilled />} />}
|
||||
/>
|
||||
@ -81,8 +71,7 @@ const ProductionSidebar = () => {
|
||||
<Menu
|
||||
mode='inline'
|
||||
selectedKeys={[selectedKey]}
|
||||
defaultSelectedKeys={['overview']}
|
||||
items={items}
|
||||
items={_items}
|
||||
style={{ flexGrow: 1, border: 'none' }}
|
||||
_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,
|
||||
useCallback
|
||||
} 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 PropTypes from 'prop-types'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
@ -18,15 +28,22 @@ const DashboardTable = forwardRef(
|
||||
columns,
|
||||
url,
|
||||
pageSize = 25,
|
||||
scrollHeight = 'calc(100vh - 270px)',
|
||||
scrollHeight = 'calc(var(--unit-100vh) - 270px)',
|
||||
onDataChange,
|
||||
authenticated,
|
||||
initialPage = 1
|
||||
initialPage = 1,
|
||||
cards = false
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
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 tableRef = useRef(null)
|
||||
const [filters, setFilters] = useState({})
|
||||
@ -96,6 +113,7 @@ const DashboardTable = forwardRef(
|
||||
|
||||
setLoading(false)
|
||||
setLazyLoading(false)
|
||||
return newData
|
||||
} catch (error) {
|
||||
setPages((prev) =>
|
||||
prev.map((page) => ({
|
||||
@ -223,15 +241,19 @@ const DashboardTable = forwardRef(
|
||||
[fetchData, totalPages]
|
||||
)
|
||||
|
||||
const loadInitialPage = useCallback(() => {
|
||||
const loadInitialPage = useCallback(async () => {
|
||||
// Create initial page with skeletons
|
||||
setPages([
|
||||
{ pageNum: initialPage, items: createSkeletonData() },
|
||||
setPages([{ pageNum: initialPage, items: createSkeletonData() }])
|
||||
|
||||
const items = await fetchData(initialPage)
|
||||
|
||||
if (items.length >= 25) {
|
||||
setPages((prev) => [
|
||||
...prev,
|
||||
{ pageNum: initialPage + 1, items: createSkeletonData() }
|
||||
])
|
||||
|
||||
// Fetch both pages
|
||||
return Promise.all([fetchData(initialPage), fetchData(initialPage + 1)])
|
||||
await fetchData(initialPage + 1)
|
||||
}
|
||||
}, [initialPage, createSkeletonData, fetchData])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
@ -281,9 +303,91 @@ const DashboardTable = forwardRef(
|
||||
// Flatten pages array for table display
|
||||
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 (
|
||||
<>
|
||||
{contextHolder}
|
||||
{cards ? (
|
||||
<Spin indicator={<LoadingOutlined />} spinning={loading}>
|
||||
{renderCards()}
|
||||
</Spin>
|
||||
) : (
|
||||
<Table
|
||||
ref={tableRef}
|
||||
dataSource={tableData}
|
||||
@ -298,6 +402,7 @@ const DashboardTable = forwardRef(
|
||||
showSorterTooltip={false}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -312,7 +417,9 @@ DashboardTable.propTypes = {
|
||||
scrollHeight: PropTypes.string,
|
||||
onDataChange: PropTypes.func,
|
||||
authenticated: PropTypes.bool.isRequired,
|
||||
initialPage: PropTypes.number
|
||||
initialPage: PropTypes.number,
|
||||
cards: PropTypes.bool,
|
||||
cardRenderer: PropTypes.func
|
||||
}
|
||||
|
||||
export default DashboardTable
|
||||
|
||||
@ -1,205 +1,28 @@
|
||||
// FilamentSelect.js
|
||||
import { TreeSelect, Badge } from 'antd'
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import axios from 'axios'
|
||||
|
||||
import config from '../../../config'
|
||||
import ObjectSelect from './ObjectSelect'
|
||||
|
||||
const propertyOrder = ['diameter', 'type', 'vendor.name']
|
||||
|
||||
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 (
|
||||
<TreeSelect
|
||||
treeDataSimpleMode
|
||||
value={defaultValue?._id}
|
||||
loadData={handleFilamentsTreeLoad}
|
||||
treeData={filamentsTreeData}
|
||||
onChange={handleOnChange}
|
||||
loading={loading}
|
||||
<ObjectSelect
|
||||
endpoint={`${config.backendUrl}/filaments`}
|
||||
propertyOrder={propertyOrder}
|
||||
filter={filter}
|
||||
useFilter={useFilter}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder='Select Filament'
|
||||
type={'filament'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
FilamentSelect.propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func,
|
||||
value: PropTypes.object,
|
||||
filter: PropTypes.object,
|
||||
useFilter: PropTypes.bool
|
||||
|
||||
@ -77,7 +77,7 @@ const FilamentStockState = ({
|
||||
}, [currentState])
|
||||
|
||||
return (
|
||||
<Flex gap='middle' align={'center'}>
|
||||
<Flex gap='small' align={'center'} wrap>
|
||||
{showStatus && (
|
||||
<Space>
|
||||
<Tag color={badgeStatus} style={{ marginRight: 0 }}>
|
||||
|
||||
@ -1,12 +1,8 @@
|
||||
// GCodeFileSelect.js
|
||||
import PropTypes from 'prop-types'
|
||||
import { TreeSelect, Badge, Flex, message, Typography } from 'antd'
|
||||
import React, { useEffect, useState, useContext } from 'react'
|
||||
import axios from 'axios'
|
||||
import GCodeFileIcon from '../../Icons/GCodeFileIcon'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
|
||||
import React from 'react'
|
||||
import config from '../../../config'
|
||||
import ObjectSelect from './ObjectSelect'
|
||||
|
||||
const propertyOrder = [
|
||||
'filament.diameter',
|
||||
@ -14,205 +10,25 @@ const propertyOrder = [
|
||||
'filament.vendor.name'
|
||||
]
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
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 (
|
||||
<TreeSelect
|
||||
showSearch
|
||||
treeDataSimpleMode
|
||||
loadData={handleGCodeFilesTreeLoad}
|
||||
treeData={gcodeFilesTreeData}
|
||||
<ObjectSelect
|
||||
endpoint={`${config.backendUrl}/gcodefiles`}
|
||||
propertyOrder={propertyOrder}
|
||||
filter={filter}
|
||||
useFilter={useFilter}
|
||||
onChange={onChange}
|
||||
onSearch={handleGCodeFilesSearch}
|
||||
loading={loading}
|
||||
placeholder='Select GCode File'
|
||||
showSearch={true}
|
||||
style={style}
|
||||
placeholder='Select GCode File'
|
||||
type='gcodefile'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
GCodeFileSelect.propTypes = {
|
||||
onChange: PropTypes.func,
|
||||
filter: PropTypes.string,
|
||||
filter: PropTypes.object,
|
||||
useFilter: PropTypes.bool,
|
||||
style: PropTypes.object
|
||||
}
|
||||
|
||||
@ -1,27 +1,20 @@
|
||||
// PrinterSelect.js
|
||||
import React from 'react'
|
||||
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 { useMediaQuery } from 'react-responsive'
|
||||
import CopyIcon from '../../Icons/CopyIcon'
|
||||
import PrinterIcon from '../../Icons/PrinterIcon'
|
||||
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
|
||||
import FilamentIcon from '../../Icons/FilamentIcon'
|
||||
import FilamentStockIcon from '../../Icons/FilamentStockIcon'
|
||||
import GCodeFileIcon from '../../Icons/GCodeFileIcon'
|
||||
import JobIcon from '../../Icons/JobIcon'
|
||||
import PartIcon from '../../Icons/PartIcon'
|
||||
import ProductIcon from '../../Icons/ProductIcon'
|
||||
import VendorIcon from '../../Icons/VendorIcon'
|
||||
import SubJobIcon from '../../Icons/SubJobIcon'
|
||||
import StockEventIcon from '../../Icons/StockEventIcon'
|
||||
import StockAuditIcon from '../../Icons/StockAuditIcon'
|
||||
import PartStockIcon from '../../Icons/PartStockIcon'
|
||||
import ProductStockIcon from '../../Icons/ProductStockIcon'
|
||||
import AuditLogIcon from '../../Icons/AuditLogIcon'
|
||||
import PersonIcon from '../../Icons/PersonIcon'
|
||||
import NoteTypeIcon from '../../Icons/NoteTypeIcon'
|
||||
import SpotlightTooltip from './SpotlightTooltip'
|
||||
import { getTypeMeta } from '../utils/Utils'
|
||||
|
||||
const { Text, Link } = Typography
|
||||
|
||||
@ -30,107 +23,18 @@ const IdText = ({
|
||||
type,
|
||||
showCopy = true,
|
||||
longId = true,
|
||||
showHyperlink = false
|
||||
showHyperlink = false,
|
||||
showSpotlight = true
|
||||
}) => {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
var prefix = 'UNK'
|
||||
var hyperlink = '#'
|
||||
var icon = <QuestionCircleIcon style={{ paddingTop: '4px' }} />
|
||||
|
||||
switch (type) {
|
||||
case 'printer':
|
||||
prefix = 'PRN'
|
||||
hyperlink = `/dashboard/production/printers/info?printerId=${id}`
|
||||
icon = <PrinterIcon style={{ paddingTop: '4px' }} />
|
||||
break
|
||||
case 'filament':
|
||||
prefix = 'FIL'
|
||||
hyperlink = `/dashboard/management/filaments/info?filamentId=${id}`
|
||||
icon = <FilamentIcon style={{ paddingTop: '4px' }} />
|
||||
break
|
||||
case 'spool':
|
||||
prefix = 'SPL'
|
||||
hyperlink = `/dashboard/inventory/spool/info?spoolId=${id}`
|
||||
icon = <FilamentIcon style={{ paddingTop: '4px' }} />
|
||||
break
|
||||
case 'gcodefile':
|
||||
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' }} />
|
||||
}
|
||||
const meta = getTypeMeta(type)
|
||||
const prefix = meta.prefix
|
||||
const hyperlink = meta.url(id)
|
||||
const IconComponent = meta.icon
|
||||
const icon = <IconComponent style={{ paddingTop: '4px' }} />
|
||||
|
||||
id = typeof id === 'string' ? id.toString().toUpperCase() : 'XXXXXX'
|
||||
var displayId = prefix + ':' + id
|
||||
@ -141,10 +45,22 @@ const IdText = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex align={'center'} gap={'small'}>
|
||||
<Flex align={'center'} gap={'small'} className='idtext'>
|
||||
{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
|
||||
onClick={() => {
|
||||
if (showHyperlink) {
|
||||
@ -159,16 +75,51 @@ const IdText = ({
|
||||
</Space>
|
||||
</Text>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!showHyperlink && (
|
||||
</Popover>
|
||||
) : (
|
||||
<Link
|
||||
onClick={() => {
|
||||
if (showHyperlink) {
|
||||
navigate(hyperlink)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text code ellipsis>
|
||||
<Space size={4}>
|
||||
{icon}
|
||||
{displayId}
|
||||
</Space>
|
||||
</Text>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{!showHyperlink &&
|
||||
(showSpotlight ? (
|
||||
<Popover
|
||||
content={
|
||||
id && type ? (
|
||||
<SpotlightTooltip query={prefix + ':' + id} type={type} />
|
||||
) : null
|
||||
}
|
||||
trigger={['hover', 'click']}
|
||||
placement='topLeft'
|
||||
arrow={false}
|
||||
>
|
||||
<Text code ellipsis>
|
||||
<Space size={4}>
|
||||
{icon}
|
||||
{displayId}
|
||||
</Space>
|
||||
</Text>
|
||||
</Popover>
|
||||
) : (
|
||||
<Text code ellipsis>
|
||||
<Space size={4}>
|
||||
{icon}
|
||||
{displayId}
|
||||
</Space>
|
||||
</Text>
|
||||
))}
|
||||
{showCopy && (
|
||||
<Tooltip title='Copy ID' arrow={false}>
|
||||
<Button
|
||||
@ -176,14 +127,44 @@ const IdText = ({
|
||||
type='text'
|
||||
style={{ height: '22px' }}
|
||||
onClick={() => {
|
||||
const doCopy = (text) => {
|
||||
if (
|
||||
navigator &&
|
||||
navigator.clipboard &&
|
||||
navigator.clipboard.writeText
|
||||
) {
|
||||
navigator.clipboard
|
||||
.writeText(copyId)
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
messageApi.success('ID copied to clipboard')
|
||||
})
|
||||
.catch(() => {
|
||||
messageApi.error('Failed to copy ID')
|
||||
})
|
||||
} else if (
|
||||
document.queryCommandSupported &&
|
||||
document.queryCommandSupported('copy')
|
||||
) {
|
||||
// Legacy fallback
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'absolute'
|
||||
textarea.style.left = '-9999px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
messageApi.success('ID copied to clipboard')
|
||||
} catch (err) {
|
||||
messageApi.error('Failed to copy ID')
|
||||
}
|
||||
document.body.removeChild(textarea)
|
||||
} else {
|
||||
messageApi.error('Copy not supported in this browser')
|
||||
}
|
||||
}
|
||||
doCopy(copyId)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
@ -197,7 +178,8 @@ IdText.propTypes = {
|
||||
type: PropTypes.string,
|
||||
showCopy: PropTypes.bool,
|
||||
longId: PropTypes.bool,
|
||||
showHyperlink: PropTypes.bool
|
||||
showHyperlink: PropTypes.bool,
|
||||
showSpotlight: PropTypes.bool
|
||||
}
|
||||
|
||||
export default IdText
|
||||
|
||||
@ -1,41 +1,33 @@
|
||||
import React from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Typography, List, Space } from 'antd'
|
||||
import { Typography, Space } from 'antd'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const { Title, Paragraph, Text } = Typography
|
||||
|
||||
const UlComponent = ({ children }) => (
|
||||
<List
|
||||
size='small'
|
||||
dataSource={children}
|
||||
renderItem={(item) => <List.Item>{item}</List.Item>}
|
||||
/>
|
||||
<ul style={{ paddingLeft: '20px', margin: 0 }}>{children}</ul>
|
||||
)
|
||||
UlComponent.propTypes = { children: PropTypes.node }
|
||||
UlComponent.propTypes = {
|
||||
children: PropTypes.node
|
||||
}
|
||||
|
||||
const OlComponent = ({ children }) => (
|
||||
<List
|
||||
size='small'
|
||||
dataSource={children}
|
||||
renderItem={(item) => <List.Item>{item}</List.Item>}
|
||||
/>
|
||||
<ol style={{ paddingLeft: '20px', margin: 0 }}>{children}</ol>
|
||||
)
|
||||
OlComponent.propTypes = { children: PropTypes.node }
|
||||
OlComponent.propTypes = {
|
||||
children: PropTypes.node
|
||||
}
|
||||
|
||||
const LiComponent = ({ children }) => <List.Item>{children}</List.Item>
|
||||
LiComponent.propTypes = { children: PropTypes.node }
|
||||
const LiComponent = ({ children }) => <li>{children}</li>
|
||||
LiComponent.propTypes = {
|
||||
children: PropTypes.node
|
||||
}
|
||||
|
||||
const BlockquoteComponent = ({ children }) => (
|
||||
<Paragraph
|
||||
style={{
|
||||
borderLeft: '4px solid #f0f0f0',
|
||||
paddingLeft: '16px',
|
||||
margin: '16px 0'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<Paragraph>
|
||||
<blockquote>{children}</blockquote>
|
||||
</Paragraph>
|
||||
)
|
||||
BlockquoteComponent.propTypes = { children: PropTypes.node }
|
||||
@ -59,22 +51,18 @@ const MarkdownDisplay = ({ content }) => {
|
||||
<Text code {...props} />
|
||||
) : (
|
||||
<Paragraph>
|
||||
<pre
|
||||
style={{
|
||||
background: '#f5f5f5',
|
||||
padding: '16px',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
<code {...props} />
|
||||
</pre>
|
||||
<pre {...props} />
|
||||
</Paragraph>
|
||||
),
|
||||
blockquote: BlockquoteComponent
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction='vertical' style={{ width: '100%' }}>
|
||||
<Space
|
||||
direction='vertical'
|
||||
style={{ width: '100%' }}
|
||||
className={'markdown-display'}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
|
||||
{content}
|
||||
</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
|
||||
import { TreeSelect, Badge } from 'antd'
|
||||
import React, { useEffect, useState, useContext, useRef } from 'react'
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import axios from 'axios'
|
||||
import { AuthContext } from '../../Auth/AuthContext'
|
||||
import { Badge } from 'antd'
|
||||
import config from '../../../config'
|
||||
import ObjectSelect from './ObjectSelect'
|
||||
|
||||
const propertyOrder = ['diameter', 'type', 'brand']
|
||||
|
||||
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 (
|
||||
<TreeSelect
|
||||
treeDataSimpleMode
|
||||
loadData={handlePartsTreeLoad}
|
||||
treeData={partsTreeData}
|
||||
<ObjectSelect
|
||||
endpoint={`${config.backendUrl}/parts`}
|
||||
propertyOrder={propertyOrder}
|
||||
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}
|
||||
loading={loading}
|
||||
placeholder='Select Part'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -7,13 +7,13 @@ import {
|
||||
Collapse,
|
||||
InputNumber,
|
||||
Descriptions,
|
||||
Button,
|
||||
Tag
|
||||
Button
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
||||
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||
import { SocketContext } from '../context/SocketContext'
|
||||
import styled from 'styled-components'
|
||||
import PropTypes from 'prop-types'
|
||||
import BoolDisplay from './BoolDisplay'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@ -76,7 +76,7 @@ const PrinterPositionPanel = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (!initialized && socket.connected) {
|
||||
if (!initialized && socket?.connected) {
|
||||
setInitialized(true)
|
||||
|
||||
socket.on('connect', () => {
|
||||
@ -93,7 +93,7 @@ const PrinterPositionPanel = ({
|
||||
setExtrudeFactor(positionData.extrude_factor)
|
||||
|
||||
return () => {
|
||||
if (socket.connected && initialized && shouldUnsubscribe) {
|
||||
if (socket?.connected && initialized && shouldUnsubscribe) {
|
||||
socket.off('notify_status_update', notifyPositionStatusUpdate)
|
||||
socket.emit('printer.objects.unsubscribe', params)
|
||||
}
|
||||
@ -241,10 +241,13 @@ const PrinterPositionPanel = ({
|
||||
{positionData.speed.toFixed(2)}mm/s
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label='Absolute Coordinates'>
|
||||
{positionData.absolute_coordinates ? (
|
||||
<Tag color='green'>Yes</Tag>
|
||||
{positionData ? (
|
||||
<BoolDisplay
|
||||
value={positionData.absolute_coordinates}
|
||||
yesNo={true}
|
||||
/>
|
||||
) : (
|
||||
<Tag color='red'>No</Tag>
|
||||
<Text>n/a</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
@ -256,7 +259,7 @@ const PrinterPositionPanel = ({
|
||||
size='small'
|
||||
items={moreInfoItems}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretRightOutlined rotate={isActive ? 90 : 0} />
|
||||
<CaretLeftOutlined rotate={isActive ? 90 : 0} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -1,136 +1,36 @@
|
||||
// PrinterSelect.js
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { TreeSelect, message, 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 { Tag } from 'antd'
|
||||
import config from '../../../config'
|
||||
import ObjectSelect from './ObjectSelect'
|
||||
import PrinterState from './PrinterState'
|
||||
|
||||
const PrinterSelect = ({ onChange, disabled, checkable, value = [] }) => {
|
||||
const [printersTreeData, setPrintersTreeData] = useState([])
|
||||
const [printersData, setPrintersData] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [messageApi] = message.useMessage()
|
||||
const [defaultValue, setDefaultValue] = useState(value)
|
||||
|
||||
const { authenticated } = useContext(AuthContext)
|
||||
|
||||
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.'
|
||||
const PrinterSelect = ({ onChange, disabled }) => {
|
||||
// getTitle: if isLeaf, render PrinterState, else render Tag or 'Untagged'
|
||||
const getTitle = (item, isLeaf) =>
|
||||
isLeaf ? (
|
||||
<PrinterState printer={item} showProgress={false} showControls={false} />
|
||||
) : item === 'Untagged' ? (
|
||||
'Untagged'
|
||||
) : (
|
||||
<Tag color='blue'>{item}</Tag>
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [authenticated, messageApi])
|
||||
|
||||
const generatePrinterItems = useCallback(async () => {
|
||||
const printerData = await fetchPrintersTreeData()
|
||||
setPrintersData(printerData)
|
||||
|
||||
// Create a map to store tags and their printers
|
||||
const tagMap = new Map()
|
||||
|
||||
// Add printers to their respective tag groups
|
||||
printerData.forEach((printer) => {
|
||||
if (printer.tags && printer.tags.length > 0) {
|
||||
printer.tags.forEach((tag) => {
|
||||
if (!tagMap.has(tag)) {
|
||||
tagMap.set(tag, [])
|
||||
}
|
||||
tagMap.get(tag).push(printer)
|
||||
})
|
||||
} else {
|
||||
// If no tags, add to "Untagged" group
|
||||
if (!tagMap.has('Untagged')) {
|
||||
tagMap.set('Untagged', [])
|
||||
}
|
||||
tagMap.get('Untagged').push(printer)
|
||||
}
|
||||
})
|
||||
|
||||
// Convert the map to tree data structure
|
||||
Array.from(tagMap.entries()).map(([tag, printers]) => {
|
||||
const newNode = {
|
||||
title: tag === 'Untagged' ? tag : <Tag color='blue'>{tag}</Tag>,
|
||||
value: `tag-${tag}`,
|
||||
key: `tag-${tag}`,
|
||||
children: printers.map((printer) => ({
|
||||
title: (
|
||||
<PrinterState
|
||||
printer={printer}
|
||||
showProgress={false}
|
||||
showControls={false}
|
||||
/>
|
||||
),
|
||||
value: printer._id,
|
||||
key: printer._id
|
||||
}))
|
||||
}
|
||||
setPrintersTreeData((prev) => {
|
||||
const filtered = prev.filter((node) => node.key !== newNode.key)
|
||||
return [...filtered, newNode]
|
||||
})
|
||||
})
|
||||
}, [fetchPrintersTreeData])
|
||||
|
||||
const handleOnChange = (value, selectedOptions) => {
|
||||
if (checkable) {
|
||||
// Multiple selection mode
|
||||
const newValue = printersData.filter((printer) =>
|
||||
value.includes(printer._id)
|
||||
)
|
||||
setDefaultValue(newValue)
|
||||
onChange(newValue, selectedOptions)
|
||||
} else {
|
||||
// Single selection mode
|
||||
const selectedPrinter = printersData.find(
|
||||
(printer) => printer._id === value
|
||||
)
|
||||
setDefaultValue(selectedPrinter ? [selectedPrinter] : [])
|
||||
onChange(selectedPrinter, selectedOptions)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (authenticated) {
|
||||
generatePrinterItems()
|
||||
}
|
||||
}, [authenticated, generatePrinterItems])
|
||||
// getValue/getKey: for leaf, use _id; for tag, use tag string
|
||||
const getValue = (item, isLeaf) => (isLeaf ? item._id : item)
|
||||
const getKey = (item, isLeaf) => (isLeaf ? item._id : item)
|
||||
|
||||
return (
|
||||
<TreeSelect
|
||||
treeData={printersTreeData}
|
||||
onChange={handleOnChange}
|
||||
loading={loading}
|
||||
<ObjectSelect
|
||||
endpoint={`${config.backendUrl}/printers`}
|
||||
propertyOrder={['tags']}
|
||||
getTitle={getTitle}
|
||||
getValue={getValue}
|
||||
getKey={getKey}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
treeDefaultExpandAll
|
||||
treeCheckable={checkable}
|
||||
treeNodeFilterProp='title'
|
||||
placeholder='Select Printer'
|
||||
style={{ width: '100%' }}
|
||||
value={
|
||||
checkable ? defaultValue.map((item) => item._id) : defaultValue?._id
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
} from 'antd'
|
||||
import React, { useState, useContext, useEffect } from 'react'
|
||||
import { SocketContext } from '../context/SocketContext'
|
||||
import { CaretRightOutlined } from '@ant-design/icons'
|
||||
import { CaretLeftOutlined } from '@ant-design/icons'
|
||||
import XMarkIcon from '../../Icons/XMarkIcon'
|
||||
import PauseIcon from '../../Icons/PauseIcon'
|
||||
|
||||
@ -163,7 +163,7 @@ const PrinterState = ({
|
||||
style={{ fontSize: '12px', marginBottom: '3px' }}
|
||||
/>
|
||||
) : (
|
||||
<CaretRightOutlined
|
||||
<CaretLeftOutlined
|
||||
style={{ fontSize: '10px', marginBottom: '3px' }}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
InputNumber,
|
||||
Button
|
||||
} from 'antd'
|
||||
import { LoadingOutlined, CaretRightOutlined } from '@ant-design/icons'
|
||||
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
||||
import { SocketContext } from '../context/SocketContext'
|
||||
import styled from 'styled-components'
|
||||
import PropTypes from 'prop-types'
|
||||
@ -96,7 +96,7 @@ const PrinterTemperaturePanel = ({
|
||||
heater_bed: null // eslint-disable-line
|
||||
}
|
||||
}
|
||||
if (socket.connected == true) {
|
||||
if (socket?.connected == true) {
|
||||
console.log('Printer Temperature Panel is subscribing...')
|
||||
socket.emit('printer.objects.subscribe', params)
|
||||
socket.emit('printer.objects.query', params)
|
||||
@ -109,13 +109,7 @@ const PrinterTemperaturePanel = ({
|
||||
socket.emit('printer.objects.unsubscribe', params)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
socket,
|
||||
socket.connected,
|
||||
printerId,
|
||||
notifyTemperatureStatusUpdate,
|
||||
shouldUnsubscribe
|
||||
])
|
||||
}, [socket, printerId, notifyTemperatureStatusUpdate, shouldUnsubscribe])
|
||||
|
||||
const handleSetTemperatureClick = (target, value) => {
|
||||
if (socket) {
|
||||
@ -290,7 +284,7 @@ const PrinterTemperaturePanel = ({
|
||||
size='small'
|
||||
items={moreInfoItems}
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
setStockEventsData(stockEvents)
|
||||
}, [stockEvents])
|
||||
|
||||
const getTypeFilterProps = () => {
|
||||
// Get unique types from the data
|
||||
const uniqueTypes = [
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import PropTypes from 'prop-types'
|
||||
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 { SocketContext } from '../context/SocketContext'
|
||||
import IdText from './IdText'
|
||||
@ -84,11 +84,7 @@ const SubJobState = ({
|
||||
return (
|
||||
<Flex gap='small' align={'center'}>
|
||||
{showId && (
|
||||
<IdText
|
||||
id={subJob.number.toString().padStart(6, '0')}
|
||||
showCopy={false}
|
||||
type='subjob'
|
||||
/>
|
||||
<IdText id={subJob._id} showCopy={false} type='subjob' longId={false} />
|
||||
)}
|
||||
{showStatus && (
|
||||
<Space>
|
||||
@ -136,7 +132,7 @@ const SubJobState = ({
|
||||
style={{ fontSize: '12px', marginBottom: '3px' }}
|
||||
/>
|
||||
) : (
|
||||
<CaretRightOutlined
|
||||
<CaretLeftOutlined
|
||||
style={{ fontSize: '10px', marginBottom: '3px' }}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -58,7 +58,7 @@ const SubJobsTree = ({ jobData, loading }) => {
|
||||
title: (
|
||||
<Space>
|
||||
<SubJobIcon />
|
||||
{'Sub Job'}
|
||||
{'Sub Job #' + subJob?.number.toString().padStart(2, '0')}
|
||||
<SubJobState subJob={subJob} showProgress={true} />
|
||||
</Space>
|
||||
),
|
||||
|
||||
@ -40,7 +40,8 @@ const TimeDisplay = ({
|
||||
dateTime,
|
||||
showDate = true,
|
||||
showTime = true,
|
||||
showSince = false
|
||||
showSince = false,
|
||||
type = 'primary'
|
||||
}) => {
|
||||
const [timeAgo, setTimeAgo] = useState(formatTimeDifference(dateTime))
|
||||
|
||||
@ -66,8 +67,8 @@ const TimeDisplay = ({
|
||||
|
||||
return (
|
||||
<Flex align={'center'} gap={'small'}>
|
||||
<Text>{formattedDate}</Text>
|
||||
{showSince ? <Tag>{timeAgo + ' ago'}</Tag> : null}
|
||||
<Text type={type}>{formattedDate}</Text>
|
||||
{showSince ? <Tag style={{ margin: 0 }}>{timeAgo + ' ago'}</Tag> : null}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@ -76,7 +77,8 @@ TimeDisplay.propTypes = {
|
||||
dateTime: PropTypes.string,
|
||||
showDate: PropTypes.bool,
|
||||
showTime: PropTypes.bool,
|
||||
showSince: PropTypes.bool
|
||||
showSince: PropTypes.bool,
|
||||
type: PropTypes.string
|
||||
}
|
||||
|
||||
export default TimeDisplay
|
||||
|
||||
0
src/components/Dashboard/common/TypeDisplay.jsx
Normal file
@ -158,6 +158,7 @@ const VendorSelect = ({ onChange, filter = {}, useFilter = false, value }) => {
|
||||
}, [vendorsTreeData])
|
||||
|
||||
useEffect(() => {
|
||||
console.log('value', value)
|
||||
if (value?.name) {
|
||||
setDefaultValue(value.name)
|
||||
}
|
||||
|
||||
@ -215,7 +215,7 @@ const AuthProvider = ({ children }) => {
|
||||
open={showSessionExpiredModal}
|
||||
onOk={handleSessionExpiredModalOk}
|
||||
okText='Log In'
|
||||
style={{ maxWidth: 430, top: '50%', transform: 'translateY(-50%)' }}
|
||||
style={{ maxWidth: 430 }}
|
||||
closable={false}
|
||||
centered
|
||||
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 axios from 'axios'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import PrinterState from '../common/PrinterState'
|
||||
import JobState from '../common/JobState'
|
||||
import IdText from '../common/IdText'
|
||||
|
||||
import config from '../../../config'
|
||||
import JobIcon from '../../Icons/JobIcon'
|
||||
import PrinterIcon from '../../Icons/PrinterIcon'
|
||||
import { getTypeMeta, getPrefixMeta } from '../utils/Utils'
|
||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||
import FilamentStockState from '../common/FilamentStockState'
|
||||
import SubJobState from '../common/SubJobState'
|
||||
|
||||
const SpotlightContext = createContext()
|
||||
|
||||
const SpotlightProvider = ({ children }) => {
|
||||
const { Text } = Typography
|
||||
const navigate = useNavigate()
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [listData, setListData] = useState([])
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const [inputPrefix, setInputPrefix] = useState('')
|
||||
const [inputPrefix, setInputPrefix] = useState({ prefix: '', mode: null })
|
||||
|
||||
// Refs for throttling/debouncing
|
||||
const lastFetchTime = useRef(0)
|
||||
@ -35,15 +49,55 @@ const SpotlightProvider = ({ children }) => {
|
||||
|
||||
// Set prefix based on default query if provided
|
||||
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)
|
||||
} 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
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
if (!searchQuery || !searchQuery.trim()) return
|
||||
|
||||
@ -55,7 +109,29 @@ const SpotlightProvider = ({ children }) => {
|
||||
|
||||
setLoading(true)
|
||||
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())}`,
|
||||
{
|
||||
headers: {
|
||||
@ -64,8 +140,20 @@ const SpotlightProvider = ({ children }) => {
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Check if there's a pending query after this fetch completes
|
||||
if (pendingQuery.current !== null) {
|
||||
@ -121,44 +209,18 @@ const SpotlightProvider = ({ children }) => {
|
||||
}, 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 newQuery = formData.query || ''
|
||||
setQuery(newQuery)
|
||||
|
||||
// Detect and set the appropriate prefix
|
||||
detectAndSetPrefix(inputPrefix + newQuery)
|
||||
// Build the full search query with prefix if available
|
||||
let fullQuery = newQuery
|
||||
if (inputPrefix) {
|
||||
fullQuery = inputPrefix.prefix + inputPrefix.mode + newQuery
|
||||
}
|
||||
|
||||
// Check if we need to fetch data
|
||||
checkAndFetchData(inputPrefix + newQuery)
|
||||
checkAndFetchData(fullQuery)
|
||||
}
|
||||
|
||||
// Focus the input element
|
||||
@ -181,46 +243,105 @@ const SpotlightProvider = ({ children }) => {
|
||||
if (!value || value.trim() === '') {
|
||||
// Only clear the prefix if the input is completely empty
|
||||
if (value === '') {
|
||||
console.log('Clearning prefix')
|
||||
setInputPrefix('')
|
||||
console.log('Clearing prefix')
|
||||
setInputPrefix(null)
|
||||
}
|
||||
if (formRef.current) {
|
||||
formRef.current.setFieldsValue({ query: value })
|
||||
}
|
||||
return
|
||||
}
|
||||
// If the user is typing and it doesn't have a prefix yet
|
||||
else if (!inputPrefix) {
|
||||
console.log('No prefix')
|
||||
// Check for prefixes at the beginning of the input
|
||||
|
||||
// Check if the input contains a prefix (format: XXX:, XXX?, or XXX^)
|
||||
const upperValue = value.toUpperCase()
|
||||
const prefixInfo = parsePrefix(upperValue)
|
||||
|
||||
if (upperValue.startsWith('JOB:') || upperValue.startsWith('PRN:')) {
|
||||
const parts = upperValue.split(':')
|
||||
const prefix = parts[0] + ':'
|
||||
const restOfInput = value.substring(prefix.length)
|
||||
|
||||
// Set the prefix and update the input without the prefix
|
||||
setInputPrefix(prefix)
|
||||
// If it's a valid prefix
|
||||
if (prefixInfo) {
|
||||
setInputPrefix(prefixInfo)
|
||||
// Remove the prefix from the input value, keeping only what comes after the mode character
|
||||
const remainingValue = value.substring(4)
|
||||
if (formRef.current) {
|
||||
formRef.current.setFieldsValue({ query: restOfInput })
|
||||
// Ensure input gets focus after prefix is set
|
||||
focusInput()
|
||||
formRef.current.setFieldsValue({ query: remainingValue })
|
||||
}
|
||||
setQuery(remainingValue)
|
||||
return
|
||||
}
|
||||
|
||||
// Update the form value normally
|
||||
if (formRef.current) {
|
||||
formRef.current.setFieldsValue({ query: value })
|
||||
}
|
||||
}
|
||||
|
||||
// Handle key down events for backspace behavior and navigation
|
||||
const handleKeyDown = (e) => {
|
||||
// If backspace is pressed and there's a prefix but the input is empty
|
||||
if (e.key === 'Backspace' && inputPrefix && query === '') {
|
||||
console.log('Clearing prefix on backspace')
|
||||
// Clear the prefix
|
||||
setInputPrefix(null)
|
||||
// Prevent the default backspace behavior in this case
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// Handle navigation shortcuts
|
||||
if (listData.length > 0) {
|
||||
// Enter key - navigate to first item
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
navigateToItem(listData[0])
|
||||
return
|
||||
}
|
||||
|
||||
// Number keys 0-9 - navigate to corresponding item
|
||||
if (/^[0-9]$/.test(e.key)) {
|
||||
e.preventDefault()
|
||||
const index = parseInt(e.key)
|
||||
// 0-9 keys map to items 1-9 (indices 0-8)
|
||||
const itemIndex = index + 1
|
||||
if (itemIndex < listData.length) {
|
||||
navigateToItem(listData[itemIndex])
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle key down events for backspace behavior
|
||||
const handleKeyDown = (e) => {
|
||||
// If backspace is pressed and there's a prefix but the input is empty
|
||||
// Function to navigate to item URL
|
||||
const navigateToItem = (item) => {
|
||||
// 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) {
|
||||
console.log('Query', query)
|
||||
// Clear the prefix
|
||||
setInputPrefix('')
|
||||
// Prevent the default backspace behavior in this case
|
||||
e.preventDefault()
|
||||
const meta = getTypeMeta(type)
|
||||
|
||||
// Get the appropriate ID for the item
|
||||
let itemId = item._id || item.id
|
||||
|
||||
// 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
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
// Only clear data if there's no existing data and no current query
|
||||
if (listData.length === 0 && !query) {
|
||||
setListData([])
|
||||
}
|
||||
focusInput()
|
||||
}
|
||||
}, [inputPrefix, showModal])
|
||||
|
||||
// Update form value when query changes
|
||||
useEffect(() => {
|
||||
if (showModal && formRef.current) {
|
||||
formRef.current.setFieldsValue({ query: query })
|
||||
}
|
||||
}, [query, showModal])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<SpotlightContext.Provider value={{ showSpotlight }}>
|
||||
{contextHolder}
|
||||
@ -280,22 +426,39 @@ const SpotlightProvider = ({ children }) => {
|
||||
onCancel={() => setShowModal(false)}
|
||||
closeIcon={null}
|
||||
footer={null}
|
||||
styles={{ content: { backgroundColor: 'transparent' } }}
|
||||
styles={{ content: { padding: 0 } }}
|
||||
destroyOnHidden={true}
|
||||
>
|
||||
<Flex vertical>
|
||||
<Form ref={formRef} onValuesChange={handleSpotlightChange}>
|
||||
<Form.Item name='query' initialValue={query}>
|
||||
<Form.Item name='query' initialValue={query} style={{ margin: 0 }}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder='Enter a query or scan a barcode...'
|
||||
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={
|
||||
<Flex align='center' gap='small'>
|
||||
{inputPrefix?.mode && (
|
||||
<Text type='secondary' style={{ fontSize: '12px' }}>
|
||||
{getModeDescription(inputPrefix.mode)}
|
||||
</Text>
|
||||
)}
|
||||
<Spin
|
||||
indicator={<LoadingOutlined />}
|
||||
spinning={loading}
|
||||
size='small'
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
@ -304,63 +467,100 @@ const SpotlightProvider = ({ children }) => {
|
||||
</Form>
|
||||
|
||||
{listData.length > 0 && (
|
||||
<div style={{ marginLeft: '18px', marginRight: '14px' }}>
|
||||
<List
|
||||
bordered
|
||||
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.Meta
|
||||
description={
|
||||
<Flex gap={'middle'} align='center'>
|
||||
<Text>
|
||||
{item.printer ? (
|
||||
<PrinterIcon style={{ fontSize: '20px' }} />
|
||||
) : null}
|
||||
{item.job ? (
|
||||
<JobIcon style={{ fontSize: '20px' }} />
|
||||
{Icon ? (
|
||||
<Icon style={{ fontSize: '20px' }} />
|
||||
) : null}
|
||||
</Text>
|
||||
<Flex
|
||||
vertical
|
||||
gap={'6px'}
|
||||
style={{ marginBottom: '2px' }}
|
||||
>
|
||||
<Text>{item.name}</Text>
|
||||
<Flex gap={'small'} style={{ marginBottom: '2px' }}>
|
||||
{item.name ? <Text>{item.name}</Text> : null}
|
||||
|
||||
{item.printer ? (
|
||||
<Flex gap={'small'}>
|
||||
{meta.type == 'printer' ? (
|
||||
<PrinterState
|
||||
printer={item.printer}
|
||||
printer={item}
|
||||
showPrinterName={false}
|
||||
showProgress={false}
|
||||
showId={false}
|
||||
/>
|
||||
<IdText
|
||||
id={item.id}
|
||||
longId={false}
|
||||
type='printer'
|
||||
/>
|
||||
</Flex>
|
||||
) : null}
|
||||
{item.job ? (
|
||||
<Flex gap={'small'}>
|
||||
{item.job.state.type ? (
|
||||
{meta.type == 'job' ? (
|
||||
<JobState
|
||||
job={item.job}
|
||||
job={item}
|
||||
showQuantity={false}
|
||||
showProgress={false}
|
||||
showId={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<IdText id={item.id} longId={false} type='job' />
|
||||
{meta.type == 'subjob' ? (
|
||||
<SubJobState
|
||||
subJob={item}
|
||||
showProgress={false}
|
||||
showId={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{meta.type == 'filamentstock' ? (
|
||||
<Flex gap={'small'}>
|
||||
<FilamentStockState
|
||||
filamentStock={item}
|
||||
showId={false}
|
||||
showProgress={false}
|
||||
/>
|
||||
</Flex>
|
||||
) : null}
|
||||
<IdText
|
||||
id={item._id}
|
||||
type={meta.type}
|
||||
longId={false}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
</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) {
|
||||
try {
|
||||
return string[0].toUpperCase() + string.slice(1)
|
||||
@ -29,3 +48,162 @@ export function timeStringToMinutes(timeString) {
|
||||
// Return the integer value of total minutes
|
||||
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'
|
||||
},
|
||||
production: {
|
||||
backendUrl: 'http://localhost:8080', // Replace with your production backend URL
|
||||
wsUrl: 'ws://localhost:8081' // Replace with your production WebSocket URL
|
||||
backendUrl: 'http://192.168.68.53:8080', // Replace with your production backend URL
|
||||
wsUrl: 'http://192.168.68.53:8081' // Replace with your production WebSocket URL
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,3 +5,7 @@ body {
|
||||
.ant-modal-mask {
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.ant-spin-blur {
|
||||
filter: blur(3px);
|
||||
}
|
||||
|
||||