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 && ( + + } + onClick={handleDelete} + /> + )} + + + {getNotificationMessage(notification.metadata, notification.type)} + + {notification.metadata && + getMetadataDisplay(notification.metadata, notification.type)} + {showExtraInfo && ( + <> + + + {getNotificationTag(notification.type)} + + + > + )} + + + ) + + return showCard ? ( - - - {getNotificationIcon(notification.type)} - - - - - {notification.title} - - {notification.type} - - - - - - - - {notification.message} - - {notification.metadata && ( - - {JSON.stringify(notification.metadata)} - - )} - - + {content} + ) : ( + {content} ) } @@ -107,7 +231,12 @@ Notification.propTypes = { createdAt: PropTypes.string.isRequired, metadata: PropTypes.object }).isRequired, - onMarkAsRead: PropTypes.func + onMarkAsRead: PropTypes.func, + onDelete: PropTypes.func, + showCard: PropTypes.bool, + showDelete: PropTypes.bool, + showExtraInfo: PropTypes.bool, + inlineIcon: PropTypes.bool } export default Notification diff --git a/src/components/Dashboard/common/NotificationCenter.jsx b/src/components/Dashboard/common/NotificationCenter.jsx index 5aaa184..1b59ef8 100644 --- a/src/components/Dashboard/common/NotificationCenter.jsx +++ b/src/components/Dashboard/common/NotificationCenter.jsx @@ -1,174 +1,62 @@ -import { useState, useEffect, useContext, useCallback } from 'react' -import { - Typography, - Space, - Button, - Empty, - Spin, - Popconfirm, - Flex, - Badge, - Dropdown -} from 'antd' -import { useMessageContext } from '../context/MessageContext' -import { - BellOutlined, - DeleteOutlined, - CheckOutlined, - ReloadOutlined -} from '@ant-design/icons' -import axios from 'axios' +import { useContext, useState } from 'react' +import { Typography, Button, Empty, Spin, Flex, Dropdown } from 'antd' +import { LoadingOutlined } from '@ant-design/icons' import PropTypes from 'prop-types' -import { AuthContext } from '../context/AuthContext' -import config from '../../../config' +import { NotificationContext } from '../context/NotificationContext' import Notification from './Notification' +import ScrollBox from './ScrollBox' +import CheckIcon from '../../Icons/CheckIcon' +import BinIcon from '../../Icons/BinIcon' const { Text } = Typography const NotificationCenter = ({ visible }) => { - const [notifications, setNotifications] = useState([]) - const [loading, setLoading] = useState(false) - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) - const { showSuccess, showError } = useMessageContext() + const { + notifications, + markNotificationAsRead, + markAllNotificationsAsRead, + deleteNotification, + deleteAllNotifications, + notificationsLoading + } = useContext(NotificationContext) - const { authenticated } = useContext(AuthContext) + const unreadCount = notifications.filter((n) => !n.read).length - const fetchNotifications = useCallback(async () => { - if (!authenticated) return + const [clearNotificationsLoading, setClearNotificationsLoading] = + useState(false) - 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) - showError('Failed to fetch notifications') - } finally { - setLoading(false) - } - }, [authenticated, showError]) + const handleMarkAsRead = async (notificationId) => { + await markNotificationAsRead(notificationId) + } - 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 - }) - ) - - showSuccess('Notification marked as read') - } catch (error) { - console.error('Error marking notification as read:', error) - showError('Failed to mark notification as read') - } - }, - [showSuccess, showError] - ) - - 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 })) - ) - - showSuccess('All notifications marked as read') - } catch (error) { - console.error('Error marking all notifications as read:', error) - showError('Failed to mark all notifications as read') - } - }, [showSuccess, showError]) - - const deleteAllNotifications = useCallback(async () => { - try { - await axios.delete(`${config.backendUrl}/notifications`, { - headers: { - Accept: 'application/json' - }, - withCredentials: true - }) - - setNotifications([]) - showSuccess('All notifications deleted') - } catch (error) { - console.error('Error deleting all notifications:', error) - showError('Failed to delete all notifications') - } finally { - setShowDeleteConfirm(false) - } - }, [showSuccess, showError]) - - useEffect(() => { - if (visible && authenticated) { - fetchNotifications() - } - }, [visible, authenticated, fetchNotifications]) - - const unreadCount = notifications.filter( - (notification) => !notification.read - ).length + const handleMarkAllAsRead = async () => { + await markAllNotificationsAsRead() + } const actionItems = { items: [ { label: 'Mark All Read', key: 'markAllRead', - icon: , + icon: , disabled: unreadCount === 0 }, - { - label: 'Reload Notifications', - key: 'reloadNotifications', - icon: - }, { type: 'divider' }, { - label: 'Delete All', - key: 'deleteAll', - icon: , + label: 'Clear All', + key: 'clearAll', + icon: , danger: true, + disabled: notifications.length === 0 } ], onClick: ({ key }) => { if (key === 'markAllRead') { - markAllAsRead() - } else if (key === 'reloadNotifications') { - fetchNotifications() - } else if (key === 'deleteAll') { - setShowDeleteConfirm(true) + handleMarkAllAsRead() + } else if (key === 'clearAll') { + setClearNotificationsLoading(true) + deleteAllNotifications().finally(() => setClearNotificationsLoading(false)) } } } @@ -178,58 +66,45 @@ const NotificationCenter = ({ visible }) => { } return ( - <> - - - - Actions - - - - - - - } danger /> - + + + + Actions + - - {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) + )} + + + + } + size='small' + disabled={!isCurrentUser(item.user)} + loading={emailTogglingId === item._id} + onClick={() => handleEmailToggle(item)} + /> + + + ))} + > + )} + + ) + + return ( + + + } + disabled={disabled || loading || initialLoad} + loading={loading || !objectId || !type} + onClick={handleClick} + /> + + ) +} + +UserNotifierToggle.propTypes = { + type: PropTypes.string.isRequired, + objectData: PropTypes.object.isRequired, + disabled: PropTypes.bool +} + +export default UserNotifierToggle diff --git a/src/components/Dashboard/context/ApiServerContext.jsx b/src/components/Dashboard/context/ApiServerContext.jsx index 1c86bf3..70a4846 100644 --- a/src/components/Dashboard/context/ApiServerContext.jsx +++ b/src/components/Dashboard/context/ApiServerContext.jsx @@ -36,6 +36,7 @@ const ApiServerProvider = ({ children }) => { const [retryCallback, setRetryCallback] = useState(null) const subscribedCallbacksRef = useRef(new Map()) const subscribedLockCallbacksRef = useRef(new Map()) + const notificationListenersRef = useRef(new Set()) const handleLockUpdate = useCallback( async (lockData) => { @@ -72,6 +73,16 @@ const ApiServerProvider = ({ children }) => { const clearSubscriptions = useCallback(() => { subscribedCallbacksRef.current.clear() subscribedLockCallbacksRef.current.clear() + notificationListenersRef.current.clear() + }, []) + + const registerNotificationListener = useCallback((callback) => { + notificationListenersRef.current.add(callback) + return () => notificationListenersRef.current.delete(callback) + }, []) + + const unregisterNotificationListener = useCallback((callback) => { + notificationListenersRef.current.delete(callback) }, []) const connectToServer = useCallback(() => { @@ -101,6 +112,25 @@ const ApiServerProvider = ({ children }) => { newSocket.on('objectDelete', handleObjectDelete) newSocket.on('lockUpdate', handleLockUpdate) newSocket.on('modelStats', handleModelStats) + newSocket.on('notification', (data) => { + let notification + try { + notification = + typeof data === 'string' ? JSON.parse(data) : data + } catch (err) { + logger.error('Failed to parse notification:', err) + return + } + if (!notification) return + logger.debug('Notification received:', notification) + notificationListenersRef.current.forEach((cb) => { + try { + cb(notification) + } catch (err) { + logger.error('Error in notification callback:', err) + } + }) + }) newSocket.on('disconnect', () => { logger.debug('Api Server disconnected') @@ -1212,6 +1242,130 @@ const ApiServerProvider = ({ children }) => { } } + const createUserNotifier = async (objectId, objectType) => { + if (!userProfile?._id) return null + return createObject('userNotifier', { + user: userProfile._id, + object: objectId, + objectType + }) + } + + const deleteUserNotifier = async (userNotifierId) => { + return deleteObject(userNotifierId, 'userNotifier') + } + + const fetchUserNotifiersForObject = async (objectId, objectType) => { + if (!userProfile?._id) return { data: [] } + const result = await fetchObjects('userNotifier', { + filter: { + user: userProfile._id, + object: objectId, + objectType + }, + limit: 1 + }) + return result || { data: [] } + } + + const fetchAllUserNotifiersForObject = async (objectId, objectType) => { + const result = await fetchObjects('userNotifier', { + filter: { + object: objectId, + objectType + }, + limit: 100 + }) + return result || { data: [] } + } + + const toggleUserNotifier = async (objectId, objectType) => { + const { data } = await fetchUserNotifiersForObject(objectId, objectType) + const existing = data?.[0] + if (existing) { + await deleteUserNotifier(existing._id) + return false + } else { + await createUserNotifier(objectId, objectType) + return true + } + } + + const editUserNotifier = async (userNotifierId, updates) => { + try { + const result = await updateObject(userNotifierId, 'userNotifier', updates) + return result + } catch (err) { + console.error(err) + showError(err, () => editUserNotifier(userNotifierId, updates)) + return null + } + } + + const fetchNotificationsApi = useCallback(async () => { + const response = await axios.get(`${config.backendUrl}/notifications`, { + params: { limit: 100, sort: 'createdAt', order: 'descend' }, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + } + }) + return Array.isArray(response.data) ? response.data : [] + }, [token]) + + const markNotificationAsReadApi = useCallback( + async (notificationId) => { + await axios.put( + `${config.backendUrl}/notifications/${notificationId}/read`, + {}, + { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + } + } + ) + }, + [token] + ) + + const markAllNotificationsAsReadApi = useCallback(async () => { + await axios.put( + `${config.backendUrl}/notifications/read-all`, + {}, + { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + } + } + ) + }, [token]) + + const deleteNotificationApi = useCallback( + async (notificationId) => { + await axios.delete( + `${config.backendUrl}/notifications/${notificationId}`, + { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + } + } + ) + }, + [token] + ) + + const deleteAllNotificationsApi = useCallback(async () => { + await axios.delete(`${config.backendUrl}/notifications`, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + } + }) + }, [token]) + const flushFile = async (id) => { logger.debug('Flushing file...') try { @@ -1288,7 +1442,20 @@ const ApiServerProvider = ({ children }) => { sendObjectAction, uploadFile, flushFile, - formatFileName + formatFileName, + createUserNotifier, + deleteUserNotifier, + fetchUserNotifiersForObject, + fetchAllUserNotifiersForObject, + toggleUserNotifier, + editUserNotifier, + fetchNotificationsApi, + markNotificationAsReadApi, + markAllNotificationsAsReadApi, + deleteNotificationApi, + deleteAllNotificationsApi, + registerNotificationListener, + unregisterNotificationListener }} > {contextHolder} diff --git a/src/components/Dashboard/context/NotificationContext.jsx b/src/components/Dashboard/context/NotificationContext.jsx new file mode 100644 index 0000000..461db80 --- /dev/null +++ b/src/components/Dashboard/context/NotificationContext.jsx @@ -0,0 +1,183 @@ +import { + createContext, + useState, + useContext, + useCallback, + useEffect +} from 'react' +import { notification, Drawer } from 'antd' +import PropTypes from 'prop-types' +import { AuthContext } from './AuthContext' +import { ApiServerContext } from './ApiServerContext' +import NotificationCenter from '../common/NotificationCenter' +import Notification from '../common/Notification' + +const NotificationContext = createContext() + +const NotificationProvider = ({ children }) => { + const [api, contextHolder] = notification.useNotification() + const { authenticated } = useContext(AuthContext) + const { + showError, + fetchNotificationsApi, + markNotificationAsReadApi, + markAllNotificationsAsReadApi, + deleteNotificationApi, + deleteAllNotificationsApi, + registerNotificationListener + } = useContext(ApiServerContext) + + const [notificationCenterVisible, setNotificationCenterVisible] = + useState(false) + const [notifications, setNotifications] = useState([]) + const [notificationsLoading, setNotificationsLoading] = useState(false) + + const fetchNotifications = useCallback(async () => { + if (!authenticated) return [] + setNotificationsLoading(true) + try { + const data = await fetchNotificationsApi() + setNotifications(data) + return data + } catch (err) { + console.error(err) + showError(err, () => fetchNotifications()) + return [] + } finally { + setNotificationsLoading(false) + } + }, [authenticated, fetchNotificationsApi, showError]) + + const markNotificationAsRead = useCallback( + async (notificationId) => { + try { + await markNotificationAsReadApi(notificationId) + setNotifications((prev) => + prev.map((n) => (n._id === notificationId ? { ...n, read: true } : n)) + ) + } catch (err) { + console.error(err) + showError(err, () => markNotificationAsRead(notificationId)) + } + }, + [markNotificationAsReadApi, showError] + ) + + const markAllNotificationsAsRead = useCallback(async () => { + try { + await markAllNotificationsAsReadApi() + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))) + } catch (err) { + console.error(err) + showError(err, () => markAllNotificationsAsRead()) + } + }, [markAllNotificationsAsReadApi, showError]) + + const deleteNotification = useCallback( + async (notificationId) => { + try { + await deleteNotificationApi(notificationId) + setNotifications((prev) => prev.filter((n) => n._id !== notificationId)) + } catch (err) { + console.error(err) + showError(err, () => deleteNotification(notificationId)) + } + }, + [deleteNotificationApi, showError] + ) + + const deleteAllNotifications = useCallback(async () => { + try { + await deleteAllNotificationsApi() + setNotifications([]) + } catch (err) { + console.error(err) + showError(err, () => deleteAllNotifications()) + } + }, [deleteAllNotificationsApi, showError]) + + const toggleNotificationCenter = useCallback(() => { + setNotificationCenterVisible((prev) => !prev) + }, []) + + const unreadCount = notifications.filter((n) => !n.read).length + + useEffect(() => { + if (authenticated && notificationCenterVisible) { + fetchNotifications() + } + }, [authenticated, notificationCenterVisible, fetchNotifications]) + + useEffect(() => { + if (authenticated) { + fetchNotifications() + } + }, [authenticated, fetchNotifications]) + + useEffect(() => { + if (!authenticated || !registerNotificationListener) return + const handleNotification = (notif) => { + setNotifications((prev) => { + const exists = prev.some((n) => n._id === notif._id) + if (exists) return prev + return [{ ...notif, read: false }, ...prev] + }) + const show = api.open + show({ + message: ( + + ), + icon: null, + duration: 3, + key: notif._id + }) + } + const unregister = registerNotificationListener(handleNotification) + return unregister + }, [authenticated, registerNotificationListener, api]) + + return ( + + {contextHolder} + {children} + setNotificationCenterVisible(false)} + open={notificationCenterVisible} + > + setNotificationCenterVisible(false)} + /> + + + ) +} + +NotificationProvider.propTypes = { + children: PropTypes.node.isRequired +} + +export { NotificationContext, NotificationProvider } diff --git a/src/components/Dashboard/context/ThemeContext.jsx b/src/components/Dashboard/context/ThemeContext.jsx index b7e2bfd..ab6cdf5 100644 --- a/src/components/Dashboard/context/ThemeContext.jsx +++ b/src/components/Dashboard/context/ThemeContext.jsx @@ -100,6 +100,23 @@ export const ThemeProvider = ({ children }) => { return colors } + // Set CSS custom properties for theme colors + useEffect(() => { + const root = document.documentElement + root.style.setProperty('--color-primary', colors.colorPrimary) + root.style.setProperty('--color-success', colors.colorSuccess) + root.style.setProperty('--color-warning', colors.colorWarning) + root.style.setProperty('--color-error', colors.colorError) + root.style.setProperty('--color-info', colors.colorInfo) + root.style.setProperty('--color-link', colors.colorLink) + root.style.setProperty('--color-cyan', colors.colorCyan) + root.style.setProperty('--color-pink', colors.colorPink) + root.style.setProperty('--color-purple', colors.colorPurple) + root.style.setProperty('--color-magenta', colors.colorMagenta) + root.style.setProperty('--color-volcano', colors.colorVolcano) + root.style.setProperty('--layout-header-bg', isDarkMode ? '#141414' : '#ffffff') + }, [isDarkMode]) + const themeConfig = { algorithm: getThemeAlgorithm(), token: { diff --git a/src/database/models/Payment.js b/src/database/models/Payment.js index 84039ea..cc5b027 100644 --- a/src/database/models/Payment.js +++ b/src/database/models/Payment.js @@ -158,26 +158,19 @@ export const Payment = { showHyperlink: true }, { - name: 'useRemainingAmount', - label: 'Use Remaining Amount', - type: 'boolean', - required: true - }, - { - name: 'vendor', - label: 'Vendor', + name: 'payTo', + label: 'Pay To', type: 'object', objectType: 'vendor', showHyperlink: true, - readOnly: true - }, - { - name: 'client', - label: 'Client', - type: 'object', - objectType: 'client', - showHyperlink: true, - readOnly: true + required: true, + readOnly: true, + disabled: (objectData) => { + return objectData?.invoice?.orderType == 'purchaseOrder' + }, + value: (objectData) => { + return objectData?.invoice?.from + } }, { name: 'paymentDate',