feature/IO-3096-GlobalNotifications - Checkpoint
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { useSplitClient } from "@splitsoftware/splitio-react";
|
import { useSplitClient } from "@splitsoftware/splitio-react";
|
||||||
import { Button, Result } from "antd";
|
import { Button, Result } from "antd";
|
||||||
import LogRocket from "logrocket";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { Route, Routes } from "react-router-dom";
|
import { Route, Routes } from "react-router-dom";
|
||||||
@@ -23,8 +23,6 @@ import InstanceRenderMgr from "../utils/instanceRenderMgr";
|
|||||||
import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx";
|
import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx";
|
||||||
import { SocketProvider } from "../contexts/SocketIO/socketContext.jsx";
|
import { SocketProvider } from "../contexts/SocketIO/socketContext.jsx";
|
||||||
import { NotificationProvider } from "../contexts/Notifications/notificationContext.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 ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component"));
|
||||||
const ManagePage = lazy(() => import("../pages/manage/manage.page.container"));
|
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 client = useSplitClient().client;
|
||||||
const [listenersAdded, setListenersAdded] = useState(false);
|
const [listenersAdded, setListenersAdded] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const apolloClient = useApolloClient();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
@@ -107,114 +104,6 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
|
|||||||
}
|
}
|
||||||
}, [bodyshop, client, currentUser.authorized]);
|
}, [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) {
|
if (currentUser.authorized === null) {
|
||||||
return <LoadingSpinner message={t("general.labels.loggingin")} />;
|
return <LoadingSpinner message={t("general.labels.loggingin")} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import Icon, {
|
|||||||
UserOutlined
|
UserOutlined
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { BsKanban } from "react-icons/bs";
|
import { BsKanban } from "react-icons/bs";
|
||||||
import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa";
|
import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa";
|
||||||
@@ -50,6 +50,7 @@ import { debounce } from "lodash";
|
|||||||
import { useQuery } from "@apollo/client";
|
import { useQuery } from "@apollo/client";
|
||||||
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
|
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
|
||||||
import { NotificationCenterContainer } from "../notification-center/notification-center.container.jsx";
|
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
|
// Used to Determine if the Header is in Mobile Mode, and to toggle the multiple menus
|
||||||
const HEADER_MOBILE_BREAKPOINT = 576;
|
const HEADER_MOBILE_BREAKPOINT = 576;
|
||||||
@@ -125,6 +126,57 @@ function Header({
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
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 [isMobile, setIsMobile] = useState(() => {
|
||||||
const effectiveWidth = window.innerWidth / (window.devicePixelRatio || 1);
|
const effectiveWidth = window.innerWidth / (window.devicePixelRatio || 1);
|
||||||
return effectiveWidth <= HEADER_MOBILE_BREAKPOINT;
|
return effectiveWidth <= HEADER_MOBILE_BREAKPOINT;
|
||||||
@@ -135,39 +187,6 @@ function Header({
|
|||||||
setIsMobile(effectiveWidth <= HEADER_MOBILE_BREAKPOINT);
|
setIsMobile(effectiveWidth <= HEADER_MOBILE_BREAKPOINT);
|
||||||
}, 200);
|
}, 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(() => {
|
useEffect(() => {
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
window.addEventListener("orientationchange", handleResize);
|
window.addEventListener("orientationchange", handleResize);
|
||||||
@@ -688,8 +707,10 @@ function Header({
|
|||||||
// Right-aligned items on desktop, merged on mobile
|
// Right-aligned items on desktop, merged on mobile
|
||||||
{
|
{
|
||||||
key: "notifications",
|
key: "notifications",
|
||||||
icon: (
|
icon: unreadLoading ? (
|
||||||
<Badge count={unreadCount}>
|
<Spin size="small" />
|
||||||
|
) : (
|
||||||
|
<Badge count={displayCount}>
|
||||||
<BellFilled />
|
<BellFilled />
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
// notification-center.component.jsx
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Virtuoso } from "react-virtuoso";
|
import { Virtuoso } from "react-virtuoso";
|
||||||
import { Button, Checkbox, List, Badge, Typography, Alert } from "antd";
|
import { Button, Checkbox, List, Badge, Typography, Alert } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import "./notification-center.styles.scss";
|
import "./notification-center.styles.scss";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
const NotificationCenterComponent = ({
|
const NotificationCenterComponent = ({
|
||||||
visible,
|
visible,
|
||||||
@@ -14,25 +16,35 @@ const NotificationCenterComponent = ({
|
|||||||
error,
|
error,
|
||||||
showUnreadOnly,
|
showUnreadOnly,
|
||||||
toggleUnreadOnly,
|
toggleUnreadOnly,
|
||||||
markAllRead
|
markAllRead,
|
||||||
|
loadMore
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const renderNotification = (index, notification) => {
|
const renderNotification = (index, notification) => {
|
||||||
console.log(`Rendering notification ${index}:`, {
|
console.log("Rendering notification at index:", index, notification);
|
||||||
id: notification.id,
|
|
||||||
scenarioTextLength: notification.scenarioText.length,
|
|
||||||
read: notification.read,
|
|
||||||
created_at: notification.created_at,
|
|
||||||
associationid: notification.associationid
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
<List.Item
|
<List.Item
|
||||||
key={`${notification.id}-${index}`} // Ensure unique key per render
|
key={`${notification.id}-${index}`}
|
||||||
className={notification.read ? "notification-read" : "notification-unread"}
|
className={notification.read ? "notification-read" : "notification-unread"}
|
||||||
>
|
>
|
||||||
<Badge dot={!notification.read}>
|
<Badge dot={!notification.read}>
|
||||||
<div>
|
<div>
|
||||||
|
{/* RO number as title/link */}
|
||||||
|
<Title
|
||||||
|
level={5}
|
||||||
|
style={{
|
||||||
|
margin: "0 0 8px 0",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link to={`/manage/jobs/${notification.jobid}`} target="_blank">
|
||||||
|
RO #{notification.roNumber}
|
||||||
|
</Link>
|
||||||
|
<Text type="secondary">{new Date(notification.created_at).toLocaleString()}</Text>
|
||||||
|
</Title>
|
||||||
<Text strong={!notification.read}>
|
<Text strong={!notification.read}>
|
||||||
<ul>
|
<ul>
|
||||||
{notification.scenarioText.map((text, idx) => (
|
{notification.scenarioText.map((text, idx) => (
|
||||||
@@ -40,48 +52,37 @@ const NotificationCenterComponent = ({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</Text>
|
</Text>
|
||||||
<Text type="secondary">{new Date(notification.created_at).toLocaleString()}</Text>
|
|
||||||
{notification.associationid && <Text type="secondary">Association ID: {notification.associationid}</Text>}
|
|
||||||
</div>
|
</div>
|
||||||
</Badge>
|
</Badge>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(
|
console.log("NotificationCenterComponent render:", { notifications, loading, error, showUnreadOnly });
|
||||||
"Rendering NotificationCenter with notifications:",
|
|
||||||
notifications.length,
|
|
||||||
notifications.map((n) => ({
|
|
||||||
id: n.id,
|
|
||||||
read: n.read,
|
|
||||||
created_at: n.created_at,
|
|
||||||
associationid: n.associationid
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`notification-center ${visible ? "visible" : ""}`}>
|
<div className={`notification-center ${visible ? "visible" : ""}`}>
|
||||||
<div className="notification-header">
|
<div className="notification-header">
|
||||||
<h3>{t("notifications.title", "Notifications")}</h3>
|
<h3>{t("notifications.labels.new-notification-title")}</h3>
|
||||||
<div className="notification-controls">
|
<div className="notification-controls">
|
||||||
<Checkbox checked={showUnreadOnly} onChange={(e) => toggleUnreadOnly(e.target.checked)}>
|
<Checkbox checked={showUnreadOnly} onChange={(e) => toggleUnreadOnly(e.target.checked)}>
|
||||||
{t("notifications.showUnreadOnly", "Show Unread Only")}
|
{t("notifications.labels.show-unread-only")}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<Button type="link" onClick={markAllRead} disabled={!notifications.some((n) => !n.read)}>
|
<Button type="link" onClick={markAllRead} disabled={!notifications.some((n) => !n.read)}>
|
||||||
{t("notifications.markAllRead", "Mark All Read")}
|
{t("notifications.labels.mark-all-read")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && <Alert message="Error" description={error} type="error" closable onClose={() => onClose()} />}
|
{error && <Alert message="Error" description={error} type="error" closable onClose={() => onClose()} />}
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
style={{ height: "400px", width: "100%", overflow: "auto" }} // Default Virtuoso styling
|
style={{ height: "400px", width: "100%" }}
|
||||||
data={notifications}
|
data={notifications}
|
||||||
totalCount={notifications.length}
|
totalCount={notifications.length}
|
||||||
overscan={20}
|
overscan={200}
|
||||||
increaseViewportBy={200}
|
endReached={loadMore}
|
||||||
itemContent={renderNotification}
|
itemContent={renderNotification}
|
||||||
/>
|
/>
|
||||||
{loading && !error && <div>{t("notifications.loading", "Loading...")}</div>}
|
{loading && !error && <div>{t("notifications.labels.loading")}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
|
// notification-center.container.jsx
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useQuery, useMutation } from "@apollo/client";
|
import { useQuery, useMutation } from "@apollo/client";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import NotificationCenterComponent from "./notification-center.component";
|
import NotificationCenterComponent from "./notification-center.component";
|
||||||
import { GET_NOTIFICATIONS, MARK_ALL_NOTIFICATIONS_READ } from "../../graphql/notifications.queries";
|
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 [showUnreadOnly, setShowUnreadOnly] = useState(false);
|
||||||
const [notifications, setNotifications] = useState([]);
|
const [notifications, setNotifications] = useState([]);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
const { isConnected } = useSocket();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -19,106 +22,115 @@ export function NotificationCenterContainer({ visible, onClose }) {
|
|||||||
variables: {
|
variables: {
|
||||||
limit: 20,
|
limit: 20,
|
||||||
offset: 0,
|
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,
|
notifyOnNetworkStatusChange: true,
|
||||||
|
pollInterval: isConnected ? 0 : 30000,
|
||||||
|
skip: false,
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
|
console.error("GET_NOTIFICATIONS error:", err);
|
||||||
setTimeout(() => refetch(), 2000);
|
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(() => {
|
useEffect(() => {
|
||||||
console.log(
|
console.log("Data changed:", data);
|
||||||
"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(() => {
|
|
||||||
if (data?.notifications) {
|
if (data?.notifications) {
|
||||||
const processedNotifications = data.notifications
|
const processedNotifications = data.notifications
|
||||||
.map((notif) => {
|
.map((notif) => {
|
||||||
let scenarioText;
|
let scenarioText;
|
||||||
let scenarioMeta;
|
let scenarioMeta;
|
||||||
try {
|
try {
|
||||||
scenarioText =
|
scenarioText = notif.scenario_text ? JSON.parse(notif.scenario_text) : [];
|
||||||
typeof notif.scenario_text === "string" ? JSON.parse(notif.scenario_text) : notif.scenario_text || [];
|
scenarioMeta = notif.scenario_meta ? JSON.parse(notif.scenario_meta) : {};
|
||||||
scenarioMeta =
|
|
||||||
typeof notif.scenario_meta === "string" ? JSON.parse(notif.scenario_meta) : notif.scenario_meta || [];
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error parsing JSON for notification:", notif.id, e);
|
console.error("Error parsing JSON for notification:", notif.id, e);
|
||||||
scenarioText = [notif.fcm_text || "Invalid notification data"];
|
scenarioText = [notif.fcm_text || "Invalid notification data"];
|
||||||
scenarioMeta = [];
|
scenarioMeta = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(scenarioText)) scenarioText = [scenarioText];
|
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];
|
if (!Array.isArray(scenarioMeta)) scenarioMeta = [scenarioMeta];
|
||||||
|
const processed = {
|
||||||
console.log("Processed notification:", {
|
|
||||||
id: notif.id,
|
|
||||||
scenarioText,
|
|
||||||
scenarioMeta,
|
|
||||||
read: notif.read,
|
|
||||||
created_at: notif.created_at,
|
|
||||||
associationid: notif.associationid,
|
|
||||||
raw: notif
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
id: notif.id,
|
id: notif.id,
|
||||||
jobid: notif.jobid,
|
jobid: notif.jobid,
|
||||||
associationid: notif.associationid,
|
associationid: notif.associationid,
|
||||||
scenarioText,
|
scenarioText,
|
||||||
scenarioMeta,
|
scenarioMeta,
|
||||||
|
roNumber, // Add RO number to notification object
|
||||||
created_at: notif.created_at,
|
created_at: notif.created_at,
|
||||||
read: notif.read,
|
read: notif.read,
|
||||||
__typename: notif.__typename
|
__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
|
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||||
|
console.log("Setting notifications with RO:", processedNotifications);
|
||||||
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);
|
|
||||||
setNotifications(processedNotifications);
|
setNotifications(processedNotifications);
|
||||||
setError(null);
|
setError(null);
|
||||||
} else {
|
} else {
|
||||||
console.log("No data yet or error in data:", data, queryError);
|
console.log("No notifications in data:", data);
|
||||||
}
|
}
|
||||||
}, [data, queryError]);
|
}, [data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (queryError || mutationError) {
|
if (queryError || mutationError) {
|
||||||
@@ -126,56 +138,61 @@ export function NotificationCenterContainer({ visible, onClose }) {
|
|||||||
}
|
}
|
||||||
}, [queryError, mutationError]);
|
}, [queryError, mutationError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("Notifications state updated:", notifications);
|
||||||
|
}, [notifications]);
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (!loading && data?.notifications.length) {
|
if (!loading && data?.notifications.length) {
|
||||||
|
console.log("Loading more notifications, current length:", data.notifications.length);
|
||||||
fetchMore({
|
fetchMore({
|
||||||
variables: { offset: data.notifications.length },
|
variables: { offset: data.notifications.length },
|
||||||
updateQuery: (prev, { fetchMoreResult }) => {
|
updateQuery: (prev, { fetchMoreResult }) => {
|
||||||
if (!fetchMoreResult) return prev;
|
if (!fetchMoreResult) return prev;
|
||||||
|
console.log("Fetched more:", fetchMoreResult.notifications);
|
||||||
return {
|
return {
|
||||||
notifications: [...prev.notifications, ...fetchMoreResult.notifications]
|
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]);
|
}, [data?.notifications?.length, fetchMore, loading]);
|
||||||
|
|
||||||
const handleToggleUnreadOnly = (value) => {
|
const handleToggleUnreadOnly = (value) => {
|
||||||
setShowUnreadOnly(value);
|
setShowUnreadOnly(value);
|
||||||
|
console.log("Toggled showUnreadOnly:", value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkAllRead = () => {
|
const handleMarkAllRead = () => {
|
||||||
markAllReadMutation().catch((e) =>
|
console.log("Marking all notifications as read");
|
||||||
console.error(`Something went wrong marking all notifications read: ${e?.message || ""}`)
|
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(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible && !isConnected) {
|
||||||
const virtuosoRef = { current: null };
|
console.log("Notification Center opened, socket disconnected, refetching...");
|
||||||
virtuosoRef.current?.scrollTo({ top: 0 });
|
refetch();
|
||||||
const handleScroll = () => {
|
} else if (visible && isConnected) {
|
||||||
if (virtuosoRef.current && virtuosoRef.current.scrollTop + 400 >= virtuosoRef.current.scrollHeight) {
|
console.log("Notification Center opened, socket connected, relying on cache...");
|
||||||
loadMore();
|
refetch(); // Ensure fresh data even with socket
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("scroll", handleScroll);
|
|
||||||
return () => window.removeEventListener("scroll", handleScroll);
|
|
||||||
}
|
}
|
||||||
}, [visible, loading, data, loadMore]);
|
}, [visible, isConnected, refetch]);
|
||||||
|
|
||||||
console.log(
|
|
||||||
"Rendering NotificationCenter with notifications:",
|
|
||||||
notifications.length,
|
|
||||||
notifications.map((n) => ({
|
|
||||||
id: n.id,
|
|
||||||
read: n.read,
|
|
||||||
created_at: n.created_at
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Remove NotificationContext.Provider
|
|
||||||
<NotificationCenterComponent
|
<NotificationCenterComponent
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
@@ -185,8 +202,9 @@ export function NotificationCenterContainer({ visible, onClose }) {
|
|||||||
showUnreadOnly={showUnreadOnly}
|
showUnreadOnly={showUnreadOnly}
|
||||||
toggleUnreadOnly={handleToggleUnreadOnly}
|
toggleUnreadOnly={handleToggleUnreadOnly}
|
||||||
markAllRead={handleMarkAllRead}
|
markAllRead={handleMarkAllRead}
|
||||||
|
loadMore={loadMore}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(null, null)(NotificationCenterContainer);
|
export default connect((state) => ({ bodyshop: state.user.bodyshop }), null)(NotificationCenterContainer);
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 64px;
|
top: 64px;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: 400px;
|
width: 600px;
|
||||||
background: #2a2d34;
|
background: #fff; /* White background, Ant’s default */
|
||||||
color: #d9d9d9;
|
color: rgba(0, 0, 0, 0.85); /* Primary text color in Ant 5 */
|
||||||
border: 1px solid #434343;
|
border: 1px solid #d9d9d9; /* Neutral gray border */
|
||||||
border-radius: 4px;
|
border-radius: 6px; /* Slightly larger radius per Ant 5 */
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
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;
|
z-index: 1000;
|
||||||
display: none;
|
display: none;
|
||||||
|
|
||||||
@@ -17,16 +17,16 @@
|
|||||||
|
|
||||||
.notification-header {
|
.notification-header {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-bottom: 1px solid #434343;
|
border-bottom: 1px solid #f0f0f0; /* Light gray border from Ant 5 */
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: #1f2229;
|
background: #fafafa; /* Light gray background for header */
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #ffffff;
|
color: rgba(0, 0, 0, 0.85); /* Primary text color */
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-controls {
|
.notification-controls {
|
||||||
@@ -35,60 +35,79 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|
||||||
.ant-checkbox-wrapper {
|
.ant-checkbox-wrapper {
|
||||||
color: #d9d9d9;
|
color: rgba(0, 0, 0, 0.85); /* Match Ant’s text color */
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-btn-link {
|
.ant-btn-link {
|
||||||
color: #40c4ff;
|
color: #1677ff; /* Ant 5 primary blue */
|
||||||
&:hover { color: #80deea; }
|
&:hover {
|
||||||
&:disabled { color: #616161; }
|
color: #69b1ff; /* Lighter blue on hover */
|
||||||
|
}
|
||||||
|
&:disabled {
|
||||||
|
color: rgba(0, 0, 0, 0.25); /* Disabled text color from Ant 5 */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-read {
|
.notification-read {
|
||||||
background: #2a2d34;
|
background: #fff; /* White background for read items */
|
||||||
color: #b0b0b0;
|
color: rgba(0, 0, 0, 0.65); /* Secondary text color */
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-unread {
|
.notification-unread {
|
||||||
background: #353942;
|
background: #f5f5f5; /* Very light gray for unread items */
|
||||||
color: #ffffff;
|
color: rgba(0, 0, 0, 0.85); /* Primary text color */
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-list {
|
.ant-list {
|
||||||
overflow: auto; // Match Virtuoso’s default scrolling behavior
|
overflow: auto; /* Match Virtuoso’s default scrolling behavior */
|
||||||
max-height: 100%; // Allow full height, let Virtuoso handle virtualization
|
max-height: 100%; /* Allow full height, let Virtuoso handle virtualization */
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-list-item {
|
.ant-list-item {
|
||||||
padding: 12px 16px;
|
padding: 2px 16px;
|
||||||
border-bottom: 1px solid #434343;
|
border-bottom: 1px solid #f0f0f0; /* Light gray border */
|
||||||
display: block; // Ensure visibility
|
display: block; /* Ensure visibility */
|
||||||
overflow: visible; // Prevent clipping within items
|
overflow: visible; /* Prevent clipping within items */
|
||||||
min-height: 80px; // Minimum height to ensure visibility of multi-line content
|
min-height: 80px; /* Minimum height for multi-line content */
|
||||||
|
|
||||||
.ant-typography { color: inherit; }
|
.ant-typography {
|
||||||
.ant-typography-secondary { font-size: 12px; color: #b0b0b0; }
|
color: inherit; /* Inherit from parent (read/unread) */
|
||||||
.ant-badge-dot { background: #ff4d4f; }
|
}
|
||||||
|
|
||||||
|
.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 {
|
ul {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 20px; // Standard list padding
|
padding-left: 20px; /* Standard list padding */
|
||||||
list-style-type: disc; // Ensure bullet points
|
list-style-type: disc; /* Ensure bullet points */
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
li {
|
||||||
margin-bottom: 4px; // Space between list items
|
margin-bottom: 4px; /* Space between list items */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-alert {
|
.ant-alert {
|
||||||
margin: 8px;
|
margin: 8px;
|
||||||
background: #ff4d4f;
|
background: #fff1f0; /* Light red background for error per Ant 5 */
|
||||||
color: #fff;
|
color: rgba(0, 0, 0, 0.85);
|
||||||
border: none;
|
border: 1px solid #ffa39e; /* Light red border */
|
||||||
|
|
||||||
.ant-alert-message { color: #fff; }
|
.ant-alert-message {
|
||||||
.ant-alert-description { color: #ffebee; }
|
color: #ff4d4f; /* Red text for message */
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-alert-description {
|
||||||
|
color: rgba(0, 0, 0, 0.65); /* Slightly muted description */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,261 @@
|
|||||||
import React, { createContext } from "react";
|
// contexts/SocketIO/socketContext.jsx
|
||||||
import useSocket from "./useSocket";
|
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);
|
const SocketContext = createContext(null);
|
||||||
|
|
||||||
export const SocketProvider = ({ children, bodyshop }) => {
|
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 <SocketContext.Provider value={{ socket, clientId }}> {children}</SocketContext.Provider>;
|
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: (
|
||||||
|
<ul>
|
||||||
|
{notifications.map((notif, index) => (
|
||||||
|
<li key={index}>{notif.body}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
),
|
||||||
|
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 (
|
||||||
|
<SocketContext.Provider value={{ socket: socketRef.current, clientId, isConnected }}>
|
||||||
|
{children}
|
||||||
|
</SocketContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSocket = () => {
|
||||||
|
const context = useContext(SocketContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSocket must be used within a SocketProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SocketContext;
|
export default SocketContext;
|
||||||
|
|||||||
@@ -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: (
|
|
||||||
<ul>
|
|
||||||
{notifications.map((notif, index) => (
|
|
||||||
<li key={index}>{notif.body}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
),
|
|
||||||
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;
|
|
||||||
@@ -11,6 +11,9 @@ export const GET_NOTIFICATIONS = gql`
|
|||||||
scenario_meta
|
scenario_meta
|
||||||
created_at
|
created_at
|
||||||
read
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|||||||
@@ -3769,6 +3769,7 @@
|
|||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"labels": {
|
"labels": {
|
||||||
|
"notification-center": "Notification Center",
|
||||||
"scenario": "Scenario",
|
"scenario": "Scenario",
|
||||||
"notificationscenarios": "Job Notification Scenarios",
|
"notificationscenarios": "Job Notification Scenarios",
|
||||||
"save": "Save Scenarios",
|
"save": "Save Scenarios",
|
||||||
|
|||||||
Reference in New Issue
Block a user