{JSON.stringify(
diff --git a/client/src/components/job-watcher-toggle/job-watcher-toggle.component.jsx b/client/src/components/job-watcher-toggle/job-watcher-toggle.component.jsx
new file mode 100644
index 000000000..879b8c1f7
--- /dev/null
+++ b/client/src/components/job-watcher-toggle/job-watcher-toggle.component.jsx
@@ -0,0 +1,154 @@
+import React from "react";
+import { EyeFilled, EyeOutlined, UserOutlined } from "@ant-design/icons";
+import { Avatar, Button, Divider, List, Popover, Select, Tooltip, Typography } from "antd";
+import { useTranslation } from "react-i18next";
+import EmployeeSearchSelectComponent from "../../components/employee-search-select/employee-search-select.component.jsx";
+import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component.jsx";
+import { BiSolidTrash } from "react-icons/bi";
+
+const { Text } = Typography;
+
+export default function JobWatcherToggleComponent({
+ jobWatchers,
+ isWatching,
+ watcherLoading,
+ adding,
+ removing,
+ open,
+ setOpen,
+ selectedWatcher,
+ setSelectedWatcher,
+ selectedTeam,
+ bodyshop,
+ Enhanced_Payroll,
+ handleToggleSelf,
+ handleRemoveWatcher,
+ handleWatcherSelect,
+ handleTeamSelect
+}) {
+ const { t } = useTranslation();
+
+ const handleRenderItem = (watcher) => {
+ // Check if watcher is defined and has user_email
+ if (!watcher || !watcher.user_email) {
+ return null; // Skip rendering invalid watchers
+ }
+
+ const employee = bodyshop?.employees?.find((e) => e.user_email === watcher.user_email);
+ const displayName = employee ? `${employee.first_name} ${employee.last_name}` : watcher.user_email;
+
+ return (
+ }
+ onClick={() => handleRemoveWatcher(watcher.user_email)}
+ disabled={adding || removing} // Optional: Disable button during mutations
+ >
+ {t("notifications.actions.remove")}
+
+ ]}
+ >
+ } />}
+ title={{displayName}}
+ description={watcher.user_email}
+ />
+
+ );
+ };
+
+ const popoverContent = (
+
+
+ : }
+ size="medium"
+ onClick={handleToggleSelf}
+ loading={adding || removing}
+ >
+ {isWatching ? t("notifications.labels.unwatch") : t("notifications.labels.watch")}
+
+ ]}
+ >
+
+
+ {t("notifications.labels.watching-issue")}
+
+
+
+
+ {watcherLoading ? (
+
+ ) : jobWatchers && jobWatchers.length > 0 ? (
+
+ ) : (
+
{t("notifications.labels.no-watchers")}
+ )}
+
+
+
{t("notifications.labels.add-watchers")}
+
+ jobWatchers.every((w) => w.user_email !== e.user_email && e.active && e.user_email)
+ ) || []
+ }
+ placeholder={t("notifications.labels.employee-search")}
+ value={selectedWatcher}
+ onChange={(value) => {
+ setSelectedWatcher(value);
+ handleWatcherSelect(value);
+ }}
+ />
+ {Enhanced_Payroll && bodyshop?.employee_teams?.length > 0 && (
+ <>
+
+ {t("notifications.labels.add-watchers-team")}
+
+ );
+
+ return (
+
+
+ : }
+ loading={watcherLoading}
+ />
+
+
+ );
+}
diff --git a/client/src/components/job-watcher-toggle/job-watcher-toggle.container.jsx b/client/src/components/job-watcher-toggle/job-watcher-toggle.container.jsx
new file mode 100644
index 000000000..d57fa6c89
--- /dev/null
+++ b/client/src/components/job-watcher-toggle/job-watcher-toggle.container.jsx
@@ -0,0 +1,219 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useMutation, useQuery } from "@apollo/client";
+import { ADD_JOB_WATCHER, GET_JOB_WATCHERS, REMOVE_JOB_WATCHER } from "../../graphql/jobs.queries.js";
+import { connect } from "react-redux";
+import { createStructuredSelector } from "reselect";
+import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
+import { useSplitTreatments } from "@splitsoftware/splitio-react";
+import JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx";
+
+const mapStateToProps = createStructuredSelector({
+ bodyshop: selectBodyshop,
+ currentUser: selectCurrentUser
+});
+
+function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
+ const {
+ treatments: { Enhanced_Payroll }
+ } = useSplitTreatments({
+ attributes: {},
+ names: ["Enhanced_Payroll"],
+ splitKey: bodyshop && bodyshop.imexshopid
+ });
+
+ const userEmail = currentUser.email;
+ const jobid = job.id;
+
+ const [open, setOpen] = useState(false);
+ const [selectedWatcher, setSelectedWatcher] = useState(null);
+ const [selectedTeam, setSelectedTeam] = useState(null);
+
+ // Fetch current watchers with refetch capability
+ const {
+ data: watcherData,
+ loading: watcherLoading,
+ refetch
+ } = useQuery(GET_JOB_WATCHERS, {
+ variables: { jobid },
+ fetchPolicy: "cache-and-network" // Ensure fresh data from server
+ });
+
+ // Refetch jobWatchers when the popover opens (open changes to true)
+ useEffect(() => {
+ if (open) {
+ refetch().catch((err) =>
+ console.error(`Something went wrong fetching Notification Watchers on popover open: ${err?.message}`, {
+ stack: err?.stack
+ })
+ );
+ }
+ }, [open, refetch]);
+
+ const jobWatchers = useMemo(() => (watcherData?.job_watchers ? [...watcherData.job_watchers] : []), [watcherData]);
+ const isWatching = jobWatchers.some((w) => w.user_email === userEmail);
+
+ const [addWatcher, { loading: adding }] = useMutation(ADD_JOB_WATCHER, {
+ onCompleted: () =>
+ refetch().catch((err) =>
+ console.error(`Something went wrong fetching Notification Watchers after add: ${err?.message}`, {
+ stack: err?.stack
+ })
+ ),
+ onError: (err) => {
+ if (err.graphQLErrors && err.graphQLErrors.length > 0) {
+ const errorMessage = err.graphQLErrors[0].message;
+ if (
+ errorMessage.includes("Uniqueness violation") ||
+ errorMessage.includes("idx_job_watchers_jobid_user_email_unique")
+ ) {
+ console.warn("Watcher already exists for this job and user.");
+ refetch().catch((err) =>
+ console.error(
+ `Something went wrong fetching Notification Watchers after uniqueness violation: ${err?.message}`,
+ { stack: err?.stack }
+ )
+ ); // Sync with server to ensure UI reflects actual state
+ } else {
+ console.error(`Error adding job watcher: ${errorMessage}`);
+ }
+ } else {
+ console.error(`Unexpected error adding job watcher: ${err.message || JSON.stringify(err)}`);
+ }
+ },
+ update(cache, { data }) {
+ if (!data || !data.insert_job_watchers_one) {
+ console.warn("No data or insert_job_watchers_one returned from mutation, skipping cache update.");
+ refetch().catch((err) =>
+ console.error(`Something went wrong updating Notification Watchers after add: ${err?.message}`, {
+ stack: err?.stack
+ })
+ );
+ return;
+ }
+
+ const insert_job_watchers_one = data.insert_job_watchers_one;
+ const existingData = cache.readQuery({
+ query: GET_JOB_WATCHERS,
+ variables: { jobid }
+ });
+
+ cache.writeQuery({
+ query: GET_JOB_WATCHERS,
+ variables: { jobid },
+ data: {
+ ...existingData,
+ job_watchers: [...(existingData?.job_watchers || []), insert_job_watchers_one]
+ }
+ });
+ }
+ });
+
+ const [removeWatcher, { loading: removing }] = useMutation(REMOVE_JOB_WATCHER, {
+ onCompleted: () =>
+ refetch().catch((err) =>
+ console.error(`Something went wrong fetching Notification Watchers after remove: ${err?.message}`, {
+ stack: err?.stack
+ })
+ ), // Refetch to sync with server after success
+ onError: (err) => console.error(`Error removing job watcher: ${err.message}`),
+ update(cache, { data: { delete_job_watchers } }) {
+ const existingData = cache.readQuery({
+ query: GET_JOB_WATCHERS,
+ variables: { jobid }
+ });
+
+ const deletedWatcher = delete_job_watchers.returning[0];
+ const updatedWatchers = deletedWatcher
+ ? (existingData?.job_watchers || []).filter((watcher) => watcher.user_email !== deletedWatcher.user_email)
+ : existingData?.job_watchers || [];
+
+ cache.writeQuery({
+ query: GET_JOB_WATCHERS,
+ variables: { jobid },
+ data: {
+ ...existingData,
+ job_watchers: updatedWatchers
+ }
+ });
+ }
+ });
+
+ const handleToggleSelf = useCallback(async () => {
+ if (adding || removing) return;
+ if (isWatching) {
+ await removeWatcher({ variables: { jobid, userEmail } });
+ } else {
+ await addWatcher({ variables: { jobid, userEmail } });
+ }
+ }, [isWatching, addWatcher, removeWatcher, jobid, userEmail, adding, removing]);
+
+ const handleRemoveWatcher = useCallback(
+ async (email) => {
+ if (removing) return;
+ await removeWatcher({ variables: { jobid, userEmail: email } });
+ },
+ [removeWatcher, jobid, removing]
+ );
+
+ const handleWatcherSelect = useCallback(
+ async (selectedUser) => {
+ if (adding || removing) return;
+ const employee = bodyshop.employees.find((e) => e.id === selectedUser);
+ if (!employee) return;
+
+ const email = employee.user_email;
+ const isAlreadyWatching = jobWatchers.some((w) => w.user_email === email);
+
+ if (isAlreadyWatching) {
+ await handleRemoveWatcher(email);
+ } else {
+ await addWatcher({ variables: { jobid, userEmail: email } });
+ }
+ setSelectedWatcher(null);
+ },
+ [jobWatchers, addWatcher, handleRemoveWatcher, jobid, bodyshop, adding, removing]
+ );
+
+ const handleTeamSelect = useCallback(
+ async (team) => {
+ if (adding) return;
+ const selectedTeamMembers = JSON.parse(team);
+ const newWatchers = selectedTeamMembers.filter(
+ (email) => !jobWatchers.some((watcher) => watcher.user_email === email)
+ );
+
+ if (newWatchers.length === 0) {
+ console.warn("All selected team members are already watchers.");
+ setSelectedTeam(null);
+ return;
+ }
+ await Promise.all(newWatchers.map((email) => addWatcher({ variables: { jobid, userEmail: email } })));
+ },
+ [jobWatchers, addWatcher, jobid, adding]
+ );
+
+ return (
+
+ );
+}
+
+export default connect(mapStateToProps)(JobWatcherToggleContainer);
diff --git a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx
index ee644654a..02a6b3494 100644
--- a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx
+++ b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx
@@ -4,12 +4,12 @@ import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Card, Dropdown, Form, Input, Modal, Popconfirm, Popover, Select, Space } from "antd";
import axios from "axios";
import parsePhoneNumber from "libphonenumber-js";
-import { useContext, useMemo, useState } from "react";
+import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
-import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
+import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { auth, logImEXEvent } from "../../firebase/firebase.utils";
import { CANCEL_APPOINTMENTS_BY_JOB_ID, INSERT_MANUAL_APPT } from "../../graphql/appointments.queries";
import { GET_CURRENT_QUESTIONSET_ID, INSERT_CSI } from "../../graphql/csi.queries";
@@ -28,11 +28,11 @@ import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
import LockerWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
+import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
import AddToProduction from "./jobs-detail-header-actions.addtoproduction.util";
import DuplicateJob from "./jobs-detail-header-actions.duplicate.util";
import JobsDetailHeaderActionsToggleProduction from "./jobs-detail-header-actions.toggle-production";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
-import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -130,7 +130,7 @@ export function JobsDetailHeaderActions({
const [updateJob] = useMutation(UPDATE_JOB);
const [voidJob] = useMutation(VOID_JOB);
const [cancelAllAppointments] = useMutation(CANCEL_APPOINTMENTS_BY_JOB_ID);
- const { socket } = useContext(SocketContext);
+ const { socket } = useSocket();
const notification = useNotification();
const {
@@ -775,15 +775,14 @@ export function JobsDetailHeaderActions({
key: "enterpayments",
id: "job-actions-enterpayments",
disabled: !job.converted,
- label: {t("menus.header.enterpayment")},
+ label: t("menus.header.enterpayment"),
onClick: () => {
logImEXEvent("job_header_enter_payment");
- HasFeatureAccess({ featureName: "payments", bodyshop }) &&
- setPaymentContext({
- actions: {},
- context: { jobid: job.id }
- });
+ setPaymentContext({
+ actions: {},
+ context: { jobid: job.id }
+ });
}
});
diff --git a/client/src/components/jobs-detail-header/jobs-detail-header.component.jsx b/client/src/components/jobs-detail-header/jobs-detail-header.component.jsx
index ffe729b51..dd49ffee0 100644
--- a/client/src/components/jobs-detail-header/jobs-detail-header.component.jsx
+++ b/client/src/components/jobs-detail-header/jobs-detail-header.component.jsx
@@ -119,7 +119,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
{job.cccontracts.map((c, index) => (
-
+
{`${c.agreementnumber} - ${c.courtesycar.fleetnumber} ${c.courtesycar.year} ${c.courtesycar.make} ${c.courtesycar.model}`}
{index !== job.cccontracts.length - 1 ? "," : null}
diff --git a/client/src/components/notification-center/notification-center.component.jsx b/client/src/components/notification-center/notification-center.component.jsx
new file mode 100644
index 000000000..f72d2bd48
--- /dev/null
+++ b/client/src/components/notification-center/notification-center.component.jsx
@@ -0,0 +1,122 @@
+import { Virtuoso } from "react-virtuoso";
+import { Badge, Button, Space, Spin, Switch, Tooltip, Typography } from "antd";
+import { CheckCircleFilled, CheckCircleOutlined, EyeFilled, EyeOutlined } from "@ant-design/icons";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+import "./notification-center.styles.scss";
+import day from "../../utils/day.js";
+import { forwardRef, useRef, useEffect } from "react";
+import { DateTimeFormat } from "../../utils/DateFormatter.jsx";
+
+const { Text, Title } = Typography;
+
+/**
+ * Notification Center Component
+ * @type {React.ForwardRefExoticComponent & React.RefAttributes>}
+ */
+const NotificationCenterComponent = forwardRef(
+ (
+ {
+ visible,
+ onClose,
+ notifications,
+ loading,
+ showUnreadOnly,
+ toggleUnreadOnly,
+ markAllRead,
+ loadMore,
+ onNotificationClick,
+ unreadCount
+ },
+ ref
+ ) => {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const virtuosoRef = useRef(null);
+
+ // Scroll to top when showUnreadOnly changes
+ useEffect(() => {
+ if (virtuosoRef.current) {
+ virtuosoRef.current.scrollToIndex({ index: 0, behavior: "smooth" });
+ }
+ }, [showUnreadOnly]);
+
+ const renderNotification = (index, notification) => {
+ const handleClick = () => {
+ if (!notification.read) {
+ onNotificationClick(notification.id);
+ }
+ navigate(`/manage/jobs/${notification.jobid}`);
+ };
+
+ return (
+
+
+
+
+
+ {t("notifications.labels.ro-number", { ro_number: notification.roNumber || t("general.labels.na") })}
+
+
+ {day(notification.created_at).fromNow()}
+
+
+
+
+ {notification.scenarioText.map((text, idx) => (
+ - {text}
+ ))}
+
+
+
+
+
+ );
+ };
+
+ return (
+
+
+
+ {t("notifications.labels.notification-center")}
+ {loading && }
+
+
+
+
+ {showUnreadOnly ? (
+
+ ) : (
+
+ )}
+ toggleUnreadOnly(checked)} size="small" />
+
+
+
+ : }
+ onClick={markAllRead}
+ disabled={!unreadCount}
+ />
+
+
+
+
+
+ );
+ }
+);
+
+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
new file mode 100644
index 000000000..ae12552f3
--- /dev/null
+++ b/client/src/components/notification-center/notification-center.container.jsx
@@ -0,0 +1,202 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useQuery } from "@apollo/client";
+import { connect } from "react-redux";
+import NotificationCenterComponent from "./notification-center.component";
+import { GET_NOTIFICATIONS } from "../../graphql/notifications.queries";
+import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/useSocket.jsx";
+import { createStructuredSelector } from "reselect";
+import { selectBodyshop } from "../../redux/user/user.selectors.js";
+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;
+
+/**
+ * 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 [isLoading, setIsLoading] = useState(false);
+ const { isConnected, markNotificationRead, markAllNotificationsRead } = useSocket();
+ const notificationRef = useRef(null);
+
+ const userAssociationId = bodyshop?.associations?.[0]?.id;
+
+ const baseWhereClause = useMemo(() => {
+ return { associationid: { _eq: userAssociationId } };
+ }, [userAssociationId]);
+
+ const whereClause = useMemo(() => {
+ return showUnreadOnly ? { ...baseWhereClause, read: { _is_null: true } } : baseWhereClause;
+ }, [baseWhereClause, showUnreadOnly]);
+
+ const {
+ data,
+ fetchMore,
+ loading: queryLoading,
+ refetch
+ } = useQuery(GET_NOTIFICATIONS, {
+ variables: {
+ limit: INITIAL_NOTIFICATIONS,
+ offset: 0,
+ where: whereClause
+ },
+ fetchPolicy: "cache-and-network",
+ notifyOnNetworkStatusChange: true,
+ pollInterval: isConnected ? 0 : day.duration(NOTIFICATION_POLL_INTERVAL_SECONDS, "seconds").asMilliseconds(),
+ skip: !userAssociationId,
+ onError: (err) => {
+ console.error(`Error polling Notifications: ${err?.message || ""}`);
+ setTimeout(() => refetch(), day.duration(2, "seconds").asMilliseconds());
+ }
+ });
+
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ // Prevent open + close behavior from the header
+ if (event.target.closest("#header-notifications")) return;
+ 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
+ .map((notif) => {
+ let scenarioText;
+ let scenarioMeta;
+ try {
+ scenarioText = notif.scenario_text ? JSON.parse(notif.scenario_text) : [];
+ scenarioMeta = notif.scenario_meta ? JSON.parse(notif.scenario_meta) : {};
+ } catch (e) {
+ console.error("Error parsing JSON for notification:", notif.id, e);
+ scenarioText = [notif.fcm_text || "Invalid notification data"];
+ scenarioMeta = {};
+ }
+ if (!Array.isArray(scenarioText)) scenarioText = [scenarioText];
+ const roNumber = notif.job.ro_number;
+ if (!Array.isArray(scenarioMeta)) scenarioMeta = [scenarioMeta];
+ return {
+ id: notif.id,
+ jobid: notif.jobid,
+ associationid: notif.associationid,
+ scenarioText,
+ scenarioMeta,
+ roNumber,
+ created_at: notif.created_at,
+ read: notif.read,
+ __typename: notif.__typename
+ };
+ })
+ .sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
+ setNotifications(processedNotifications);
+ }
+ }, [data]);
+
+ const loadMore = useCallback(() => {
+ if (!queryLoading && data?.notifications.length) {
+ setIsLoading(true); // Show spinner during fetchMore
+ fetchMore({
+ variables: { offset: data.notifications.length, where: whereClause },
+ updateQuery: (prev, { fetchMoreResult }) => {
+ if (!fetchMoreResult) return prev;
+ return {
+ notifications: [...prev.notifications, ...fetchMoreResult.notifications]
+ };
+ }
+ })
+ .catch((err) => {
+ console.error("Fetch more error:", err);
+ })
+ .finally(() => setIsLoading(false)); // Hide spinner when done
+ }
+ }, [data?.notifications?.length, fetchMore, queryLoading, whereClause]);
+
+ const handleToggleUnreadOnly = (value) => {
+ setShowUnreadOnly(value);
+ };
+
+ const handleMarkAllRead = useCallback(() => {
+ setIsLoading(true);
+ markAllNotificationsRead()
+ .then(() => {
+ const timestamp = new Date().toISOString();
+ setNotifications((prev) => {
+ const updatedNotifications = prev.map((notif) =>
+ notif.read === null && notif.associationid === userAssociationId
+ ? {
+ ...notif,
+ read: timestamp
+ }
+ : notif
+ );
+ // Filter out read notifications if in unread only mode
+ return showUnreadOnly ? updatedNotifications.filter((notif) => !notif.read) : updatedNotifications;
+ });
+ })
+ .catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`))
+ .finally(() => setIsLoading(false));
+ }, [markAllNotificationsRead, userAssociationId, showUnreadOnly]);
+
+ const handleNotificationClick = useCallback(
+ (notificationId) => {
+ setIsLoading(true);
+ markNotificationRead({ variables: { id: notificationId } })
+ .then(() => {
+ const timestamp = new Date().toISOString();
+ setNotifications((prev) => {
+ const updatedNotifications = prev.map((notif) =>
+ notif.id === notificationId && !notif.read ? { ...notif, read: timestamp } : notif
+ );
+ // Filter out the read notification if in unread only mode
+ return showUnreadOnly ? updatedNotifications.filter((notif) => !notif.read) : updatedNotifications;
+ });
+ })
+ .catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`))
+ .finally(() => setIsLoading(false));
+ },
+ [markNotificationRead, showUnreadOnly]
+ );
+
+ useEffect(() => {
+ if (visible && !isConnected) {
+ setIsLoading(true);
+ refetch()
+ .catch((err) => console.error(`Error re-fetching notifications: ${err?.message || ""}`))
+ .finally(() => setIsLoading(false));
+ }
+ }, [visible, isConnected, refetch]);
+
+ return (
+
+ );
+};
+
+const mapStateToProps = createStructuredSelector({
+ bodyshop: selectBodyshop
+});
+
+export default connect(mapStateToProps, null)(NotificationCenterContainer);
diff --git a/client/src/components/notification-center/notification-center.styles.scss b/client/src/components/notification-center/notification-center.styles.scss
new file mode 100644
index 000000000..89ae0beae
--- /dev/null
+++ b/client/src/components/notification-center/notification-center.styles.scss
@@ -0,0 +1,175 @@
+.notification-center {
+ position: absolute;
+ top: 64px;
+ right: 0;
+ width: 400px;
+ max-width: 400px;
+ background: #fff;
+ color: rgba(0, 0, 0, 0.85);
+ border: 1px solid #d9d9d9;
+ border-radius: 6px;
+ 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;
+
+ &.visible {
+ display: block;
+ }
+
+ .notification-header {
+ padding: 4px 16px;
+ border-bottom: 1px solid #f0f0f0;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background: #fafafa;
+
+ h3 {
+ margin: 0;
+ font-size: 14px;
+ color: rgba(0, 0, 0, 0.85);
+ }
+
+ .notification-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ // Styles for the eye icon and switch (custom classes)
+ .notification-toggle {
+ align-items: center; // Ensure vertical alignment
+ }
+
+ .notification-toggle-icon {
+ font-size: 14px;
+ color: #1677ff;
+ vertical-align: middle;
+ }
+
+ .ant-switch {
+ &.ant-switch-small {
+ min-width: 28px;
+ height: 16px;
+ line-height: 16px;
+
+ .ant-switch-handle {
+ width: 12px;
+ height: 12px;
+ }
+
+ &.ant-switch-checked {
+ background-color: #1677ff;
+ .ant-switch-handle {
+ left: calc(100% - 14px);
+ }
+ }
+ }
+ }
+
+ // Styles for the "Mark All Read" button (restore original link button style)
+ .ant-btn-link {
+ padding: 0;
+ color: #1677ff;
+
+ &:hover {
+ color: #69b1ff;
+ }
+
+ &:disabled {
+ color: rgba(0, 0, 0, 0.25);
+ cursor: not-allowed;
+ }
+
+ &.active {
+ color: #0958d9;
+ }
+ }
+ }
+ }
+
+ .notification-read {
+ background: #fff;
+ color: rgba(0, 0, 0, 0.65);
+ }
+
+ .notification-unread {
+ background: #f5f5f5;
+ color: rgba(0, 0, 0, 0.85);
+ }
+
+ .notification-item {
+ padding: 12px 16px;
+ border-bottom: 1px solid #f0f0f0;
+ display: block;
+ overflow: visible;
+ width: 100%;
+ box-sizing: border-box;
+ cursor: pointer;
+
+ &:hover {
+ background: #fafafa;
+ }
+
+ .notification-content {
+ width: 100%;
+ }
+
+ .notification-title {
+ margin: 0;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ box-sizing: border-box;
+
+ .ro-number {
+ margin: 0;
+ color: #1677ff;
+ flex-shrink: 0;
+ white-space: nowrap;
+ }
+
+ .relative-time {
+ margin: 0;
+ font-size: 12px;
+ color: rgba(0, 0, 0, 0.45);
+ white-space: nowrap;
+ flex-shrink: 0;
+ margin-left: auto;
+ }
+ }
+
+ .notification-body {
+ margin-top: 4px;
+
+ .ant-typography {
+ color: inherit;
+ }
+
+ ul {
+ margin: 0;
+ padding: 0;
+ }
+
+ li {
+ margin-bottom: 2px;
+ }
+ }
+ }
+
+ .ant-badge {
+ width: 100%;
+ }
+
+ .ant-alert {
+ margin: 8px;
+ background: #fff1f0;
+ color: rgba(0, 0, 0, 0.85);
+ border: 1px solid #ffa39e;
+
+ .ant-alert-message {
+ color: #ff4d4f;
+ }
+ }
+}
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/notification-settings/notification-settings-form.component.jsx b/client/src/components/notification-settings/notification-settings-form.component.jsx
new file mode 100644
index 000000000..859826bbc
--- /dev/null
+++ b/client/src/components/notification-settings/notification-settings-form.component.jsx
@@ -0,0 +1,168 @@
+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";
+import AlertComponent from "../alert/alert.component";
+import { QUERY_NOTIFICATION_SETTINGS, UPDATE_NOTIFICATION_SETTINGS } from "../../graphql/user.queries.js";
+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";
+
+/**
+ * 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, {
+ fetchPolicy: "network-only",
+ nextFetchPolicy: "network-only",
+ variables: { email: currentUser.email },
+ skip: !currentUser
+ });
+
+ const [updateNotificationSettings, { loading: saving }] = useMutation(UPDATE_NOTIFICATION_SETTINGS);
+
+ // Populate form with fetched data.
+ useEffect(() => {
+ if (data?.associations?.length > 0) {
+ const settings = data.associations[0].notification_settings || {};
+ // Ensure each scenario has an object with { app, email, fcm }.
+ const formattedValues = notificationScenarios.reduce((acc, scenario) => {
+ acc[scenario] = settings[scenario] ?? { app: false, email: false, fcm: false };
+ return acc;
+ }, {});
+
+ setInitialValues(formattedValues);
+ form.setFieldsValue(formattedValues);
+ setIsDirty(false); // Reset dirty state when new data loads.
+ }
+ }, [data, form]);
+
+ const handleSave = async (values) => {
+ if (data?.associations?.length > 0) {
+ const userId = data.associations[0].id;
+ // Save the updated notification settings.
+ 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") });
+ }
+ }
+ };
+
+ // Mark the form as dirty on any manual change.
+ const handleFormChange = () => {
+ setIsDirty(true);
+ };
+
+ const handleReset = () => {
+ form.setFieldsValue(initialValues);
+ setIsDirty(false);
+ };
+
+ if (error) return ;
+ if (loading) return ;
+
+ const columns = [
+ {
+ title: t("notifications.labels.scenario"),
+ dataIndex: "scenarioLabel",
+ key: "scenario",
+ render: (_, record) => t(`notifications.scenarios.${record.key}`),
+ width: "90%"
+ },
+ {
+ title: setIsDirty(true)} />,
+ dataIndex: "app",
+ key: "app",
+ align: "center",
+ render: (_, record) => (
+
+
+
+ )
+ },
+ {
+ title: setIsDirty(true)} />,
+ dataIndex: "email",
+ key: "email",
+ align: "center",
+ render: (_, record) => (
+
+
+
+ )
+ }
+ // TODO: Disabled for now until FCM is implemented.
+ // {
+ // title: setIsDirty(true)} />,
+ // dataIndex: "fcm",
+ // key: "fcm",
+ // align: "center",
+ // render: (_, record) => (
+ //
+ //
+ //
+ // )
+ // }
+ ];
+
+ const dataSource = notificationScenarios.map((scenario) => ({ key: scenario }));
+
+ return (
+
+ }
+ >
+
+
+
+ );
+};
+
+NotificationSettingsForm.propTypes = {
+ currentUser: PropTypes.shape({
+ email: PropTypes.string.isRequired
+ }).isRequired
+};
+
+const mapStateToProps = createStructuredSelector({
+ currentUser: selectCurrentUser
+});
+
+export default connect(mapStateToProps)(NotificationSettingsForm);
diff --git a/client/src/components/payments-generate-link/payments-generate-link.component.jsx b/client/src/components/payments-generate-link/payments-generate-link.component.jsx
index f9d84d10f..7e121621f 100644
--- a/client/src/components/payments-generate-link/payments-generate-link.component.jsx
+++ b/client/src/components/payments-generate-link/payments-generate-link.component.jsx
@@ -2,15 +2,15 @@ import { CopyFilled } from "@ant-design/icons";
import { Button, Form, message, Popover, Space } from "antd";
import axios from "axios";
import Dinero from "dinero.js";
-import { parsePhoneNumber } from "libphonenumber-js";
-import React, { useContext, useState } from "react";
+import { parsePhoneNumberWithError, ParseError } from "libphonenumber-js";
+import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
-import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
+import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -29,22 +29,34 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [paymentLink, setPaymentLink] = useState(null);
- const { socket } = useContext(SocketContext);
+ const { socket } = useSocket();
const handleFinish = async ({ amount }) => {
setLoading(true);
let p;
try {
- p = parsePhoneNumber(job.ownr_ph1 || "", "CA");
+ // Updated to use parsePhoneNumberWithError
+ p = parsePhoneNumberWithError(job.ownr_ph1 || "", "CA");
} catch (error) {
- console.log("Unable to parse phone number");
+ if (error instanceof ParseError) {
+ // Handle specific parsing errors
+ console.log(`Phone number parsing failed: ${error.message}`);
+ } else {
+ // Handle other unexpected errors
+ console.log("Unexpected error while parsing phone number:", error);
+ }
}
setLoading(true);
const response = await axios.post("/intellipay/generate_payment_url", {
bodyshop,
amount: amount,
account: job.ro_number,
- comment: btoa(JSON.stringify({ payments: [{ jobid: job.id, amount }], userEmail: currentUser.email }))
+ comment: btoa(
+ JSON.stringify({
+ payments: [{ jobid: job.id, amount }],
+ userEmail: currentUser.email
+ })
+ )
});
setLoading(false);
setPaymentLink(response.data.shorUrl);
@@ -106,7 +118,20 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope