diff --git a/client/src/App/App.jsx b/client/src/App/App.jsx index 906981c14..2f71832cc 100644 --- a/client/src/App/App.jsx +++ b/client/src/App/App.jsx @@ -1,7 +1,7 @@ import { useSplitClient } from "@splitsoftware/splitio-react"; import { Button, Result } from "antd"; import LogRocket from "logrocket"; -import React, { lazy, Suspense, useEffect, useState } from "react"; +import { lazy, Suspense, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { Route, Routes } from "react-router-dom"; @@ -23,8 +23,6 @@ import InstanceRenderMgr from "../utils/instanceRenderMgr"; import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx"; import { SocketProvider } from "../contexts/SocketIO/socketContext.jsx"; import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx"; -import { useSubscription, useApolloClient, gql } from "@apollo/client"; -import { SUBSCRIBE_TO_NOTIFICATIONS } from "../graphql/notifications.queries.js"; const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component")); const ManagePage = lazy(() => import("../pages/manage/manage.page.container")); @@ -48,7 +46,6 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline const client = useSplitClient().client; const [listenersAdded, setListenersAdded] = useState(false); const { t } = useTranslation(); - const apolloClient = useApolloClient(); useEffect(() => { if (!navigator.onLine) { @@ -107,114 +104,6 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline } }, [bodyshop, client, currentUser.authorized]); - // Add subscription for all unread notifications with proper normalization and format - useSubscription(SUBSCRIBE_TO_NOTIFICATIONS, { - onData: ({ data }) => { - if (data.data?.notifications) { - console.log("Subscription data received (all unread):", data.data.notifications); - const newNotifs = data.data.notifications.filter( - (newNotif) => - !apolloClient.cache - .readQuery({ - query: gql` - query GetNotifications { - notifications(order_by: { created_at: desc }) { - id - __typename - } - } - ` - }) - ?.notifications.some((n) => n.id === newNotif.id) - ); - - if (newNotifs.length === 0) return; - - // Use writeQuery to normalize and add new notifications as References, matching cache format - apolloClient.cache.writeQuery({ - query: gql` - query GetNotifications { - notifications(order_by: { created_at: desc }) { - __typename - id - jobid - associationid - scenario_text - fcm_text - scenario_meta - created_at - read - } - } - `, - data: { - notifications: [ - ...newNotifs.map((notif) => ({ - ...notif, - __typename: "notifications", - scenario_text: JSON.stringify( - typeof notif.scenario_text === "string" ? JSON.parse(notif.scenario_text) : notif.scenario_text || [] - ), - scenario_meta: JSON.stringify( - typeof notif.scenario_meta === "string" ? JSON.parse(notif.scenario_meta) : notif.scenario_meta || [] - ) - })), - ...(apolloClient.cache.readQuery({ - query: gql` - query GetNotifications { - notifications(order_by: { created_at: desc }) { - __typename - id - jobid - associationid - scenario_text - fcm_text - scenario_meta - created_at - read - } - } - ` - })?.notifications || []) - ].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) // Sort by created_at desc - } - }); - - // Update notifications_aggregate for unread count - apolloClient.cache.modify({ - id: "ROOT_QUERY", - fields: { - notifications_aggregate(existing = { aggregate: { count: 0 } }) { - const unreadCount = existing.aggregate.count + newNotifs.length; - console.log("Updating unread count from subscription:", unreadCount); - return { - ...existing, - aggregate: { - ...existing.aggregate, - count: unreadCount - } - }; - } - }, - optimistic: false - }); - } - }, - onError: (err) => { - console.error("Subscription error:", err); - // Fallback: Poll for all unread notifications if subscription fails - apolloClient - .query({ - query: SUBSCRIBE_TO_NOTIFICATIONS, - variables: { where: { read: { _is_null: true } }, order_by: { created_at: "desc" } }, - pollInterval: 30000 - }) - .catch((pollError) => console.error("Polling error:", pollError)); - }, - shouldResubscribe: true, - skip: !currentUser.authorized // Skip if user isn’t authorized - }); - if (currentUser.authorized === null) { return ; } diff --git a/client/src/components/header/header.component.jsx b/client/src/components/header/header.component.jsx index 48fe65e8b..df09cd05b 100644 --- a/client/src/components/header/header.component.jsx +++ b/client/src/components/header/header.component.jsx @@ -27,7 +27,7 @@ import Icon, { UserOutlined } from "@ant-design/icons"; import { useSplitTreatments } from "@splitsoftware/splitio-react"; -import { Badge, Layout, Menu, Space } from "antd"; +import { Badge, Layout, Menu, Space, Spin } from "antd"; import { useTranslation } from "react-i18next"; import { BsKanban } from "react-icons/bs"; import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa"; @@ -50,6 +50,7 @@ import { debounce } from "lodash"; import { useQuery } from "@apollo/client"; import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js"; import { NotificationCenterContainer } from "../notification-center/notification-center.container.jsx"; +import { useSocket } from "../../contexts/SocketIO/socketContext.jsx"; // Used to Determine if the Header is in Mobile Mode, and to toggle the multiple menus const HEADER_MOBILE_BREAKPOINT = 576; @@ -125,6 +126,57 @@ function Header({ const { t } = useTranslation(); + const { isConnected } = useSocket(bodyshop); + const [notificationVisible, setNotificationVisible] = useState(false); + const [displayCount, setDisplayCount] = useState(0); // Explicit state for badge + + const { + data: unreadData, + refetch: refetchUnread, + loading: unreadLoading + } = useQuery(GET_UNREAD_COUNT, { + fetchPolicy: "network-only", + pollInterval: isConnected ? 0 : 30000 // Poll only if socket is down + }); + + const unreadCount = unreadData?.notifications_aggregate?.aggregate?.count ?? 0; + + // Update displayCount when unreadCount changes + useEffect(() => { + console.log("Updating displayCount with unreadCount:", unreadCount); + setDisplayCount(unreadCount); + }, [unreadCount]); + + // Initial fetch and socket status handling + useEffect(() => { + console.log("Running initial refetchUnread"); + refetchUnread(); + }, [refetchUnread]); + + useEffect(() => { + if (!isConnected && !unreadLoading) { + console.log("Socket disconnected, refetching unread count"); + refetchUnread(); + } + }, [isConnected, unreadLoading, refetchUnread]); + + // Debug logging + useEffect(() => { + console.log("Unread Count State:", { + unreadCount, + displayCount, + unreadLoading, + isConnected, + unreadData: unreadData?.notifications_aggregate, + rawUnreadData: unreadData + }); + }, [unreadCount, displayCount, unreadLoading, isConnected, unreadData]); + + const handleNotificationClick = (e) => { + setNotificationVisible(!notificationVisible); + if (handleMenuClick) handleMenuClick(e); + }; + const [isMobile, setIsMobile] = useState(() => { const effectiveWidth = window.innerWidth / (window.devicePixelRatio || 1); return effectiveWidth <= HEADER_MOBILE_BREAKPOINT; @@ -135,39 +187,6 @@ function Header({ setIsMobile(effectiveWidth <= HEADER_MOBILE_BREAKPOINT); }, 200); - const [notificationVisible, setNotificationVisible] = useState(false); - - const { - data: unreadData, - error: unreadError, - refetch: refetchUnread, - loading: unreadLoading - } = useQuery(GET_UNREAD_COUNT, { - fetchPolicy: "network-only", // Force network request for fresh data - pollInterval: 30000, // Poll every 30 seconds to ensure updates - onError: (err) => { - console.error("Error fetching unread count:", err); - console.log("Unread data state:", unreadData, "Error details:", err); - } - }); - - const unreadCount = unreadData?.notifications_aggregate?.aggregate?.count || 0; - - // Refetch unread count when the component mounts, updates, or on specific events - useEffect(() => { - refetchUnread(); - }, [refetchUnread, bodyshop, currentUser]); // Add dependencies to trigger refetch on user or shop changes - - // Log unread count for debugging - useEffect(() => { - console.log("Unread count updated:", unreadCount, "Loading:", unreadLoading, "Error:", unreadError); - }, [unreadCount, unreadLoading, unreadError]); - - const handleNotificationClick = (e) => { - setNotificationVisible(!notificationVisible); - if (handleMenuClick) handleMenuClick(e); - }; - useEffect(() => { window.addEventListener("resize", handleResize); window.addEventListener("orientationchange", handleResize); @@ -688,8 +707,10 @@ function Header({ // Right-aligned items on desktop, merged on mobile { key: "notifications", - icon: ( - + icon: unreadLoading ? ( + + ) : ( + ), diff --git a/client/src/components/notification-center/notification-center.component.jsx b/client/src/components/notification-center/notification-center.component.jsx index df2b5feb3..025fdea94 100644 --- a/client/src/components/notification-center/notification-center.component.jsx +++ b/client/src/components/notification-center/notification-center.component.jsx @@ -1,10 +1,12 @@ +// notification-center.component.jsx import React from "react"; import { Virtuoso } from "react-virtuoso"; import { Button, Checkbox, List, Badge, Typography, Alert } from "antd"; import { useTranslation } from "react-i18next"; import "./notification-center.styles.scss"; +import { Link } from "react-router-dom"; -const { Text } = Typography; +const { Text, Title } = Typography; const NotificationCenterComponent = ({ visible, @@ -14,25 +16,35 @@ const NotificationCenterComponent = ({ error, showUnreadOnly, toggleUnreadOnly, - markAllRead + markAllRead, + loadMore }) => { const { t } = useTranslation(); const renderNotification = (index, notification) => { - console.log(`Rendering notification ${index}:`, { - id: notification.id, - scenarioTextLength: notification.scenarioText.length, - read: notification.read, - created_at: notification.created_at, - associationid: notification.associationid - }); + console.log("Rendering notification at index:", index, notification); return ( + {/* RO number as title/link */} + + + RO #{notification.roNumber} + + {new Date(notification.created_at).toLocaleString()} + {notification.scenarioText.map((text, idx) => ( @@ -40,48 +52,37 @@ const NotificationCenterComponent = ({ ))} - {new Date(notification.created_at).toLocaleString()} - {notification.associationid && Association ID: {notification.associationid}} ); }; - console.log( - "Rendering NotificationCenter with notifications:", - notifications.length, - notifications.map((n) => ({ - id: n.id, - read: n.read, - created_at: n.created_at, - associationid: n.associationid - })) - ); + console.log("NotificationCenterComponent render:", { notifications, loading, error, showUnreadOnly }); return ( - {t("notifications.title", "Notifications")} + {t("notifications.labels.new-notification-title")} toggleUnreadOnly(e.target.checked)}> - {t("notifications.showUnreadOnly", "Show Unread Only")} + {t("notifications.labels.show-unread-only")} !n.read)}> - {t("notifications.markAllRead", "Mark All Read")} + {t("notifications.labels.mark-all-read")} {error && onClose()} />} - {loading && !error && {t("notifications.loading", "Loading...")}} + {loading && !error && {t("notifications.labels.loading")}} ); }; diff --git a/client/src/components/notification-center/notification-center.container.jsx b/client/src/components/notification-center/notification-center.container.jsx index 6930765de..9f7a1a05c 100644 --- a/client/src/components/notification-center/notification-center.container.jsx +++ b/client/src/components/notification-center/notification-center.container.jsx @@ -1,13 +1,16 @@ +// notification-center.container.jsx import { useState, useEffect, useCallback } from "react"; import { useQuery, useMutation } from "@apollo/client"; import { connect } from "react-redux"; import NotificationCenterComponent from "./notification-center.component"; import { GET_NOTIFICATIONS, MARK_ALL_NOTIFICATIONS_READ } from "../../graphql/notifications.queries"; +import { useSocket } from "../../contexts/SocketIO/socketContext.jsx"; -export function NotificationCenterContainer({ visible, onClose }) { +export function NotificationCenterContainer({ visible, onClose, bodyshop }) { const [showUnreadOnly, setShowUnreadOnly] = useState(false); const [notifications, setNotifications] = useState([]); const [error, setError] = useState(null); + const { isConnected } = useSocket(); const { data, @@ -19,106 +22,115 @@ export function NotificationCenterContainer({ visible, onClose }) { variables: { limit: 20, offset: 0, - where: showUnreadOnly ? { read: { _is_null: true } } : {} // Default to all notifications + where: showUnreadOnly ? { read: { _is_null: true } } : {} }, - fetchPolicy: "cache-and-network", // Ensure reactivity to cache updates + fetchPolicy: "cache-and-network", notifyOnNetworkStatusChange: true, + pollInterval: isConnected ? 0 : 30000, + skip: false, onError: (err) => { setError(err.message); + console.error("GET_NOTIFICATIONS error:", err); setTimeout(() => refetch(), 2000); + }, + onCompleted: (data) => { + console.log("GET_NOTIFICATIONS completed:", data?.notifications); + } + }); + + const [markAllReadMutation, { error: mutationError }] = useMutation(MARK_ALL_NOTIFICATIONS_READ, { + update: (cache, { data: mutationData }) => { + const timestamp = new Date().toISOString(); + cache.modify({ + fields: { + notifications(existing = [], { readField }) { + return existing.map((notif) => { + if (readField("read", notif) === null) { + return { ...notif, read: timestamp }; + } + return notif; + }); + }, + notifications_aggregate() { + return { aggregate: { count: 0, __typename: "notifications_aggregate_fields" } }; + } + } + }); + + if (isConnected) { + const cachedNotifications = cache.readQuery({ + query: GET_NOTIFICATIONS, + variables: { + limit: 20, + offset: 0, + where: showUnreadOnly ? { read: { _is_null: true } } : {} + } + }); + + if (cachedNotifications?.notifications) { + cache.writeQuery({ + query: GET_NOTIFICATIONS, + variables: { + limit: 20, + offset: 0, + where: showUnreadOnly ? { read: { _is_null: true } } : {} + }, + data: { + notifications: cachedNotifications.notifications.map((notif) => + notif.read === null ? { ...notif, read: timestamp } : notif + ) + } + }); + } + } + }, + onError: (err) => { + setError(err.message); + console.error("MARK_ALL_NOTIFICATIONS_READ error:", err); } }); useEffect(() => { - console.log( - "Notifications data updated:", - data?.notifications?.map((n) => ({ - id: n.id, - read: n.read, - created_at: n.created_at, - associationid: n.associationid - })) - ); - }, [data]); - - const [markAllReadMutation, { error: mutationError }] = useMutation(MARK_ALL_NOTIFICATIONS_READ, { - update: (cache) => { - cache.modify({ - fields: { - notifications(existing = []) { - return existing.map((notif) => ({ - ...notif, - read: notif.read || new Date().toISOString() - })); - }, - notifications_aggregate() { - return { aggregate: { count: 0 } }; - } - } - }); - }, - onError: (err) => setError(err.message) - }); - - // Remove refetchNotifications function and useEffect/context logic - useEffect(() => { + console.log("Data changed:", data); if (data?.notifications) { const processedNotifications = data.notifications .map((notif) => { let scenarioText; let scenarioMeta; try { - scenarioText = - typeof notif.scenario_text === "string" ? JSON.parse(notif.scenario_text) : notif.scenario_text || []; - scenarioMeta = - typeof notif.scenario_meta === "string" ? JSON.parse(notif.scenario_meta) : notif.scenario_meta || []; + 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 = []; + scenarioMeta = {}; } - if (!Array.isArray(scenarioText)) scenarioText = [scenarioText]; + // Derive RO number from scenario_meta or assume it's available in notif + const roNumber = notif.job.ro_number || "RO Not Found"; // Adjust based on your data structure if (!Array.isArray(scenarioMeta)) scenarioMeta = [scenarioMeta]; - - console.log("Processed notification:", { - id: notif.id, - scenarioText, - scenarioMeta, - read: notif.read, - created_at: notif.created_at, - associationid: notif.associationid, - raw: notif - }); - return { + const processed = { id: notif.id, jobid: notif.jobid, associationid: notif.associationid, scenarioText, scenarioMeta, + roNumber, // Add RO number to notification object created_at: notif.created_at, read: notif.read, __typename: notif.__typename }; + console.log("Processed notification with RO:", processed); + return processed; }) - .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); // Explicitly sort by created_at desc - - console.log( - "Processed Notifications:", - processedNotifications.map((n) => ({ - id: n.id, - read: n.read, - created_at: n.created_at, - associationid: n.associationid - })) - ); - console.log("Number of notifications to render:", processedNotifications.length); + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + console.log("Setting notifications with RO:", processedNotifications); setNotifications(processedNotifications); setError(null); } else { - console.log("No data yet or error in data:", data, queryError); + console.log("No notifications in data:", data); } - }, [data, queryError]); + }, [data]); useEffect(() => { if (queryError || mutationError) { @@ -126,56 +138,61 @@ export function NotificationCenterContainer({ visible, onClose }) { } }, [queryError, mutationError]); + useEffect(() => { + console.log("Notifications state updated:", notifications); + }, [notifications]); + const loadMore = useCallback(() => { if (!loading && data?.notifications.length) { + console.log("Loading more notifications, current length:", data.notifications.length); fetchMore({ variables: { offset: data.notifications.length }, updateQuery: (prev, { fetchMoreResult }) => { if (!fetchMoreResult) return prev; + console.log("Fetched more:", fetchMoreResult.notifications); return { notifications: [...prev.notifications, ...fetchMoreResult.notifications] }; } - }).catch((err) => setError(err.message)); + }).catch((err) => { + setError(err.message); + console.error("Fetch more error:", err); + }); } }, [data?.notifications?.length, fetchMore, loading]); const handleToggleUnreadOnly = (value) => { setShowUnreadOnly(value); + console.log("Toggled showUnreadOnly:", value); }; const handleMarkAllRead = () => { - markAllReadMutation().catch((e) => - console.error(`Something went wrong marking all notifications read: ${e?.message || ""}`) - ); + console.log("Marking all notifications as read"); + markAllReadMutation() + .then(() => { + const timestamp = new Date().toISOString(); + setNotifications((prev) => { + const updatedNotifications = prev.map((notif) => + notif.read === null ? { ...notif, read: timestamp } : notif + ); + console.log("Updated notifications after mark all read:", updatedNotifications); + return [...updatedNotifications]; + }); + }) + .catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`)); }; useEffect(() => { - if (visible) { - const virtuosoRef = { current: null }; - virtuosoRef.current?.scrollTo({ top: 0 }); - const handleScroll = () => { - if (virtuosoRef.current && virtuosoRef.current.scrollTop + 400 >= virtuosoRef.current.scrollHeight) { - loadMore(); - } - }; - window.addEventListener("scroll", handleScroll); - return () => window.removeEventListener("scroll", handleScroll); + if (visible && !isConnected) { + console.log("Notification Center opened, socket disconnected, refetching..."); + refetch(); + } else if (visible && isConnected) { + console.log("Notification Center opened, socket connected, relying on cache..."); + refetch(); // Ensure fresh data even with socket } - }, [visible, loading, data, loadMore]); - - console.log( - "Rendering NotificationCenter with notifications:", - notifications.length, - notifications.map((n) => ({ - id: n.id, - read: n.read, - created_at: n.created_at - })) - ); + }, [visible, isConnected, refetch]); return ( - // Remove NotificationContext.Provider ); } -export default connect(null, null)(NotificationCenterContainer); +export default connect((state) => ({ bodyshop: state.user.bodyshop }), null)(NotificationCenterContainer); diff --git a/client/src/components/notification-center/notification-center.styles.scss b/client/src/components/notification-center/notification-center.styles.scss index 417716ef3..dfc3fdace 100644 --- a/client/src/components/notification-center/notification-center.styles.scss +++ b/client/src/components/notification-center/notification-center.styles.scss @@ -2,12 +2,12 @@ position: absolute; top: 64px; right: 0; - width: 400px; - background: #2a2d34; - color: #d9d9d9; - border: 1px solid #434343; - border-radius: 4px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + width: 600px; + background: #fff; /* White background, Ant’s default */ + color: rgba(0, 0, 0, 0.85); /* Primary text color in Ant 5 */ + border: 1px solid #d9d9d9; /* Neutral gray border */ + border-radius: 6px; /* Slightly larger radius per Ant 5 */ + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08), 0 3px 6px rgba(0, 0, 0, 0.06); /* Subtle Ant 5 shadow */ z-index: 1000; display: none; @@ -17,16 +17,16 @@ .notification-header { padding: 16px; - border-bottom: 1px solid #434343; + border-bottom: 1px solid #f0f0f0; /* Light gray border from Ant 5 */ display: flex; justify-content: space-between; align-items: center; - background: #1f2229; + background: #fafafa; /* Light gray background for header */ h3 { margin: 0; font-size: 16px; - color: #ffffff; + color: rgba(0, 0, 0, 0.85); /* Primary text color */ } .notification-controls { @@ -35,60 +35,79 @@ gap: 16px; .ant-checkbox-wrapper { - color: #d9d9d9; + color: rgba(0, 0, 0, 0.85); /* Match Ant’s text color */ } .ant-btn-link { - color: #40c4ff; - &:hover { color: #80deea; } - &:disabled { color: #616161; } + color: #1677ff; /* Ant 5 primary blue */ + &:hover { + color: #69b1ff; /* Lighter blue on hover */ + } + &:disabled { + color: rgba(0, 0, 0, 0.25); /* Disabled text color from Ant 5 */ + } } } } .notification-read { - background: #2a2d34; - color: #b0b0b0; + background: #fff; /* White background for read items */ + color: rgba(0, 0, 0, 0.65); /* Secondary text color */ } + .notification-unread { - background: #353942; - color: #ffffff; + background: #f5f5f5; /* Very light gray for unread items */ + color: rgba(0, 0, 0, 0.85); /* Primary text color */ } .ant-list { - overflow: auto; // Match Virtuoso’s default scrolling behavior - max-height: 100%; // Allow full height, let Virtuoso handle virtualization + overflow: auto; /* Match Virtuoso’s default scrolling behavior */ + max-height: 100%; /* Allow full height, let Virtuoso handle virtualization */ } .ant-list-item { - padding: 12px 16px; - border-bottom: 1px solid #434343; - display: block; // Ensure visibility - overflow: visible; // Prevent clipping within items - min-height: 80px; // Minimum height to ensure visibility of multi-line content + padding: 2px 16px; + border-bottom: 1px solid #f0f0f0; /* Light gray border */ + display: block; /* Ensure visibility */ + overflow: visible; /* Prevent clipping within items */ + min-height: 80px; /* Minimum height for multi-line content */ - .ant-typography { color: inherit; } - .ant-typography-secondary { font-size: 12px; color: #b0b0b0; } - .ant-badge-dot { background: #ff4d4f; } + .ant-typography { + color: inherit; /* Inherit from parent (read/unread) */ + } + + .ant-typography-secondary { + font-size: 12px; + color: rgba(0, 0, 0, 0.45); /* Ant 5 secondary text color */ + } + + .ant-badge-dot { + background: #ff4d4f; /* Keep red dot for unread, consistent with Ant */ + } ul { margin: 0; - padding-left: 20px; // Standard list padding - list-style-type: disc; // Ensure bullet points + padding-left: 20px; /* Standard list padding */ + list-style-type: disc; /* Ensure bullet points */ } li { - margin-bottom: 4px; // Space between list items + margin-bottom: 4px; /* Space between list items */ } } .ant-alert { margin: 8px; - background: #ff4d4f; - color: #fff; - border: none; + background: #fff1f0; /* Light red background for error per Ant 5 */ + color: rgba(0, 0, 0, 0.85); + border: 1px solid #ffa39e; /* Light red border */ - .ant-alert-message { color: #fff; } - .ant-alert-description { color: #ffebee; } + .ant-alert-message { + color: #ff4d4f; /* Red text for message */ + } + + .ant-alert-description { + color: rgba(0, 0, 0, 0.65); /* Slightly muted description */ + } } } diff --git a/client/src/contexts/SocketIO/socketContext.jsx b/client/src/contexts/SocketIO/socketContext.jsx index fc62f7221..7b7939499 100644 --- a/client/src/contexts/SocketIO/socketContext.jsx +++ b/client/src/contexts/SocketIO/socketContext.jsx @@ -1,13 +1,261 @@ -import React, { createContext } from "react"; -import useSocket from "./useSocket"; +// contexts/SocketIO/socketContext.jsx +import React, { createContext, useContext, useEffect, useRef, useState } from "react"; +import SocketIO from "socket.io-client"; +import { auth } from "../../firebase/firebase.utils"; +import { store } from "../../redux/store"; +import { addAlerts, setWssStatus } from "../../redux/application/application.actions"; +import client from "../../utils/GraphQLClient"; +import { useNotification } from "../Notifications/notificationContext.jsx"; +import { gql } from "@apollo/client"; -// Create the SocketContext const SocketContext = createContext(null); export const SocketProvider = ({ children, bodyshop }) => { - const { socket, clientId } = useSocket(bodyshop); + const socketRef = useRef(null); + const [clientId, setClientId] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const notification = useNotification(); - return {children}; + useEffect(() => { + const initializeSocket = async (token) => { + if (!bodyshop || !bodyshop.id || socketRef.current) return; // Prevent multiple instances + + const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : ""; + const socketInstance = SocketIO(endpoint, { + path: "/wss", + withCredentials: true, + auth: { token, bodyshopId: bodyshop.id }, + reconnectionAttempts: Infinity, + reconnectionDelay: 2000, + reconnectionDelayMax: 10000 + }); + + socketRef.current = socketInstance; + + const handleBodyshopMessage = (message) => { + if (!message || !message.type) return; + switch (message.type) { + case "alert-update": + store.dispatch(addAlerts(message.payload)); + break; + default: + break; + } + if (!import.meta.env.DEV) return; + console.log(`Received message for bodyshop ${bodyshop.id}:`, message); + }; + + const handleConnect = () => { + socketInstance.emit("join-bodyshop-room", bodyshop.id); + setClientId(socketInstance.id); + setIsConnected(true); + store.dispatch(setWssStatus("connected")); + console.log("Socket connected, ID:", socketInstance.id); + }; + + const handleReconnect = () => { + setIsConnected(true); + store.dispatch(setWssStatus("connected")); + }; + + const handleConnectionError = (err) => { + console.error("Socket connection error:", err); + setIsConnected(false); + if (err.message.includes("auth/id-token-expired")) { + console.warn("Token expired, refreshing..."); + auth.currentUser?.getIdToken(true).then((newToken) => { + socketInstance.auth = { token: newToken }; + socketInstance.connect(); + }); + } else { + store.dispatch(setWssStatus("error")); + } + }; + + const handleDisconnect = (reason) => { + console.warn("Socket disconnected:", reason); + setIsConnected(false); + store.dispatch(setWssStatus("disconnected")); + if (!socketInstance.connected && reason !== "io server disconnect") { + setTimeout(() => { + if (socketInstance.disconnected) { + console.log("Manually triggering reconnection..."); + socketInstance.connect(); + } + }, 2000); + } + }; + + const handleNotification = (data) => { + const { jobId, bodyShopId, notificationId, associationId, notifications } = data; + console.log("Socket Notification Received (ID:", notificationId, "):", { + jobId, + bodyShopId, + associationId, + notifications + }); + + const newNotification = { + __typename: "notifications", + id: notificationId, + jobid: jobId, + associationid: associationId || null, + scenario_text: JSON.stringify(notifications.map((notif) => notif.body)), + fcm_text: notifications.map((notif) => notif.body).join(". ") + ".", + scenario_meta: JSON.stringify(notifications.map((notif) => notif.variables || {})), + created_at: new Date(notifications[0].timestamp).toISOString(), + read: null + }; + + try { + const existingNotifications = + client.cache.readQuery({ + query: gql` + query GetNotifications { + notifications(order_by: { created_at: desc }) { + __typename + id + jobid + associationid + scenario_text + fcm_text + scenario_meta + created_at + read + } + } + ` + })?.notifications || []; + + if (existingNotifications.some((n) => n.id === newNotification.id)) { + console.log("Duplicate notification detected, skipping:", notificationId); + return; + } + + client.cache.writeQuery({ + query: gql` + query GetNotifications { + notifications(order_by: { created_at: desc }) { + __typename + id + jobid + associationid + scenario_text + fcm_text + scenario_meta + created_at + read + } + } + `, + data: { + notifications: [newNotification, ...existingNotifications].sort( + (a, b) => new Date(b.created_at) - new Date(a.created_at) + ) + }, + broadcast: true + }); + + console.log("Cache updated with new notification:", newNotification); + + client.cache.modify({ + id: "ROOT_QUERY", + fields: { + notifications_aggregate(existing = { aggregate: { count: 0 } }) { + const isUnread = newNotification.read === null; + const countChange = isUnread ? 1 : 0; + console.log("Updating unread count from socket:", existing.aggregate.count + countChange); + return { + ...existing, + aggregate: { + ...existing.aggregate, + count: existing.aggregate.count + countChange + } + }; + } + } + }); + + notification.info({ + message: "New Notification", + description: ( + + {notifications.map((notif, index) => ( + {notif.body} + ))} + + ), + placement: "topRight", + duration: 5 + }); + } catch (error) { + console.error("Error in handleNotification:", error); + } + }; + + socketInstance.on("connect", handleConnect); + socketInstance.on("reconnect", handleReconnect); + socketInstance.on("connect_error", handleConnectionError); + socketInstance.on("disconnect", handleDisconnect); + socketInstance.on("bodyshop-message", handleBodyshopMessage); + socketInstance.on("message", (message) => { + console.log("Raw socket message:", message); + try { + if (typeof message === "string" && message.startsWith("42")) { + const parsedMessage = JSON.parse(message.slice(2)); + const [event, data] = parsedMessage; + if (event === "notification") handleNotification(data); + } else if (Array.isArray(message)) { + const [event, data] = message; + if (event === "notification") handleNotification(data); + } + } catch (error) { + console.error("Error parsing socket message:", error); + } + }); + socketInstance.on("notification", handleNotification); + }; + + const unsubscribe = auth.onIdTokenChanged(async (user) => { + if (user) { + const token = await user.getIdToken(); + if (socketRef.current) { + socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id }); + } else { + initializeSocket(token); + } + } else { + if (socketRef.current) { + socketRef.current.disconnect(); + socketRef.current = null; + setIsConnected(false); + } + } + }); + + return () => { + unsubscribe(); + if (socketRef.current) { + socketRef.current.disconnect(); + socketRef.current = null; + setIsConnected(false); + } + }; + }, [bodyshop, notification]); + + return ( + + {children} + + ); +}; + +export const useSocket = () => { + const context = useContext(SocketContext); + if (!context) { + throw new Error("useSocket must be used within a SocketProvider"); + } + return context; }; export default SocketContext; diff --git a/client/src/contexts/SocketIO/useSocket.jsx b/client/src/contexts/SocketIO/useSocket.jsx deleted file mode 100644 index 9f3dc0569..000000000 --- a/client/src/contexts/SocketIO/useSocket.jsx +++ /dev/null @@ -1,241 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import SocketIO from "socket.io-client"; -import { auth } from "../../firebase/firebase.utils"; -import { store } from "../../redux/store"; -import { addAlerts, setWssStatus } from "../../redux/application/application.actions"; -import client from "../../utils/GraphQLClient"; -import { useNotification } from "../Notifications/notificationContext.jsx"; -import { gql } from "@apollo/client"; - -const useSocket = (bodyshop) => { - const socketRef = useRef(null); - const [clientId, setClientId] = useState(null); - const notification = useNotification(); - - useEffect(() => { - const initializeSocket = async (token) => { - if (!bodyshop || !bodyshop.id) return; - - const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : ""; - - const socketInstance = SocketIO(endpoint, { - path: "/wss", - withCredentials: true, - auth: { token, bodyshopId: bodyshop.id }, - reconnectionAttempts: Infinity, - reconnectionDelay: 2000, - reconnectionDelayMax: 10000 - }); - - socketRef.current = socketInstance; - - const handleBodyshopMessage = (message) => { - if (!message || !message.type) return; - switch (message.type) { - case "alert-update": - store.dispatch(addAlerts(message.payload)); - break; - default: - break; - } - if (!import.meta.env.DEV) return; - console.log(`Received message for bodyshop ${bodyshop.id}:`, message); - }; - - const handleConnect = () => { - socketInstance.emit("join-bodyshop-room", bodyshop.id); - setClientId(socketInstance.id); - store.dispatch(setWssStatus("connected")); - console.log("Socket connected, ID:", socketInstance.id); - }; - - const handleReconnect = () => { - store.dispatch(setWssStatus("connected")); - }; - - const handleConnectionError = (err) => { - console.error("Socket connection error:", err); - if (err.message.includes("auth/id-token-expired")) { - console.warn("Token expired, refreshing..."); - auth.currentUser?.getIdToken(true).then((newToken) => { - socketInstance.auth = { token: newToken }; - socketInstance.connect(); - }); - } else { - store.dispatch(setWssStatus("error")); - } - }; - - const handleDisconnect = (reason) => { - console.warn("Socket disconnected:", reason); - store.dispatch(setWssStatus("disconnected")); - if (!socketInstance.connected && reason !== "io server disconnect") { - setTimeout(() => { - if (socketInstance.disconnected) { - console.log("Manually triggering reconnection..."); - socketInstance.connect(); - } - }, 2000); - } - }; - - const handleNotification = (data) => { - const { jobId, bodyShopId, notificationId, associationId, notifications } = data; // Changed to associationId (capital I) - console.log("handleNotification - Received", { - jobId, - bodyShopId, - notificationId, - associationId, - notifications - }); - - // Construct the notification object to match the cache/database format - const newNotification = { - __typename: "notifications", - id: notificationId, - jobid: jobId, - associationid: associationId || null, // Use associationId from socket, default to null if missing - scenario_text: JSON.stringify(notifications.map((notif) => notif.body)), // Stringify as in the cache - fcm_text: notifications.map((notif) => notif.body).join(". ") + ".", - scenario_meta: JSON.stringify(notifications.map((notif) => notif.variables || {})), // Stringify as in the cache - created_at: new Date(notifications[0].timestamp).toISOString(), - read: null // Assume unread unless specified otherwise - }; - - try { - // Use writeQuery to add the new notification to the cache, ensuring normalization and broadcasting - client.cache.writeQuery({ - query: gql` - query GetNotifications { - notifications(order_by: { created_at: desc }) { - __typename - id - jobid - associationid - scenario_text - fcm_text - scenario_meta - created_at - read - } - } - `, - data: { - notifications: [ - newNotification, - ...( - client.cache.readQuery({ - query: gql` - query GetNotifications { - notifications(order_by: { created_at: desc }) { - __typename - id - jobid - associationid - scenario_text - fcm_text - scenario_meta - created_at - read - } - } - ` - })?.notifications || [] - ).filter((n) => n.id !== newNotification.id) - ].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) // Sort by created_at desc - }, - broadcast: true // Notify dependent queries of the cache update - }); - - console.log("Cache updated with new notification:", newNotification); - - // Update notifications_aggregate for unread count - client.cache.modify({ - id: "ROOT_QUERY", - fields: { - notifications_aggregate(existing = { aggregate: { count: 0 } }) { - const isUnread = newNotification.read === null; - const countChange = isUnread ? 1 : 0; - console.log("Updating unread count from socket:", existing.aggregate.count + countChange); - return { - ...existing, - aggregate: { - ...existing.aggregate, - count: existing.aggregate.count + countChange - } - }; - } - } - }); - - notification.info({ - message: "New Notification", - description: ( - - {notifications.map((notif, index) => ( - {notif.body} - ))} - - ), - placement: "topRight", - duration: 5 - }); - } catch (error) { - console.error("Error in handleNotification:", error); - } - }; - - socketInstance.on("connect", handleConnect); - socketInstance.on("reconnect", handleReconnect); - socketInstance.on("connect_error", handleConnectionError); - socketInstance.on("disconnect", handleDisconnect); - socketInstance.on("bodyshop-message", handleBodyshopMessage); - - socketInstance.on("message", (message) => { - console.log("Raw socket message:", message); - try { - if (typeof message === "string" && message.startsWith("42")) { - const parsedMessage = JSON.parse(message.slice(2)); - const [event, data] = parsedMessage; - if (event === "notification") handleNotification(data); - } else if (Array.isArray(message)) { - const [event, data] = message; - if (event === "notification") handleNotification(data); - } - } catch (error) { - console.error("Error parsing socket message:", error); - } - }); - - socketInstance.on("notification", handleNotification); - }; - - const unsubscribe = auth.onIdTokenChanged(async (user) => { - if (user) { - const token = await user.getIdToken(); - if (socketRef.current) { - socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id }); - } else { - initializeSocket(token); - } - } else { - if (socketRef.current) { - socketRef.current.disconnect(); - socketRef.current = null; - } - } - }); - - return () => { - unsubscribe(); - if (socketRef.current) { - socketRef.current.disconnect(); - socketRef.current = null; - } - }; - }, [bodyshop, notification]); - - return { socket: socketRef.current, clientId }; -}; - -export default useSocket; diff --git a/client/src/graphql/notifications.queries.js b/client/src/graphql/notifications.queries.js index 6a782ba75..e9a586e39 100644 --- a/client/src/graphql/notifications.queries.js +++ b/client/src/graphql/notifications.queries.js @@ -11,6 +11,9 @@ export const GET_NOTIFICATIONS = gql` scenario_meta created_at read + job { + ro_number + } } } `; @@ -32,18 +35,3 @@ export const MARK_ALL_NOTIFICATIONS_READ = gql` } } `; - -export const SUBSCRIBE_TO_NOTIFICATIONS = gql` - subscription SubscribeToNotifications { - notifications(where: { read: { _is_null: true } }, order_by: { created_at: desc }) { - id - jobid - associationid - scenario_text - fcm_text - scenario_meta - created_at - read - } - } -`; diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 1f58aa777..cf7756abf 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -3769,6 +3769,7 @@ }, "notifications": { "labels": { + "notification-center": "Notification Center", "scenario": "Scenario", "notificationscenarios": "Job Notification Scenarios", "save": "Save Scenarios",