feature/IO-3096-GlobalNotifications - Checkpoint - Notification Center
This commit is contained in:
@@ -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 { Layout, Menu, Space } from "antd";
|
import { Badge, Layout, Menu, Space } 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";
|
||||||
@@ -47,6 +47,9 @@ import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
|||||||
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
|
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { debounce } from "lodash";
|
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
|
// 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;
|
||||||
@@ -132,6 +135,19 @@ function Header({
|
|||||||
setIsMobile(effectiveWidth <= HEADER_MOBILE_BREAKPOINT);
|
setIsMobile(effectiveWidth <= HEADER_MOBILE_BREAKPOINT);
|
||||||
}, 200);
|
}, 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(() => {
|
useEffect(() => {
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
window.addEventListener("orientationchange", handleResize);
|
window.addEventListener("orientationchange", handleResize);
|
||||||
@@ -652,8 +668,13 @@ function Header({
|
|||||||
// Right-aligned items on desktop, merged on mobile
|
// Right-aligned items on desktop, merged on mobile
|
||||||
{
|
{
|
||||||
key: "notifications",
|
key: "notifications",
|
||||||
icon: <BellFilled />,
|
icon: (
|
||||||
id: "header-notifications"
|
<Badge count={unreadCount} offset={[10, 0]}>
|
||||||
|
<BellFilled />
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
id: "header-notifications",
|
||||||
|
onClick: handleNotificationClick
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "recent",
|
key: "recent",
|
||||||
@@ -774,6 +795,7 @@ function Header({
|
|||||||
overflow: "visible"
|
overflow: "visible"
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<NotificationCenterContainer visible={notificationVisible} onClose={() => setNotificationVisible(false)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Layout.Header>
|
</Layout.Header>
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ const colSpan = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function JobsDetailHeader({ job, bodyshop, disabled }) {
|
export function JobsDetailHeader({ job, bodyshop, disabled }) {
|
||||||
console.dir({ job });
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [notesClamped, setNotesClamped] = useState(true);
|
const [notesClamped, setNotesClamped] = useState(true);
|
||||||
const vehicleTitle = `${job.v_model_yr || ""} ${job.v_color || ""}
|
const vehicleTitle = `${job.v_model_yr || ""} ${job.v_color || ""}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<List.Item
|
||||||
|
key={`${notification.id}-${index}`} // Ensure unique key per render
|
||||||
|
className={notification.read ? "notification-read" : "notification-unread"}
|
||||||
|
>
|
||||||
|
<Badge dot={!notification.read}>
|
||||||
|
<div>
|
||||||
|
<Text strong={!notification.read}>
|
||||||
|
<ul>
|
||||||
|
{notification.scenarioText.map((text, idx) => (
|
||||||
|
<li key={`${notification.id}-${idx}`}>{text}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary">{new Date(notification.created_at).toLocaleString()}</Text>
|
||||||
|
</div>
|
||||||
|
</Badge>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Rendering NotificationCenter with notifications:", {
|
||||||
|
count: notifications.length,
|
||||||
|
ids: notifications.map((n) => n.id),
|
||||||
|
totalCount: notifications.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`notification-center ${visible ? "visible" : ""}`}>
|
||||||
|
<div className="notification-header">
|
||||||
|
<h3>{t("notifications.title", "Notifications")}</h3>
|
||||||
|
<div className="notification-controls">
|
||||||
|
<Checkbox checked={showUnreadOnly} onChange={(e) => toggleUnreadOnly(e.target.checked)}>
|
||||||
|
{t("notifications.showUnreadOnly", "Show Unread Only")}
|
||||||
|
</Checkbox>
|
||||||
|
<Button type="link" onClick={markAllRead} disabled={!notifications.some((n) => !n.read)}>
|
||||||
|
{t("notifications.markAllRead", "Mark All Read")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <Alert message="Error" description={error} type="error" closable onClose={() => onClose()} />}
|
||||||
|
<Virtuoso
|
||||||
|
style={{ height: "400px", width: "100%", overflow: "auto" }} // Default Virtuoso styling
|
||||||
|
data={notifications}
|
||||||
|
totalCount={notifications.length}
|
||||||
|
overscan={20}
|
||||||
|
increaseViewportBy={200}
|
||||||
|
itemContent={renderNotification}
|
||||||
|
/>
|
||||||
|
{loading && !error && <div>{t("notifications.loading", "Loading...")}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationCenterComponent;
|
||||||
@@ -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 (
|
||||||
|
<NotificationCenterComponent
|
||||||
|
visible={visible}
|
||||||
|
onClose={onClose}
|
||||||
|
notifications={notifications}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
showUnreadOnly={showUnreadOnly}
|
||||||
|
toggleUnreadOnly={handleToggleUnreadOnly}
|
||||||
|
markAllRead={handleMarkAllRead}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(null, null)(NotificationCenterContainer);
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { useApolloClient } from "@apollo/client";
|
|||||||
import { Button, Skeleton, Space } from "antd";
|
import { Button, Skeleton, Space } from "antd";
|
||||||
import cloneDeep from "lodash/cloneDeep";
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
import isEqual from "lodash/isEqual";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import SocketIO from "socket.io-client";
|
|||||||
import { auth } from "../../firebase/firebase.utils";
|
import { auth } from "../../firebase/firebase.utils";
|
||||||
import { store } from "../../redux/store";
|
import { store } from "../../redux/store";
|
||||||
import { addAlerts, setWssStatus } from "../../redux/application/application.actions";
|
import { addAlerts, setWssStatus } from "../../redux/application/application.actions";
|
||||||
|
import client from "../../utils/GraphQLClient";
|
||||||
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
|
||||||
const useSocket = (bodyshop) => {
|
const useSocket = (bodyshop) => {
|
||||||
const socketRef = useRef(null);
|
const socketRef = useRef(null);
|
||||||
const [clientId, setClientId] = useState(null);
|
const [clientId, setClientId] = useState(null);
|
||||||
|
const notification = useNotification();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initializeSocket = async (token) => {
|
const initializeSocket = async (token) => {
|
||||||
@@ -25,10 +28,8 @@ const useSocket = (bodyshop) => {
|
|||||||
|
|
||||||
socketRef.current = socketInstance;
|
socketRef.current = socketInstance;
|
||||||
|
|
||||||
// Handle socket events
|
|
||||||
const handleBodyshopMessage = (message) => {
|
const handleBodyshopMessage = (message) => {
|
||||||
if (!message || !message.type) return;
|
if (!message || !message.type) return;
|
||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case "alert-update":
|
case "alert-update":
|
||||||
store.dispatch(addAlerts(message.payload));
|
store.dispatch(addAlerts(message.payload));
|
||||||
@@ -36,7 +37,6 @@ const useSocket = (bodyshop) => {
|
|||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!import.meta.env.DEV) return;
|
if (!import.meta.env.DEV) return;
|
||||||
console.log(`Received message for bodyshop ${bodyshop.id}:`, message);
|
console.log(`Received message for bodyshop ${bodyshop.id}:`, message);
|
||||||
};
|
};
|
||||||
@@ -45,6 +45,7 @@ const useSocket = (bodyshop) => {
|
|||||||
socketInstance.emit("join-bodyshop-room", bodyshop.id);
|
socketInstance.emit("join-bodyshop-room", bodyshop.id);
|
||||||
setClientId(socketInstance.id);
|
setClientId(socketInstance.id);
|
||||||
store.dispatch(setWssStatus("connected"));
|
store.dispatch(setWssStatus("connected"));
|
||||||
|
console.log("Socket connected, ID:", socketInstance.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReconnect = () => {
|
const handleReconnect = () => {
|
||||||
@@ -53,13 +54,11 @@ const useSocket = (bodyshop) => {
|
|||||||
|
|
||||||
const handleConnectionError = (err) => {
|
const handleConnectionError = (err) => {
|
||||||
console.error("Socket connection error:", err);
|
console.error("Socket connection error:", err);
|
||||||
|
|
||||||
// Handle token expiration
|
|
||||||
if (err.message.includes("auth/id-token-expired")) {
|
if (err.message.includes("auth/id-token-expired")) {
|
||||||
console.warn("Token expired, refreshing...");
|
console.warn("Token expired, refreshing...");
|
||||||
auth.currentUser?.getIdToken(true).then((newToken) => {
|
auth.currentUser?.getIdToken(true).then((newToken) => {
|
||||||
socketInstance.auth = { token: newToken }; // Update socket auth
|
socketInstance.auth = { token: newToken };
|
||||||
socketInstance.connect(); // Retry connection
|
socketInstance.connect();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
store.dispatch(setWssStatus("error"));
|
store.dispatch(setWssStatus("error"));
|
||||||
@@ -69,39 +68,107 @@ const useSocket = (bodyshop) => {
|
|||||||
const handleDisconnect = (reason) => {
|
const handleDisconnect = (reason) => {
|
||||||
console.warn("Socket disconnected:", reason);
|
console.warn("Socket disconnected:", reason);
|
||||||
store.dispatch(setWssStatus("disconnected"));
|
store.dispatch(setWssStatus("disconnected"));
|
||||||
|
|
||||||
// Manually trigger reconnection if necessary
|
|
||||||
if (!socketInstance.connected && reason !== "io server disconnect") {
|
if (!socketInstance.connected && reason !== "io server disconnect") {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (socketInstance.disconnected) {
|
if (socketInstance.disconnected) {
|
||||||
console.log("Manually triggering reconnection...");
|
console.log("Manually triggering reconnection...");
|
||||||
socketInstance.connect();
|
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: (
|
||||||
|
<ul>
|
||||||
|
{notifications.map((notif, index) => (
|
||||||
|
<li key={index}>{notif.body}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
),
|
||||||
|
placement: "topRight",
|
||||||
|
duration: 5
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in handleNotification:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register event handlers
|
|
||||||
socketInstance.on("connect", handleConnect);
|
socketInstance.on("connect", handleConnect);
|
||||||
socketInstance.on("reconnect", handleReconnect);
|
socketInstance.on("reconnect", handleReconnect);
|
||||||
socketInstance.on("connect_error", handleConnectionError);
|
socketInstance.on("connect_error", handleConnectionError);
|
||||||
socketInstance.on("disconnect", handleDisconnect);
|
socketInstance.on("disconnect", handleDisconnect);
|
||||||
socketInstance.on("bodyshop-message", handleBodyshopMessage);
|
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) => {
|
const unsubscribe = auth.onIdTokenChanged(async (user) => {
|
||||||
if (user) {
|
if (user) {
|
||||||
const token = await user.getIdToken();
|
const token = await user.getIdToken();
|
||||||
|
|
||||||
if (socketRef.current) {
|
if (socketRef.current) {
|
||||||
// Update token if socket exists
|
|
||||||
socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id });
|
socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id });
|
||||||
} else {
|
} else {
|
||||||
// Initialize socket if not already connected
|
|
||||||
initializeSocket(token);
|
initializeSocket(token);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// User is not authenticated
|
|
||||||
if (socketRef.current) {
|
if (socketRef.current) {
|
||||||
socketRef.current.disconnect();
|
socketRef.current.disconnect();
|
||||||
socketRef.current = null;
|
socketRef.current = null;
|
||||||
@@ -109,7 +176,6 @@ const useSocket = (bodyshop) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up on unmount
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
if (socketRef.current) {
|
if (socketRef.current) {
|
||||||
@@ -117,7 +183,7 @@ const useSocket = (bodyshop) => {
|
|||||||
socketRef.current = null;
|
socketRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [bodyshop]);
|
}, [bodyshop, client.cache]);
|
||||||
|
|
||||||
return { socket: socketRef.current, clientId };
|
return { socket: socketRef.current, clientId };
|
||||||
};
|
};
|
||||||
49
client/src/graphql/notifications.queries.js
Normal file
49
client/src/graphql/notifications.queries.js
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -267,8 +267,6 @@ export function JobsDetailPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWatchClick = () => {};
|
|
||||||
|
|
||||||
const menuExtra = (
|
const menuExtra = (
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
Reference in New Issue
Block a user