From b395839b378aaa05c20bfb91cd290936b19d0020 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Mon, 24 Feb 2025 16:02:55 -0500 Subject: [PATCH] feature/IO-3096-GlobalNotifications - Checkpoint - Notification Center --- .../components/header/header.component.jsx | 28 +++- .../jobs-detail-header.component.jsx | 1 - .../notification-center.component.jsx | 81 ++++++++++ .../notification-center.container.jsx | 143 ++++++++++++++++++ .../notification-center.styles.scss | 94 ++++++++++++ .../production-board-kanban.component.jsx | 2 +- .../SocketIO/{useSocket.js => useSocket.jsx} | 100 +++++++++--- client/src/graphql/notifications.queries.js | 49 ++++++ .../jobs-detail.page.component.jsx | 2 - 9 files changed, 476 insertions(+), 24 deletions(-) create mode 100644 client/src/components/notification-center/notification-center.component.jsx create mode 100644 client/src/components/notification-center/notification-center.container.jsx create mode 100644 client/src/components/notification-center/notification-center.styles.scss rename client/src/contexts/SocketIO/{useSocket.js => useSocket.jsx} (52%) create mode 100644 client/src/graphql/notifications.queries.js diff --git a/client/src/components/header/header.component.jsx b/client/src/components/header/header.component.jsx index 96b0187ad..bc887edec 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 { Layout, Menu, Space } from "antd"; +import { Badge, Layout, Menu, Space } from "antd"; import { useTranslation } from "react-i18next"; import { BsKanban } from "react-icons/bs"; import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa"; @@ -47,6 +47,9 @@ import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; import LockWrapper from "../lock-wrapper/lock-wrapper.component"; import { useState, useEffect } from "react"; 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"; // Used to Determine if the Header is in Mobile Mode, and to toggle the multiple menus const HEADER_MOBILE_BREAKPOINT = 576; @@ -132,6 +135,19 @@ function Header({ setIsMobile(effectiveWidth <= HEADER_MOBILE_BREAKPOINT); }, 200); + const [notificationVisible, setNotificationVisible] = useState(false); + + const { data: unreadData } = useQuery(GET_UNREAD_COUNT, { + fetchPolicy: "cache-and-network" + }); + + const unreadCount = unreadData?.notifications_aggregate?.aggregate?.count || 0; + + const handleNotificationClick = (e) => { + setNotificationVisible(!notificationVisible); + if (handleMenuClick) handleMenuClick(e); + }; + useEffect(() => { window.addEventListener("resize", handleResize); window.addEventListener("orientationchange", handleResize); @@ -652,8 +668,13 @@ function Header({ // Right-aligned items on desktop, merged on mobile { key: "notifications", - icon: , - id: "header-notifications" + icon: ( + + + + ), + id: "header-notifications", + onClick: handleNotificationClick }, { key: "recent", @@ -774,6 +795,7 @@ function Header({ overflow: "visible" }} /> + setNotificationVisible(false)} /> )} 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 943b5aedd..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 @@ -51,7 +51,6 @@ const colSpan = { }; export function JobsDetailHeader({ job, bodyshop, disabled }) { - console.dir({ job }); const { t } = useTranslation(); const [notesClamped, setNotesClamped] = useState(true); const vehicleTitle = `${job.v_model_yr || ""} ${job.v_color || ""} 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..8b33be3ef --- /dev/null +++ b/client/src/components/notification-center/notification-center.component.jsx @@ -0,0 +1,81 @@ +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"; + +const { Text } = Typography; + +const NotificationCenterComponent = ({ + visible, + onClose, + notifications, + loading, + error, + showUnreadOnly, + toggleUnreadOnly, + markAllRead +}) => { + const { t } = useTranslation(); + + const renderNotification = (index, notification) => { + console.log(`Rendering notification ${index}:`, { + id: notification.id, + scenarioTextLength: notification.scenarioText.length, + key: `${notification.id}-${index}` + }); + return ( + + +
+ +
    + {notification.scenarioText.map((text, idx) => ( +
  • {text}
  • + ))} +
+
+ {new Date(notification.created_at).toLocaleString()} +
+
+
+ ); + }; + + console.log("Rendering NotificationCenter with notifications:", { + count: notifications.length, + ids: notifications.map((n) => n.id), + totalCount: notifications.length + }); + + return ( +
+
+

{t("notifications.title", "Notifications")}

+
+ toggleUnreadOnly(e.target.checked)}> + {t("notifications.showUnreadOnly", "Show Unread Only")} + + +
+
+ {error && onClose()} />} + + {loading && !error &&
{t("notifications.loading", "Loading...")}
} +
+ ); +}; + +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..ff5e26d22 --- /dev/null +++ b/client/src/components/notification-center/notification-center.container.jsx @@ -0,0 +1,143 @@ +import React, { useState, useEffect } 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"; + +export function NotificationCenterContainer({ visible, onClose }) { + const [showUnreadOnly, setShowUnreadOnly] = useState(false); + const [notifications, setNotifications] = useState([]); + const [error, setError] = useState(null); + + const { + data, + fetchMore, + loading, + error: queryError, + refetch + } = useQuery(GET_NOTIFICATIONS, { + variables: { + limit: 20, + offset: 0, + where: showUnreadOnly ? { read: { _is_null: true } } : {} + }, + fetchPolicy: "cache-and-network", + notifyOnNetworkStatusChange: true, + onError: (err) => { + setError(err.message); + setTimeout(() => refetch(), 2000); + } + }); + + 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) + }); + + useEffect(() => { + if (data?.notifications) { + const processedNotifications = data.notifications.map((notif) => { + let scenarioText; + try { + scenarioText = + typeof notif.scenario_text === "string" ? JSON.parse(notif.scenario_text) : notif.scenario_text || []; + } catch (e) { + console.error("Error parsing JSON for notification:", notif.id, e); + scenarioText = [notif.fcm_text || "Invalid notification data"]; + } + + if (!Array.isArray(scenarioText)) scenarioText = [scenarioText]; + + return { + id: notif.id, + jobid: notif.jobid, + associationid: notif.associationid, + scenarioText, + created_at: notif.created_at, + read: notif.read, + __typename: notif.__typename + }; + }); + + console.log("Processed Notifications:", processedNotifications); + console.log("Number of notifications to render:", processedNotifications.length); + setNotifications(processedNotifications); + setError(null); + } else { + console.log("No data yet or error in data:", data, queryError); + } + }, [data]); + + useEffect(() => { + if (queryError || mutationError) { + setError(queryError?.message || mutationError?.message); + } + }, [queryError, mutationError]); + + const loadMore = () => { + if (!loading && data?.notifications.length) { + fetchMore({ + variables: { offset: data.notifications.length }, + updateQuery: (prev, { fetchMoreResult }) => { + if (!fetchMoreResult) return prev; + return { + notifications: [...prev.notifications, ...fetchMoreResult.notifications] + }; + } + }).catch((err) => setError(err.message)); + } + }; + + const handleToggleUnreadOnly = (value) => { + setShowUnreadOnly(value); + }; + + const handleMarkAllRead = () => { + markAllReadMutation(); + }; + + 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); + } + }, [visible, loading, data]); + + console.log("Rendering NotificationCenter with notifications:", notifications.length, notifications); + + return ( + + ); +} + +export default connect(null, 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..417716ef3 --- /dev/null +++ b/client/src/components/notification-center/notification-center.styles.scss @@ -0,0 +1,94 @@ +.notification-center { + 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); + z-index: 1000; + display: none; + + &.visible { + display: block; + } + + .notification-header { + padding: 16px; + border-bottom: 1px solid #434343; + display: flex; + justify-content: space-between; + align-items: center; + background: #1f2229; + + h3 { + margin: 0; + font-size: 16px; + color: #ffffff; + } + + .notification-controls { + display: flex; + align-items: center; + gap: 16px; + + .ant-checkbox-wrapper { + color: #d9d9d9; + } + + .ant-btn-link { + color: #40c4ff; + &:hover { color: #80deea; } + &:disabled { color: #616161; } + } + } + } + + .notification-read { + background: #2a2d34; + color: #b0b0b0; + } + .notification-unread { + background: #353942; + color: #ffffff; + } + + .ant-list { + 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 + + .ant-typography { color: inherit; } + .ant-typography-secondary { font-size: 12px; color: #b0b0b0; } + .ant-badge-dot { background: #ff4d4f; } + + ul { + margin: 0; + padding-left: 20px; // Standard list padding + list-style-type: disc; // Ensure bullet points + } + + li { + margin-bottom: 4px; // Space between list items + } + } + + .ant-alert { + margin: 8px; + background: #ff4d4f; + color: #fff; + border: none; + + .ant-alert-message { color: #fff; } + .ant-alert-description { color: #ffebee; } + } +} diff --git a/client/src/components/production-board-kanban/production-board-kanban.component.jsx b/client/src/components/production-board-kanban/production-board-kanban.component.jsx index 574ee1c5a..d8315f576 100644 --- a/client/src/components/production-board-kanban/production-board-kanban.component.jsx +++ b/client/src/components/production-board-kanban/production-board-kanban.component.jsx @@ -4,7 +4,7 @@ import { useApolloClient } from "@apollo/client"; import { Button, Skeleton, Space } from "antd"; import cloneDeep from "lodash/cloneDeep"; import isEqual from "lodash/isEqual"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; diff --git a/client/src/contexts/SocketIO/useSocket.js b/client/src/contexts/SocketIO/useSocket.jsx similarity index 52% rename from client/src/contexts/SocketIO/useSocket.js rename to client/src/contexts/SocketIO/useSocket.jsx index 408694440..494f85277 100644 --- a/client/src/contexts/SocketIO/useSocket.js +++ b/client/src/contexts/SocketIO/useSocket.jsx @@ -3,10 +3,13 @@ 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 "../../contexts/Notifications/notificationContext.jsx"; const useSocket = (bodyshop) => { const socketRef = useRef(null); const [clientId, setClientId] = useState(null); + const notification = useNotification(); useEffect(() => { const initializeSocket = async (token) => { @@ -25,10 +28,8 @@ const useSocket = (bodyshop) => { socketRef.current = socketInstance; - // Handle socket events const handleBodyshopMessage = (message) => { if (!message || !message.type) return; - switch (message.type) { case "alert-update": store.dispatch(addAlerts(message.payload)); @@ -36,7 +37,6 @@ const useSocket = (bodyshop) => { default: break; } - if (!import.meta.env.DEV) return; console.log(`Received message for bodyshop ${bodyshop.id}:`, message); }; @@ -45,6 +45,7 @@ const useSocket = (bodyshop) => { socketInstance.emit("join-bodyshop-room", bodyshop.id); setClientId(socketInstance.id); store.dispatch(setWssStatus("connected")); + console.log("Socket connected, ID:", socketInstance.id); }; const handleReconnect = () => { @@ -53,13 +54,11 @@ const useSocket = (bodyshop) => { const handleConnectionError = (err) => { console.error("Socket connection error:", err); - - // Handle token expiration if (err.message.includes("auth/id-token-expired")) { console.warn("Token expired, refreshing..."); auth.currentUser?.getIdToken(true).then((newToken) => { - socketInstance.auth = { token: newToken }; // Update socket auth - socketInstance.connect(); // Retry connection + socketInstance.auth = { token: newToken }; + socketInstance.connect(); }); } else { store.dispatch(setWssStatus("error")); @@ -69,39 +68,107 @@ const useSocket = (bodyshop) => { const handleDisconnect = (reason) => { console.warn("Socket disconnected:", reason); store.dispatch(setWssStatus("disconnected")); - - // Manually trigger reconnection if necessary if (!socketInstance.connected && reason !== "io server disconnect") { setTimeout(() => { if (socketInstance.disconnected) { console.log("Manually triggering reconnection..."); socketInstance.connect(); } - }, 2000); // Retry after 2 seconds + }, 2000); + } + }; + + const handleNotification = (data) => { + const { jobId, bodyShopId, notifications, notificationId } = data; + console.log("handleNotification - Received", { jobId, bodyShopId, notificationId, notifications }); + + const newNotification = { + id: notificationId, + jobid: jobId, + associationid: null, + scenario_text: notifications.map((notif) => notif.body), + fcm_text: notifications.map((notif) => notif.body).join(". ") + ".", + scenario_meta: notifications.map((notif) => notif.variables || {}), + created_at: new Date(notifications[0].timestamp).toISOString(), + read: null, + __typename: "notifications" + }; + + try { + client.cache.modify({ + id: "ROOT_QUERY", + fields: { + notifications(existing = []) { + if (existing.some((n) => n.id === newNotification.id)) return existing; + console.log("Adding to cache", newNotification); + return [newNotification, ...existing]; + }, + notifications_aggregate(existing = { aggregate: { count: 0 } }) { + // Fallback if aggregate isn’t in schema yet + if (!existing) return existing; + console.log("Updating unread count", existing.aggregate.count + 1); + return { + ...existing, + aggregate: { + ...existing.aggregate, + count: existing.aggregate.count + 1 + } + }; + } + } + }); + + notification.info({ + message: "New Notification", + description: ( +
    + {notifications.map((notif, index) => ( +
  • {notif.body}
  • + ))} +
+ ), + placement: "topRight", + duration: 5 + }); + } catch (error) { + console.error("Error in handleNotification:", error); } }; - // Register event handlers 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) { - // Update token if socket exists socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id }); } else { - // Initialize socket if not already connected initializeSocket(token); } } else { - // User is not authenticated if (socketRef.current) { socketRef.current.disconnect(); socketRef.current = null; @@ -109,7 +176,6 @@ const useSocket = (bodyshop) => { } }); - // Clean up on unmount return () => { unsubscribe(); if (socketRef.current) { @@ -117,7 +183,7 @@ const useSocket = (bodyshop) => { socketRef.current = null; } }; - }, [bodyshop]); + }, [bodyshop, client.cache]); return { socket: socketRef.current, clientId }; }; diff --git a/client/src/graphql/notifications.queries.js b/client/src/graphql/notifications.queries.js new file mode 100644 index 000000000..44e0d3da2 --- /dev/null +++ b/client/src/graphql/notifications.queries.js @@ -0,0 +1,49 @@ +import { gql } from "@apollo/client"; + +export const GET_NOTIFICATIONS = gql` + query GetNotifications($limit: Int!, $offset: Int!, $where: notifications_bool_exp) { + notifications(limit: $limit, offset: $offset, order_by: { created_at: desc }, where: $where) { + id + jobid + associationid + scenario_text + fcm_text + scenario_meta + created_at + read + } + } +`; + +export const GET_UNREAD_COUNT = gql` + query GetUnreadCount { + notifications_aggregate(where: { read: { _is_null: true } }) { + aggregate { + count + } + } + } +`; + +export const MARK_ALL_NOTIFICATIONS_READ = gql` + mutation MarkAllNotificationsRead { + update_notifications(where: { read: { _is_null: true } }, _set: { read: "now()" }) { + affected_rows + } + } +`; + +export const SUBSCRIBE_TO_NOTIFICATIONS = gql` + subscription SubscribeToNotifications { + notifications(order_by: { created_at: desc }, limit: 1) { + id + jobid + associationid + scenario_text + fcm_text + scenario_meta + created_at + read + } + } +`; diff --git a/client/src/pages/jobs-detail/jobs-detail.page.component.jsx b/client/src/pages/jobs-detail/jobs-detail.page.component.jsx index b65f15e8e..4f8a1458b 100644 --- a/client/src/pages/jobs-detail/jobs-detail.page.component.jsx +++ b/client/src/pages/jobs-detail/jobs-detail.page.component.jsx @@ -267,8 +267,6 @@ export function JobsDetailPage({ } }; - const handleWatchClick = () => {}; - const menuExtra = (