diff --git a/client/src/components/notification-center/notification-center.component.jsx b/client/src/components/notification-center/notification-center.component.jsx index 49e3e5d15..3db86d108 100644 --- a/client/src/components/notification-center/notification-center.component.jsx +++ b/client/src/components/notification-center/notification-center.component.jsx @@ -2,106 +2,115 @@ import { Virtuoso } from "react-virtuoso"; import { Alert, Badge, Button, Space, Spin, Tooltip, Typography } from "antd"; import { CheckCircleFilled, CheckCircleOutlined, EyeFilled, EyeOutlined } from "@ant-design/icons"; import { useTranslation } from "react-i18next"; -import { Link } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import "./notification-center.styles.scss"; import day from "../../utils/day.js"; +import { forwardRef } from "react"; const { Text, Title } = Typography; -const NotificationCenterComponent = ({ - visible, - onClose, - notifications, - loading, - error, - showUnreadOnly, - toggleUnreadOnly, - markAllRead, - loadMore, - onNotificationClick, - unreadCount -}) => { - const { t } = useTranslation(); +/** + * Notification Center Component + * @type {React.ForwardRefExoticComponent & React.RefAttributes>} + */ +const NotificationCenterComponent = forwardRef( + ( + { + visible, + onClose, + notifications, + loading, + error, + showUnreadOnly, + toggleUnreadOnly, + markAllRead, + loadMore, + onNotificationClick, + unreadCount + }, + ref + ) => { + const { t } = useTranslation(); + const navigate = useNavigate(); - const renderNotification = (index, notification) => { - return ( -
!notification.read && onNotificationClick(notification.id)} - > - -
- - <Link - to={`/manage/jobs/${notification.jobid}`} - onClick={(e) => { - e.stopPropagation(); - if (!notification.read) { - onNotificationClick(notification.id); - } - }} - className="ro-number" - > - {t("notifications.labels.ro-number", { ro_number: notification.roNumber })} - </Link> - <Text - type="secondary" - className="relative-time" - title={day(notification.created_at).format("YYYY-MM-DD hh:mm A")} - > - {day(notification.created_at).fromNow()} + const renderNotification = (index, notification) => { + const handleClick = () => { + if (!notification.read) { + onNotificationClick(notification.id); + } + navigate(`/manage/jobs/${notification.jobid}`); + }; + + return ( + <div + key={`${notification.id}-${index}`} + className={`notification-item ${notification.read ? "notification-read" : "notification-unread"}`} + onClick={handleClick} + > + <Badge dot={!notification.read}> + <div className="notification-content"> + <Title level={5} className="notification-title"> + <span className="ro-number"> + {t("notifications.labels.ro-number", { ro_number: notification.roNumber })} + </span> + <Text + type="secondary" + className="relative-time" + title={day(notification.created_at).format("YYYY-MM-DD hh:mm A")} + > + {day(notification.created_at).fromNow()} + </Text> + + +
    + {notification.scenarioText.map((text, idx) => ( +
  • {text}
  • + ))} +
- - -
    - {notification.scenarioText.map((text, idx) => ( -
  • {text}
  • - ))} -
-
+
+
+
+ ); + }; + + return ( +
+
+ +

{t("notifications.labels.notification-center")}

+ {loading && !error && } +
+
+ +
- +
+ {error && onClose()} />} +
); - }; - - return ( -
-
- -

{t("notifications.labels.notification-center")}

- {loading && !error && } -
-
- -
-
- {error && onClose()} />} - -
- ); -}; + } +); export default NotificationCenterComponent; diff --git a/client/src/components/notification-center/notification-center.container.jsx b/client/src/components/notification-center/notification-center.container.jsx index dfadf3a84..6f7c9b492 100644 --- a/client/src/components/notification-center/notification-center.container.jsx +++ b/client/src/components/notification-center/notification-center.container.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; // Add useRef import { useQuery } from "@apollo/client"; import { connect } from "react-redux"; import NotificationCenterComponent from "./notification-center.component"; @@ -11,11 +11,21 @@ import day from "../../utils/day.js"; // This will be used to poll for notifications when the socket is disconnected const NOTIFICATION_POLL_INTERVAL_SECONDS = 60; -export function NotificationCenterContainer({ visible, onClose, bodyshop, unreadCount }) { +/** + * Notification Center Container + * @param visible + * @param onClose + * @param bodyshop + * @param unreadCount + * @returns {JSX.Element} + * @constructor + */ +const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }) => { const [showUnreadOnly, setShowUnreadOnly] = useState(false); const [notifications, setNotifications] = useState([]); const [error, setError] = useState(null); const { isConnected, markNotificationRead, markAllNotificationsRead } = useSocket(); + const notificationRef = useRef(null); // Add ref for the notification center const userAssociationId = bodyshop?.associations?.[0]?.id; @@ -50,6 +60,19 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop, unread } }); + // Handle click outside to close + useEffect(() => { + const handleClickOutside = (event) => { + if (visible && notificationRef.current && !notificationRef.current.contains(event.target)) { + onClose(); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [visible, onClose]); + useEffect(() => { if (data?.notifications) { const processedNotifications = data.notifications @@ -159,6 +182,7 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop, unread return ( ); -} +}; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop diff --git a/client/src/components/notification-center/notification-center.styles.scss b/client/src/components/notification-center/notification-center.styles.scss index 008642931..44e3fa601 100644 --- a/client/src/components/notification-center/notification-center.styles.scss +++ b/client/src/components/notification-center/notification-center.styles.scss @@ -11,7 +11,7 @@ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08), 0 3px 6px rgba(0, 0, 0, 0.06); z-index: 1000; display: none; - overflow-x: hidden; /* Prevent horizontal overflow */ + overflow-x: hidden; &.visible { display: block; @@ -67,12 +67,17 @@ } .notification-item { - padding: 8px 16px; + padding: 12px 16px; // Increased padding from 8px to 12px for more space border-bottom: 1px solid #f0f0f0; display: block; overflow: visible; width: 100%; box-sizing: border-box; + cursor: pointer; // Add pointer cursor to indicate clickability + + &:hover { + background: #fafafa; // Optional: Add hover effect for better UX + } .notification-content { width: 100%; @@ -122,7 +127,7 @@ } .ant-badge { - width: 100%; /* Ensure Badge takes full width to allow .notification-title to stretch properly */ + width: 100%; } .ant-alert { diff --git a/client/src/components/notification-settings/column-header-checkbox.component.jsx b/client/src/components/notification-settings/column-header-checkbox.component.jsx new file mode 100644 index 000000000..5a857921e --- /dev/null +++ b/client/src/components/notification-settings/column-header-checkbox.component.jsx @@ -0,0 +1,56 @@ +import { notificationScenarios } from "../../utils/jobNotificationScenarios.js"; +import { Checkbox, Form } from "antd"; +import { useTranslation } from "react-i18next"; +import PropTypes from "prop-types"; + +/** + * ColumnHeaderCheckbox + * @param channel + * @param form + * @param disabled + * @param onHeaderChange + * @returns {JSX.Element} + * @constructor + */ +const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange }) => { + const { t } = useTranslation(); + + // Subscribe to all form values so that this component re-renders on changes. + const formValues = Form.useWatch([], form) || {}; + + // Determine if all scenarios for this channel are checked. + const allChecked = + notificationScenarios.length > 0 && notificationScenarios.every((scenario) => formValues[scenario]?.[channel]); + + const onChange = (e) => { + const checked = e.target.checked; + // Get current form values. + const currentValues = form.getFieldsValue(); + // Update each scenario for this channel. + const newValues = { ...currentValues }; + notificationScenarios.forEach((scenario) => { + newValues[scenario] = { ...newValues[scenario], [channel]: checked }; + }); + // Update form values. + form.setFieldsValue(newValues); + // Manually mark the form as dirty. + if (onHeaderChange) { + onHeaderChange(); + } + }; + + return ( + + {t(`notifications.channels.${channel}`)} + + ); +}; + +ColumnHeaderCheckbox.propTypes = { + channel: PropTypes.oneOf(["app", "email", "fcm"]).isRequired, + form: PropTypes.object.isRequired, + disabled: PropTypes.bool, + onHeaderChange: PropTypes.func +}; + +export default ColumnHeaderCheckbox; diff --git a/client/src/components/profile-my/notification-settings.component.jsx b/client/src/components/notification-settings/notification-settings-form.component.jsx similarity index 76% rename from client/src/components/profile-my/notification-settings.component.jsx rename to client/src/components/notification-settings/notification-settings-form.component.jsx index 01fa214e1..859826bbc 100644 --- a/client/src/components/profile-my/notification-settings.component.jsx +++ b/client/src/components/notification-settings/notification-settings-form.component.jsx @@ -2,7 +2,6 @@ import { useMutation, useQuery } from "@apollo/client"; import { useEffect, useState } from "react"; import { Button, Card, Checkbox, Form, Space, Table } from "antd"; import { useTranslation } from "react-i18next"; - import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { selectCurrentUser } from "../../redux/user/user.selectors"; @@ -11,52 +10,21 @@ import { QUERY_NOTIFICATION_SETTINGS, UPDATE_NOTIFICATION_SETTINGS } from "../.. import { notificationScenarios } from "../../utils/jobNotificationScenarios.js"; import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx"; import PropTypes from "prop-types"; +import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx"; -const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange }) => { - const { t } = useTranslation(); - // Subscribe to all form values so that this component re-renders on changes. - const formValues = Form.useWatch([], form) || {}; - - // Determine if all scenarios for this channel are checked. - const allChecked = - notificationScenarios.length > 0 && notificationScenarios.every((scenario) => formValues[scenario]?.[channel]); - - const onChange = (e) => { - const checked = e.target.checked; - // Get current form values. - const currentValues = form.getFieldsValue(); - // Update each scenario for this channel. - const newValues = { ...currentValues }; - notificationScenarios.forEach((scenario) => { - newValues[scenario] = { ...newValues[scenario], [channel]: checked }; - }); - // Update form values. - form.setFieldsValue(newValues); - // Manually mark the form as dirty. - if (onHeaderChange) { - onHeaderChange(); - } - }; - - return ( - - {t(`notifications.channels.${channel}`)} - - ); -}; - -ColumnHeaderCheckbox.propTypes = { - channel: PropTypes.oneOf(["app", "email", "fcm"]).isRequired, - form: PropTypes.object.isRequired, - disabled: PropTypes.bool, - onHeaderChange: PropTypes.func -}; - -function NotificationSettingsForm({ currentUser }) { +/** + * Notifications Settings Form + * @param currentUser + * @returns {JSX.Element} + * @constructor + */ +const NotificationSettingsForm = ({ currentUser }) => { const { t } = useTranslation(); const [form] = Form.useForm(); const [initialValues, setInitialValues] = useState({}); const [isDirty, setIsDirty] = useState(false); + const notification = useNotification(); // Fetch notification settings. const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, { @@ -88,9 +56,14 @@ function NotificationSettingsForm({ currentUser }) { if (data?.associations?.length > 0) { const userId = data.associations[0].id; // Save the updated notification settings. - await updateNotificationSettings({ variables: { id: userId, ns: values } }); - setInitialValues(values); - setIsDirty(false); + const result = await updateNotificationSettings({ variables: { id: userId, ns: values } }); + if (!result?.errors) { + notification.success({ message: t("notifications.labels.notification-settings-success") }); + setInitialValues(values); + setIsDirty(false); + } else { + notification.error({ message: t("notifications.labels.notification-settings-failure") }); + } } }; @@ -180,7 +153,7 @@ function NotificationSettingsForm({ currentUser }) { ); -} +}; NotificationSettingsForm.propTypes = { currentUser: PropTypes.shape({ diff --git a/client/src/components/profile-my/profile-my.component.jsx b/client/src/components/profile-my/profile-my.component.jsx index df5a49e19..d17547cf0 100644 --- a/client/src/components/profile-my/profile-my.component.jsx +++ b/client/src/components/profile-my/profile-my.component.jsx @@ -8,8 +8,8 @@ import { selectCurrentUser } from "../../redux/user/user.selectors"; import { logImEXEvent, updateCurrentPassword } from "../../firebase/firebase.utils"; import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; -import NotificationSettingsForm from "./notification-settings.component.jsx"; import { useSocket } from "../../contexts/SocketIO/useSocket.jsx"; +import NotificationSettingsForm from "../notification-settings/notification-settings-form.component.jsx"; const mapStateToProps = createStructuredSelector({ currentUser: selectCurrentUser diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 42a18a503..9eab4bf1d 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -3783,7 +3783,9 @@ "mark-all-read": "Mark Read", "notification-popup-title": "Changes for Job #{{ro_number}}", "ro-number": "RO #{{ro_number}}", - "no-watchers": "No Watchers" + "no-watchers": "No Watchers", + "notification-settings-success": "Notification Settings saved successfully.", + "notification-settings-failure": "Error saving Notification Settings. {{error}}" }, "actions": { "remove": "remove" diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 79d96a39f..5a89d11ac 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -3783,7 +3783,9 @@ "mark-all-read": "", "notification-popup-title": "", "ro-number": "", - "no-watchers": "" + "no-watchers": "", + "notification-settings-success": "", + "notification-settings-failure": "" }, "actions": { "remove": "" diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index 76fcf0362..b9673bc45 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -3783,7 +3783,9 @@ "mark-all-read": "", "notification-popup-title": "", "ro-number": "", - "no-watchers": "" + "no-watchers": "", + "notification-settings-success": "", + "notification-settings-failure": "" }, "actions": { "remove": ""