From 2622fae5558fac489121e4d7b07a7ad40021ee11 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 1 Mar 2026 01:42:27 +0000 Subject: [PATCH] Implemented notifications. --- assets/stylesheets/App.css | 1 + src/App.jsx | 17 +- .../Finance/Invoices/InvoiceInfo.jsx | 6 + .../Finance/Payments/PaymentInfo.jsx | 6 + .../FilamentStocks/FilamentStockInfo.jsx | 6 + .../Inventory/OrderItems/OrderItemInfo.jsx | 6 + .../Inventory/PartStocks/PartStockInfo.jsx | 6 + .../PurchaseOrders/PurchaseOrderInfo.jsx | 6 + .../Inventory/Shipments/ShipmentInfo.jsx | 6 + .../Inventory/StockAudits/StockAuditInfo.jsx | 6 + .../CourierServices/CourierServiceInfo.jsx | 6 + .../Management/Couriers/CourierInfo.jsx | 6 + .../DocumentJobs/DocumentJobInfo.jsx | 6 + .../DocumentPrinters/DocumentPrinterInfo.jsx | 6 + .../DocumentSizes/DocumentSizeInfo.jsx | 6 + .../DocumentTemplateInfo.jsx | 6 + .../Management/Filaments/FilamentInfo.jsx | 6 + .../Dashboard/Management/Files/FileInfo.jsx | 6 + .../Dashboard/Management/Hosts/HostInfo.jsx | 6 + .../Management/NoteTypes/NoteTypeInfo.jsx | 6 + .../Dashboard/Management/Notes/NoteInfo.jsx | 6 + .../Dashboard/Management/Parts/PartInfo.jsx | 9 +- .../Management/Products/ProductInfo.jsx | 9 +- .../Management/TaxRates/TaxRateInfo.jsx | 6 + .../Management/TaxRecords/TaxRecordInfo.jsx | 6 + .../Dashboard/Management/Users/UserInfo.jsx | 6 + .../Management/Vendors/VendorInfo.jsx | 6 + .../Production/GCodeFiles/GCodeFileInfo.jsx | 6 + .../Dashboard/Production/Jobs/JobInfo.jsx | 6 + .../Production/Printers/PrinterInfo.jsx | 6 + .../Production/SubJobs/SubJobInfo.jsx | 6 + .../Dashboard/Sales/Clients/ClientInfo.jsx | 6 + .../Sales/SalesOrders/SalesOrderInfo.jsx | 6 + .../Dashboard/common/DashboardNavigation.jsx | 6 +- .../Dashboard/common/Notification.jsx | 237 +++++++++++++---- .../Dashboard/common/NotificationCenter.jsx | 245 +++++------------- .../Dashboard/common/TimeDisplay.jsx | 2 +- .../Dashboard/common/UserNotifierToggle.jsx | 232 +++++++++++++++++ .../Dashboard/context/ApiServerContext.jsx | 169 +++++++++++- .../Dashboard/context/NotificationContext.jsx | 183 +++++++++++++ .../Dashboard/context/ThemeContext.jsx | 17 ++ src/database/models/Payment.js | 27 +- 42 files changed, 1059 insertions(+), 269 deletions(-) create mode 100644 src/components/Dashboard/common/UserNotifierToggle.jsx create mode 100644 src/components/Dashboard/context/NotificationContext.jsx diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css index 35f31f4..4d85395 100644 --- a/assets/stylesheets/App.css +++ b/assets/stylesheets/App.css @@ -34,6 +34,7 @@ .g2-tooltip-list-item, .ant-picker-input, .ant-picker-header-view button, +.ant-badge, [class*=' ant-radio'] { font-family: 'DM Sans'; } diff --git a/src/App.jsx b/src/App.jsx index a4fcfc6..590d2a3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -24,6 +24,7 @@ import { } from './components/Dashboard/context/ThemeContext' import AppError from './components/App/AppError' import { ApiServerProvider } from './components/Dashboard/context/ApiServerContext.jsx' +import { NotificationProvider } from './components/Dashboard/context/NotificationContext.jsx' import { ElectronProvider } from './components/Dashboard/context/ElectronContext.jsx' import { MessageProvider } from './components/Dashboard/context/MessageContext.jsx' import AuthCallback from './components/App/AuthCallback.jsx' @@ -59,9 +60,10 @@ const AppContent = () => { - - - + + + + { /> } /> - - - - + + + + + diff --git a/src/components/Dashboard/Finance/Invoices/InvoiceInfo.jsx b/src/components/Dashboard/Finance/Invoices/InvoiceInfo.jsx index 6b6b3a7..3239c2a 100644 --- a/src/components/Dashboard/Finance/Invoices/InvoiceInfo.jsx +++ b/src/components/Dashboard/Finance/Invoices/InvoiceInfo.jsx @@ -22,6 +22,7 @@ import ObjectActions from '../../common/ObjectActions.jsx' import ObjectTable from '../../common/ObjectTable.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import DocumentPrintButton from '../../common/DocumentPrintButton.jsx' +import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' import ScrollBox from '../../common/ScrollBox.jsx' import { getModelByName, @@ -134,6 +135,11 @@ const InvoiceInfo = () => { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { @@ -91,6 +92,11 @@ const FilamentStockInfo = () => { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + diff --git a/src/components/Dashboard/Inventory/PartStocks/PartStockInfo.jsx b/src/components/Dashboard/Inventory/PartStocks/PartStockInfo.jsx index 0e6f6bc..8fb49ef 100644 --- a/src/components/Dashboard/Inventory/PartStocks/PartStockInfo.jsx +++ b/src/components/Dashboard/Inventory/PartStocks/PartStockInfo.jsx @@ -20,6 +20,7 @@ import ObjectActions from '../../common/ObjectActions.jsx' import ObjectTable from '../../common/ObjectTable.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import DocumentPrintButton from '../../common/DocumentPrintButton.jsx' +import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' import ScrollBox from '../../common/ScrollBox.jsx' const log = loglevel.getLogger('PartStockInfo') @@ -97,6 +98,11 @@ const PartStockInfo = () => { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { @@ -83,6 +84,11 @@ const NoteTypeInfo = () => { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { @@ -35,7 +36,8 @@ const PartInfo = () => { editLoading: false, formValid: false, lock: null, - loading: false + loading: false, + objectData: {} }) const actions = { @@ -83,6 +85,11 @@ const PartInfo = () => { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { editLoading: false, formValid: false, lock: null, - loading: false + loading: false, + objectData: {} }) const actions = { @@ -87,6 +89,11 @@ const ProductInfo = () => { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { @@ -84,6 +85,11 @@ const UserInfo = () => { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { visibleState={collapseState} updateVisibleState={updateCollapseState} /> + { const { logout, userProfile } = useContext(AuthContext) const { showSpotlight } = useContext(SpotlightContext) - const { toggleNotificationCenter, unreadCount } = useContext(ApiServerContext) const { connecting, connected } = useContext(ApiServerContext) + const { toggleNotificationCenter, unreadCount } = + useContext(NotificationContext) const [apiServerState, setApiServerState] = useState('disconnected') const navigate = useNavigate() const location = useLocation() @@ -257,7 +259,7 @@ const DashboardNavigation = () => { onClick={() => showSpotlight()} /> - + { +const Notification = ({ + notification, + onMarkAsRead, + onDelete, + showCard = true, + showDelete = true, + showExtraInfo = true, + inlineIcon = false +}) => { + const [deleting, setDeleting] = useState(false) + const getNotificationIcon = (type) => { switch (type) { case 'info': - return - case 'warning': - return + return + case 'editObject': + return + case 'deleteObject': + return case 'error': - return + return case 'success': - return + return default: - return + return } } - const getNotificationColor = (type) => { + const getNotificationTag = (type) => { + const icon = getNotificationIcon(type) switch (type) { case 'info': - return 'blue' - case 'warning': - return 'orange' + return ( + + Info + + ) + case 'editObject': + return ( + + Edit + + ) + case 'deleteObject': + return ( + + Delete + + ) case 'error': - return 'red' + return ( + + Error + + ) case 'success': - return 'green' + return ( + + Success + + ) default: - return 'default' + return ( + + Default + + ) + } + } + + const getMetadataDisplay = (metadata, type) => { + if (metadata.old && metadata.new && type === 'editObject') { + return ( + + ) + } + return null + } + + const getNotificationMessage = (metadata, type) => { + const paragraph = { + ellipsis: { + rows: 2, + expandable: true, + symbol: 'Show more' + }, + style: { margin: 0 } + } + switch (type) { + case 'editObject': { + return ( + + Object: + + User: + + + ) + } + default: + return {notification.message} } } @@ -47,52 +142,81 @@ const Notification = ({ notification, onMarkAsRead }) => { } } - return ( + const handleDelete = async (e) => { + e.stopPropagation() + if (onDelete) { + setDeleting(true) + try { + await onDelete(notification._id) + } finally { + setDeleting(false) + } + } + } + + const content = ( + + + + + {inlineIcon && getNotificationIcon(notification.type)} + + {notification.title} + + + {showDelete && ( + - - - - - - - + -
- {loading ? ( -
- -
- Loading notifications... -
-
+ + {notificationsLoading ? ( + + } /> + Loading notifications... + ) : notifications.length === 0 ? ( - + + + ) : ( - + {notifications.map((notification) => ( ))} )} -
- - setShowDeleteConfirm(false)} - okText='Yes' - cancelText='No' - /> - + +
) } diff --git a/src/components/Dashboard/common/TimeDisplay.jsx b/src/components/Dashboard/common/TimeDisplay.jsx index ff76b3d..c6f0f83 100644 --- a/src/components/Dashboard/common/TimeDisplay.jsx +++ b/src/components/Dashboard/common/TimeDisplay.jsx @@ -108,7 +108,7 @@ const TimeDisplay = ({ return ( - {formattedDate} + {showDate || showTime ? {formattedDate} : null} {showSince ? {timeAgo} : null} ) diff --git a/src/components/Dashboard/common/UserNotifierToggle.jsx b/src/components/Dashboard/common/UserNotifierToggle.jsx new file mode 100644 index 0000000..185f0be --- /dev/null +++ b/src/components/Dashboard/common/UserNotifierToggle.jsx @@ -0,0 +1,232 @@ +import PropTypes from 'prop-types' +import { useState, useEffect, useContext } from 'react' +import { Button, message, Popover, Typography, Space, Flex } from 'antd' +import { UserOutlined } from '@ant-design/icons' +import BellIcon from '../../Icons/BellIcon' +import NewMailIcon from '../../Icons/NewMailIcon' +import { ApiServerContext } from '../context/ApiServerContext' +import { AuthContext } from '../context/AuthContext' +import { LoadingOutlined } from '@ant-design/icons' +import InfoCircleIcon from '../../Icons/InfoCircleIcon' + +const { Text } = Typography + +const UserNotifierToggle = ({ + type, + objectData, + disabled = false, + ...buttonProps +}) => { + const { + toggleUserNotifier, + editUserNotifier, + fetchUserNotifiersForObject, + fetchAllUserNotifiersForObject + } = useContext(ApiServerContext) + const { userProfile } = useContext(AuthContext) + const [isNotifying, setIsNotifying] = useState(false) + const [loading, setLoading] = useState(false) + const [initialLoad, setInitialLoad] = useState(true) + const [allNotifiers, setAllNotifiers] = useState([]) + const [popoverOpen, setPopoverOpen] = useState(false) + const [popoverLoading, setPopoverLoading] = useState(false) + const [emailTogglingId, setEmailTogglingId] = useState(null) + + const objectId = objectData?._id + + useEffect(() => { + const loadNotifierState = async () => { + if (!objectId || !type) return + + setInitialLoad(true) + try { + const { data } = await fetchUserNotifiersForObject(objectId, type) + setIsNotifying(data?.length > 0) + } catch (error) { + console.error('Error fetching user notifier state:', error) + } finally { + setInitialLoad(false) + } + } + + loadNotifierState() + }, [objectId, type, fetchUserNotifiersForObject]) + + useEffect(() => { + const loadAllNotifiers = async () => { + if (!objectId || !type || !popoverOpen) return + + setPopoverLoading(true) + try { + const { data } = await fetchAllUserNotifiersForObject(objectId, type) + setAllNotifiers(data || []) + } catch (error) { + console.error('Error fetching all user notifiers:', error) + setAllNotifiers([]) + } finally { + setPopoverLoading(false) + } + } + + loadAllNotifiers() + }, [objectId, type, popoverOpen, fetchAllUserNotifiersForObject]) + + const handleClick = async () => { + if (!objectId || !type || loading) return + + setLoading(true) + try { + const enabled = await toggleUserNotifier(objectId, type) + setIsNotifying(enabled) + if (popoverOpen) { + const { data } = await fetchAllUserNotifiersForObject(objectId, type) + setAllNotifiers(data || []) + } + message.success( + enabled + ? 'Notifications enabled for this object' + : 'Notifications disabled for this object' + ) + } catch (error) { + console.error('Error toggling user notifier:', error) + message.error('Failed to update notifications') + } finally { + setLoading(false) + } + } + + const getUserDisplayName = (user) => { + if (!user) return 'Unknown' + return ( + user.name || + user.username || + `${user.firstName || ''} ${user.lastName || ''}`.trim() || + user.email || + 'Unknown' + ) + } + + const isCurrentUser = (user) => user?._id === userProfile?._id + + const handleEmailToggle = async (item) => { + if (!isCurrentUser(item.user) || emailTogglingId) return + setEmailTogglingId(item._id) + const newEmail = !item.email + try { + const result = await editUserNotifier(item._id, { email: newEmail }) + if (result) { + setAllNotifiers((prev) => + prev.map((n) => + n._id === item._id ? { ...n, email: result.email ?? newEmail } : n + ) + ) + message.success( + (result.email ?? newEmail) + ? 'Email notifications enabled' + : 'Email notifications disabled' + ) + } + } catch (error) { + console.error('Error toggling email:', error) + message.error('Failed to update email notifications') + } finally { + setEmailTogglingId(null) + } + } + + const popoverContent = ( + + {popoverLoading ? ( + + + Loading, please wait... + + ) : allNotifiers.length === 0 ? ( + + + + + + No users subscribed. + + + ) : ( + <> + {[...allNotifiers] + .sort( + (a, b) => + (isCurrentUser(b.user) ? 1 : 0) - + (isCurrentUser(a.user) ? 1 : 0) + ) + .map((item) => ( + + + + {getUserDisplayName(item.user)} + {isCurrentUser(item.user) && ( + (you) + )} + + +