@@ -656,7 +656,7 @@ function Header({
|
||||
icon: unreadLoading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<Badge count={unreadCount}>
|
||||
<Badge size="small" count={unreadCount}>
|
||||
<BellFilled />
|
||||
</Badge>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import React from "react";
|
||||
import { EyeFilled, EyeOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { Avatar, Button, Divider, List, Popover, Select, Tooltip, Typography } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EmployeeSearchSelectComponent from "../../components/employee-search-select/employee-search-select.component.jsx";
|
||||
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component.jsx";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function JobWatcherToggleComponent({
|
||||
jobWatchers,
|
||||
isWatching,
|
||||
watcherLoading,
|
||||
adding,
|
||||
removing,
|
||||
open,
|
||||
setOpen,
|
||||
selectedWatcher,
|
||||
setSelectedWatcher,
|
||||
selectedTeam,
|
||||
bodyshop,
|
||||
Enhanced_Payroll,
|
||||
handleToggleSelf,
|
||||
handleRemoveWatcher,
|
||||
handleWatcherSelect,
|
||||
handleTeamSelect
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleRenderItem = (watcher) => {
|
||||
// Check if watcher is defined and has user_email
|
||||
if (!watcher || !watcher.user_email) {
|
||||
return null; // Skip rendering invalid watchers
|
||||
}
|
||||
|
||||
const employee = bodyshop?.employees?.find((e) => e.user_email === watcher.user_email);
|
||||
const displayName = employee ? `${employee.first_name} ${employee.last_name}` : watcher.user_email;
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button
|
||||
type="default"
|
||||
danger
|
||||
size="small"
|
||||
onClick={() => handleRemoveWatcher(watcher.user_email)}
|
||||
disabled={adding || removing} // Optional: Disable button during mutations
|
||||
>
|
||||
{t("notifications.actions.remove")}
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar icon={<UserOutlined />} />}
|
||||
title={<Text>{displayName}</Text>}
|
||||
description={watcher.user_email}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const popoverContent = (
|
||||
<div style={{ width: 600 }}>
|
||||
<Button
|
||||
block
|
||||
type={isWatching ? "primary" : "default"}
|
||||
icon={isWatching ? <EyeOutlined /> : <EyeFilled />}
|
||||
onClick={handleToggleSelf}
|
||||
loading={adding || removing}
|
||||
>
|
||||
{isWatching ? t("notifications.tooltips.unwatch") : t("notifications.tooltips.watch")}
|
||||
</Button>
|
||||
<Divider />
|
||||
|
||||
<Text type="secondary" style={{ marginBottom: 8, display: "block" }}>
|
||||
{t("notifications.labels.watching-issue")}
|
||||
</Text>
|
||||
{watcherLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : jobWatchers && jobWatchers.length > 0 ? (
|
||||
<List dataSource={jobWatchers} renderItem={handleRenderItem} />
|
||||
) : (
|
||||
<Text type="secondary">{t("notifications.labels.no-watchers")}</Text>
|
||||
)}
|
||||
<Divider />
|
||||
|
||||
<Text type="secondary">{t("notifications.labels.add-watchers")}</Text>
|
||||
<EmployeeSearchSelectComponent
|
||||
style={{ minWidth: "100%" }}
|
||||
options={bodyshop?.employees?.filter((e) => jobWatchers.every((w) => w.user_email !== e.user_email)) || []}
|
||||
placeholder={t("notifications.labels.employee-search")}
|
||||
value={selectedWatcher}
|
||||
onChange={(value) => {
|
||||
setSelectedWatcher(value);
|
||||
handleWatcherSelect(value);
|
||||
}}
|
||||
/>
|
||||
{Enhanced_Payroll && bodyshop?.employee_teams?.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Text type="secondary">{t("notifications.labels.add-watchers-team")}</Text>
|
||||
<Select
|
||||
showSearch
|
||||
style={{ minWidth: "100%" }}
|
||||
placeholder={t("notifications.labels.teams-search")}
|
||||
value={selectedTeam}
|
||||
onChange={handleTeamSelect}
|
||||
options={
|
||||
bodyshop?.employee_teams?.map((team) => {
|
||||
const teamMembers = team.employee_team_members
|
||||
.map((member) => {
|
||||
const employee = bodyshop?.employees?.find((e) => e.id === member.employeeid);
|
||||
return employee ? employee.user_email : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
value: JSON.stringify(teamMembers),
|
||||
label: team.name
|
||||
};
|
||||
}) || []
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={popoverContent} trigger="click" open={open} onOpenChange={setOpen}>
|
||||
<Tooltip title={isWatching ? t("notifications.tooltips.unwatch") : t("notifications.tooltips.watch")}>
|
||||
<Button
|
||||
shape="circle"
|
||||
type={isWatching ? "primary" : "default"}
|
||||
icon={isWatching ? <EyeFilled /> : <EyeOutlined />}
|
||||
loading={watcherLoading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { ADD_JOB_WATCHER, GET_JOB_WATCHERS, REMOVE_JOB_WATCHER } from "../../graphql/jobs.queries.js";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
currentUser: selectCurrentUser
|
||||
});
|
||||
|
||||
function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
|
||||
const {
|
||||
treatments: { Enhanced_Payroll }
|
||||
} = useSplitTreatments({
|
||||
attributes: {},
|
||||
names: ["Enhanced_Payroll"],
|
||||
splitKey: bodyshop && bodyshop.imexshopid
|
||||
});
|
||||
|
||||
const userEmail = currentUser.email;
|
||||
const jobid = job.id;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedWatcher, setSelectedWatcher] = useState(null);
|
||||
const [selectedTeam, setSelectedTeam] = useState(null);
|
||||
|
||||
// Fetch current watchers with refetch capability
|
||||
const {
|
||||
data: watcherData,
|
||||
loading: watcherLoading,
|
||||
refetch
|
||||
} = useQuery(GET_JOB_WATCHERS, {
|
||||
variables: { jobid },
|
||||
fetchPolicy: "cache-and-network" // Ensure fresh data from server
|
||||
});
|
||||
|
||||
// Refetch jobWatchers when the popover opens (open changes to true)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
refetch().catch((err) =>
|
||||
console.error(`Something went wrong fetching Notification Watchers on popover open: ${err?.message}`, {
|
||||
stack: err?.stack
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [open, refetch]);
|
||||
|
||||
const jobWatchers = useMemo(() => (watcherData?.job_watchers ? [...watcherData.job_watchers] : []), [watcherData]);
|
||||
const isWatching = jobWatchers.some((w) => w.user_email === userEmail);
|
||||
|
||||
const [addWatcher, { loading: adding }] = useMutation(ADD_JOB_WATCHER, {
|
||||
onCompleted: () =>
|
||||
refetch().catch((err) =>
|
||||
console.error(`Something went wrong fetching Notification Watchers after add: ${err?.message}`, {
|
||||
stack: err?.stack
|
||||
})
|
||||
),
|
||||
onError: (err) => {
|
||||
if (err.graphQLErrors && err.graphQLErrors.length > 0) {
|
||||
const errorMessage = err.graphQLErrors[0].message;
|
||||
if (
|
||||
errorMessage.includes("Uniqueness violation") ||
|
||||
errorMessage.includes("idx_job_watchers_jobid_user_email_unique")
|
||||
) {
|
||||
console.warn("Watcher already exists for this job and user.");
|
||||
refetch().catch((err) =>
|
||||
console.error(
|
||||
`Something went wrong fetching Notification Watchers after uniqueness violation: ${err?.message}`,
|
||||
{ stack: err?.stack }
|
||||
)
|
||||
); // Sync with server to ensure UI reflects actual state
|
||||
} else {
|
||||
console.error(`Error adding job watcher: ${errorMessage}`);
|
||||
}
|
||||
} else {
|
||||
console.error(`Unexpected error adding job watcher: ${err.message || JSON.stringify(err)}`);
|
||||
}
|
||||
},
|
||||
update(cache, { data }) {
|
||||
if (!data || !data.insert_job_watchers_one) {
|
||||
console.warn("No data or insert_job_watchers_one returned from mutation, skipping cache update.");
|
||||
refetch().catch((err) =>
|
||||
console.error(`Something went wrong updating Notification Watchers after add: ${err?.message}`, {
|
||||
stack: err?.stack
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const insert_job_watchers_one = data.insert_job_watchers_one;
|
||||
const existingData = cache.readQuery({
|
||||
query: GET_JOB_WATCHERS,
|
||||
variables: { jobid }
|
||||
});
|
||||
|
||||
cache.writeQuery({
|
||||
query: GET_JOB_WATCHERS,
|
||||
variables: { jobid },
|
||||
data: {
|
||||
...existingData,
|
||||
job_watchers: [...(existingData?.job_watchers || []), insert_job_watchers_one]
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const [removeWatcher, { loading: removing }] = useMutation(REMOVE_JOB_WATCHER, {
|
||||
onCompleted: () =>
|
||||
refetch().catch((err) =>
|
||||
console.error(`Something went wrong fetching Notification Watchers after remove: ${err?.message}`, {
|
||||
stack: err?.stack
|
||||
})
|
||||
), // Refetch to sync with server after success
|
||||
onError: (err) => console.error(`Error removing job watcher: ${err.message}`),
|
||||
update(cache, { data: { delete_job_watchers } }) {
|
||||
const existingData = cache.readQuery({
|
||||
query: GET_JOB_WATCHERS,
|
||||
variables: { jobid }
|
||||
});
|
||||
|
||||
const deletedWatcher = delete_job_watchers.returning[0];
|
||||
const updatedWatchers = deletedWatcher
|
||||
? (existingData?.job_watchers || []).filter((watcher) => watcher.user_email !== deletedWatcher.user_email)
|
||||
: existingData?.job_watchers || [];
|
||||
|
||||
cache.writeQuery({
|
||||
query: GET_JOB_WATCHERS,
|
||||
variables: { jobid },
|
||||
data: {
|
||||
...existingData,
|
||||
job_watchers: updatedWatchers
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleToggleSelf = useCallback(async () => {
|
||||
if (adding || removing) return;
|
||||
if (isWatching) {
|
||||
await removeWatcher({ variables: { jobid, userEmail } });
|
||||
} else {
|
||||
await addWatcher({ variables: { jobid, userEmail } });
|
||||
}
|
||||
}, [isWatching, addWatcher, removeWatcher, jobid, userEmail, adding, removing]);
|
||||
|
||||
const handleRemoveWatcher = useCallback(
|
||||
async (email) => {
|
||||
if (removing) return;
|
||||
await removeWatcher({ variables: { jobid, userEmail: email } });
|
||||
},
|
||||
[removeWatcher, jobid, removing]
|
||||
);
|
||||
|
||||
const handleWatcherSelect = useCallback(
|
||||
async (selectedUser) => {
|
||||
if (adding || removing) return;
|
||||
const employee = bodyshop.employees.find((e) => e.id === selectedUser);
|
||||
if (!employee) return;
|
||||
|
||||
const email = employee.user_email;
|
||||
const isAlreadyWatching = jobWatchers.some((w) => w.user_email === email);
|
||||
|
||||
if (isAlreadyWatching) {
|
||||
await handleRemoveWatcher(email);
|
||||
} else {
|
||||
await addWatcher({ variables: { jobid, userEmail: email } });
|
||||
}
|
||||
setSelectedWatcher(null);
|
||||
},
|
||||
[jobWatchers, addWatcher, handleRemoveWatcher, jobid, bodyshop, adding, removing]
|
||||
);
|
||||
|
||||
const handleTeamSelect = useCallback(
|
||||
async (team) => {
|
||||
if (adding) return;
|
||||
const selectedTeamMembers = JSON.parse(team);
|
||||
const newWatchers = selectedTeamMembers.filter(
|
||||
(email) => !jobWatchers.some((watcher) => watcher.user_email === email)
|
||||
);
|
||||
|
||||
if (newWatchers.length === 0) {
|
||||
console.warn("All selected team members are already watchers.");
|
||||
setSelectedTeam(null);
|
||||
return;
|
||||
}
|
||||
await Promise.all(newWatchers.map((email) => addWatcher({ variables: { jobid, userEmail: email } })));
|
||||
},
|
||||
[jobWatchers, addWatcher, jobid, adding]
|
||||
);
|
||||
|
||||
return (
|
||||
<JobWatcherToggleComponent
|
||||
jobWatchers={jobWatchers}
|
||||
isWatching={isWatching}
|
||||
watcherLoading={watcherLoading}
|
||||
adding={adding}
|
||||
removing={removing}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
selectedWatcher={selectedWatcher}
|
||||
setSelectedWatcher={setSelectedWatcher}
|
||||
selectedTeam={selectedTeam}
|
||||
setSelectedTeam={setSelectedTeam}
|
||||
bodyshop={bodyshop}
|
||||
Enhanced_Payroll={Enhanced_Payroll}
|
||||
handleToggleSelf={handleToggleSelf}
|
||||
handleRemoveWatcher={handleRemoveWatcher}
|
||||
handleWatcherSelect={handleWatcherSelect}
|
||||
handleTeamSelect={handleTeamSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(JobWatcherToggleContainer);
|
||||
@@ -2,102 +2,115 @@ import { Virtuoso } from "react-virtuoso";
|
||||
import { Alert, Badge, Button, Space, Spin, Tooltip, Typography } from "antd";
|
||||
import { CheckCircleFilled, CheckCircleOutlined, EyeFilled, EyeOutlined } from "@ant-design/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./notification-center.styles.scss";
|
||||
import day from "../../utils/day.js";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const NotificationCenterComponent = ({
|
||||
visible,
|
||||
onClose,
|
||||
notifications,
|
||||
loading,
|
||||
error,
|
||||
showUnreadOnly,
|
||||
toggleUnreadOnly,
|
||||
markAllRead,
|
||||
loadMore,
|
||||
onNotificationClick,
|
||||
unreadCount
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
/**
|
||||
* Notification Center Component
|
||||
* @type {React.ForwardRefExoticComponent<React.PropsWithoutRef<{readonly visible?: *, readonly onClose?: *, readonly notifications?: *, readonly loading?: *, readonly error?: *, readonly showUnreadOnly?: *, readonly toggleUnreadOnly?: *, readonly markAllRead?: *, readonly loadMore?: *, readonly onNotificationClick?: *, readonly unreadCount?: *}> & React.RefAttributes<unknown>>}
|
||||
*/
|
||||
const NotificationCenterComponent = forwardRef(
|
||||
(
|
||||
{
|
||||
visible,
|
||||
onClose,
|
||||
notifications,
|
||||
loading,
|
||||
error,
|
||||
showUnreadOnly,
|
||||
toggleUnreadOnly,
|
||||
markAllRead,
|
||||
loadMore,
|
||||
onNotificationClick,
|
||||
unreadCount
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const renderNotification = (index, notification) => {
|
||||
return (
|
||||
<div
|
||||
key={`${notification.id}-${index}`}
|
||||
className={`notification-item ${notification.read ? "notification-read" : "notification-unread"}`}
|
||||
onClick={() => !notification.read && onNotificationClick(notification.id)}
|
||||
>
|
||||
<Badge dot={!notification.read}>
|
||||
<div className="notification-content">
|
||||
<Title level={5} className="notification-title">
|
||||
<Link
|
||||
to={`/manage/jobs/${notification.jobid}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!notification.read) {
|
||||
onNotificationClick(notification.id);
|
||||
}
|
||||
}}
|
||||
className="ro-number"
|
||||
>
|
||||
{t("notifications.labels.ro-number", { ro_number: notification.roNumber })}
|
||||
</Link>
|
||||
<Text type="secondary" className="relative-time">
|
||||
{day(notification.created_at).fromNow()}
|
||||
const renderNotification = (index, notification) => {
|
||||
const handleClick = () => {
|
||||
if (!notification.read) {
|
||||
onNotificationClick(notification.id);
|
||||
}
|
||||
navigate(`/manage/jobs/${notification.jobid}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${notification.id}-${index}`}
|
||||
className={`notification-item ${notification.read ? "notification-read" : "notification-unread"}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Badge dot={!notification.read}>
|
||||
<div className="notification-content">
|
||||
<Title level={5} className="notification-title">
|
||||
<span className="ro-number">
|
||||
{t("notifications.labels.ro-number", { ro_number: notification.roNumber })}
|
||||
</span>
|
||||
<Text
|
||||
type="secondary"
|
||||
className="relative-time"
|
||||
title={day(notification.created_at).format("YYYY-MM-DD hh:mm A")}
|
||||
>
|
||||
{day(notification.created_at).fromNow()}
|
||||
</Text>
|
||||
</Title>
|
||||
<Text strong={!notification.read} className="notification-body">
|
||||
<ul>
|
||||
{notification.scenarioText.map((text, idx) => (
|
||||
<li key={`${notification.id}-${idx}`}>{text}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Text>
|
||||
</Title>
|
||||
<Text strong={!notification.read} className="notification-body">
|
||||
<ul>
|
||||
{notification.scenarioText.map((text, idx) => (
|
||||
<li key={`${notification.id}-${idx}`}>{text}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Text>
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`notification-center ${visible ? "visible" : ""}`} ref={ref}>
|
||||
<div className="notification-header">
|
||||
<Space direction="horizontal">
|
||||
<h3>{t("notifications.labels.notification-center")}</h3>
|
||||
{loading && !error && <Spin spinning={loading} size="small"></Spin>}
|
||||
</Space>
|
||||
<div className="notification-controls">
|
||||
<Tooltip title={t("notifications.labels.show-unread-only")}>
|
||||
<Button
|
||||
type="link"
|
||||
icon={showUnreadOnly ? <EyeFilled /> : <EyeOutlined />}
|
||||
onClick={() => toggleUnreadOnly(!showUnreadOnly)}
|
||||
className={showUnreadOnly ? "active" : ""}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("notifications.labels.mark-all-read")}>
|
||||
<Button
|
||||
type="link"
|
||||
icon={!unreadCount ? <CheckCircleFilled /> : <CheckCircleOutlined />}
|
||||
onClick={markAllRead}
|
||||
disabled={!unreadCount}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
{error && <Alert message={error} type="error" closable onClose={() => onClose()} />}
|
||||
<Virtuoso
|
||||
style={{ height: "400px", width: "100%" }}
|
||||
data={notifications}
|
||||
totalCount={notifications.length}
|
||||
endReached={loadMore}
|
||||
itemContent={renderNotification}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`notification-center ${visible ? "visible" : ""}`}>
|
||||
<div className="notification-header">
|
||||
<Space direction="horizontal">
|
||||
<h3>{t("notifications.labels.notification-center")}</h3>
|
||||
{loading && !error && <Spin spinning={loading} size="small"></Spin>}
|
||||
</Space>
|
||||
<div className="notification-controls">
|
||||
<Tooltip title={t("notifications.labels.show-unread-only")}>
|
||||
<Button
|
||||
type="link"
|
||||
icon={showUnreadOnly ? <EyeFilled /> : <EyeOutlined />}
|
||||
onClick={() => toggleUnreadOnly(!showUnreadOnly)}
|
||||
className={showUnreadOnly ? "active" : ""}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("notifications.labels.mark-all-read")}>
|
||||
<Button
|
||||
type="link"
|
||||
icon={!unreadCount ? <CheckCircleFilled /> : <CheckCircleOutlined />}
|
||||
onClick={markAllRead}
|
||||
disabled={!unreadCount}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{error && <Alert message={error} type="error" closable onClose={() => onClose()} />}
|
||||
<Virtuoso
|
||||
style={{ height: "400px", width: "100%" }}
|
||||
data={notifications}
|
||||
totalCount={notifications.length}
|
||||
endReached={loadMore}
|
||||
itemContent={renderNotification}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export default NotificationCenterComponent;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; // Add useRef
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { connect } from "react-redux";
|
||||
import NotificationCenterComponent from "./notification-center.component";
|
||||
@@ -11,11 +11,21 @@ import day from "../../utils/day.js";
|
||||
// This will be used to poll for notifications when the socket is disconnected
|
||||
const NOTIFICATION_POLL_INTERVAL_SECONDS = 60;
|
||||
|
||||
export function NotificationCenterContainer({ visible, onClose, bodyshop, unreadCount }) {
|
||||
/**
|
||||
* Notification Center Container
|
||||
* @param visible
|
||||
* @param onClose
|
||||
* @param bodyshop
|
||||
* @param unreadCount
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }) => {
|
||||
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const { isConnected, markNotificationRead, markAllNotificationsRead } = useSocket();
|
||||
const notificationRef = useRef(null); // Add ref for the notification center
|
||||
|
||||
const userAssociationId = bodyshop?.associations?.[0]?.id;
|
||||
|
||||
@@ -50,6 +60,19 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop, unread
|
||||
}
|
||||
});
|
||||
|
||||
// Handle click outside to close
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (visible && notificationRef.current && !notificationRef.current.contains(event.target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [visible, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.notifications) {
|
||||
const processedNotifications = data.notifications
|
||||
@@ -159,6 +182,7 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop, unread
|
||||
|
||||
return (
|
||||
<NotificationCenterComponent
|
||||
ref={notificationRef}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
notifications={notifications}
|
||||
@@ -172,7 +196,7 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop, unread
|
||||
unreadCount={unreadCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08), 0 3px 6px rgba(0, 0, 0, 0.06);
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
overflow-x: hidden; /* Prevent horizontal overflow */
|
||||
overflow-x: hidden;
|
||||
|
||||
&.visible {
|
||||
display: block;
|
||||
@@ -67,12 +67,17 @@
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
padding: 8px 16px;
|
||||
padding: 12px 16px; // Increased padding from 8px to 12px for more space
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer; // Add pointer cursor to indicate clickability
|
||||
|
||||
&:hover {
|
||||
background: #fafafa; // Optional: Add hover effect for better UX
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
width: 100%;
|
||||
@@ -122,7 +127,7 @@
|
||||
}
|
||||
|
||||
.ant-badge {
|
||||
width: 100%; /* Ensure Badge takes full width to allow .notification-title to stretch properly */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-alert {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
|
||||
import { Checkbox, Form } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
/**
|
||||
* ColumnHeaderCheckbox
|
||||
* @param channel
|
||||
* @param form
|
||||
* @param disabled
|
||||
* @param onHeaderChange
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Subscribe to all form values so that this component re-renders on changes.
|
||||
const formValues = Form.useWatch([], form) || {};
|
||||
|
||||
// Determine if all scenarios for this channel are checked.
|
||||
const allChecked =
|
||||
notificationScenarios.length > 0 && notificationScenarios.every((scenario) => formValues[scenario]?.[channel]);
|
||||
|
||||
const onChange = (e) => {
|
||||
const checked = e.target.checked;
|
||||
// Get current form values.
|
||||
const currentValues = form.getFieldsValue();
|
||||
// Update each scenario for this channel.
|
||||
const newValues = { ...currentValues };
|
||||
notificationScenarios.forEach((scenario) => {
|
||||
newValues[scenario] = { ...newValues[scenario], [channel]: checked };
|
||||
});
|
||||
// Update form values.
|
||||
form.setFieldsValue(newValues);
|
||||
// Manually mark the form as dirty.
|
||||
if (onHeaderChange) {
|
||||
onHeaderChange();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Checkbox onChange={onChange} checked={allChecked} disabled={disabled}>
|
||||
{t(`notifications.channels.${channel}`)}
|
||||
</Checkbox>
|
||||
);
|
||||
};
|
||||
|
||||
ColumnHeaderCheckbox.propTypes = {
|
||||
channel: PropTypes.oneOf(["app", "email", "fcm"]).isRequired,
|
||||
form: PropTypes.object.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
onHeaderChange: PropTypes.func
|
||||
};
|
||||
|
||||
export default ColumnHeaderCheckbox;
|
||||
@@ -2,7 +2,6 @@ import { useMutation, useQuery } from "@apollo/client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Card, Checkbox, Form, Space, Table } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
@@ -11,52 +10,21 @@ import { QUERY_NOTIFICATION_SETTINGS, UPDATE_NOTIFICATION_SETTINGS } from "../..
|
||||
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
|
||||
import PropTypes from "prop-types";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx";
|
||||
|
||||
const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange }) => {
|
||||
const { t } = useTranslation();
|
||||
// Subscribe to all form values so that this component re-renders on changes.
|
||||
const formValues = Form.useWatch([], form) || {};
|
||||
|
||||
// Determine if all scenarios for this channel are checked.
|
||||
const allChecked =
|
||||
notificationScenarios.length > 0 && notificationScenarios.every((scenario) => formValues[scenario]?.[channel]);
|
||||
|
||||
const onChange = (e) => {
|
||||
const checked = e.target.checked;
|
||||
// Get current form values.
|
||||
const currentValues = form.getFieldsValue();
|
||||
// Update each scenario for this channel.
|
||||
const newValues = { ...currentValues };
|
||||
notificationScenarios.forEach((scenario) => {
|
||||
newValues[scenario] = { ...newValues[scenario], [channel]: checked };
|
||||
});
|
||||
// Update form values.
|
||||
form.setFieldsValue(newValues);
|
||||
// Manually mark the form as dirty.
|
||||
if (onHeaderChange) {
|
||||
onHeaderChange();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Checkbox onChange={onChange} checked={allChecked} disabled={disabled}>
|
||||
{t(`notifications.channels.${channel}`)}
|
||||
</Checkbox>
|
||||
);
|
||||
};
|
||||
|
||||
ColumnHeaderCheckbox.propTypes = {
|
||||
channel: PropTypes.oneOf(["app", "email", "fcm"]).isRequired,
|
||||
form: PropTypes.object.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
onHeaderChange: PropTypes.func
|
||||
};
|
||||
|
||||
function NotificationSettingsForm({ currentUser }) {
|
||||
/**
|
||||
* Notifications Settings Form
|
||||
* @param currentUser
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const NotificationSettingsForm = ({ currentUser }) => {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
const [initialValues, setInitialValues] = useState({});
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const notification = useNotification();
|
||||
|
||||
// Fetch notification settings.
|
||||
const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, {
|
||||
@@ -88,9 +56,14 @@ function NotificationSettingsForm({ currentUser }) {
|
||||
if (data?.associations?.length > 0) {
|
||||
const userId = data.associations[0].id;
|
||||
// Save the updated notification settings.
|
||||
await updateNotificationSettings({ variables: { id: userId, ns: values } });
|
||||
setInitialValues(values);
|
||||
setIsDirty(false);
|
||||
const result = await updateNotificationSettings({ variables: { id: userId, ns: values } });
|
||||
if (!result?.errors) {
|
||||
notification.success({ message: t("notifications.labels.notification-settings-success") });
|
||||
setInitialValues(values);
|
||||
setIsDirty(false);
|
||||
} else {
|
||||
notification.error({ message: t("notifications.labels.notification-settings-failure") });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -180,7 +153,7 @@ function NotificationSettingsForm({ currentUser }) {
|
||||
</Card>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
NotificationSettingsForm.propTypes = {
|
||||
currentUser: PropTypes.shape({
|
||||
@@ -8,8 +8,8 @@ import { selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import { logImEXEvent, updateCurrentPassword } from "../../firebase/firebase.utils";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import NotificationSettingsForm from "./notification-settings.component.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import NotificationSettingsForm from "../notification-settings/notification-settings-form.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { EyeFilled, EyeOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { ADD_JOB_WATCHER, GET_JOB_WATCHERS, REMOVE_JOB_WATCHER } from "../../graphql/jobs.queries.js";
|
||||
import { Avatar, Button, Divider, List, Popover, Select, Tooltip, Typography } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
|
||||
import EmployeeSearchSelectComponent from "../../components/employee-search-select/employee-search-select.component.jsx";
|
||||
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component.jsx";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
currentUser: selectCurrentUser
|
||||
});
|
||||
|
||||
const JobWatcherToggle = ({ job, currentUser, bodyshop }) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
treatments: { Enhanced_Payroll }
|
||||
} = useSplitTreatments({
|
||||
attributes: {},
|
||||
names: ["Enhanced_Payroll"],
|
||||
splitKey: bodyshop && bodyshop.imexshopid
|
||||
});
|
||||
|
||||
const userEmail = currentUser.email;
|
||||
const jobid = job.id;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedWatcher, setSelectedWatcher] = useState(null);
|
||||
const [selectedTeam, setSelectedTeam] = useState(null);
|
||||
|
||||
// Fetch current watchers
|
||||
const { data: watcherData, loading: watcherLoading } = useQuery(GET_JOB_WATCHERS, { variables: { jobid } });
|
||||
|
||||
// Extract watchers list
|
||||
const jobWatchers = useMemo(() => watcherData?.job_watchers || [], [watcherData]);
|
||||
|
||||
const isWatching = jobWatchers.some((w) => w.user_email === userEmail);
|
||||
|
||||
// Add watcher mutation with cache update
|
||||
const [addWatcher, { loading: adding }] = useMutation(ADD_JOB_WATCHER, {
|
||||
update(cache, { data: { insert_job_watchers_one } }) {
|
||||
const existingData = cache.readQuery({
|
||||
query: GET_JOB_WATCHERS,
|
||||
variables: { jobid }
|
||||
});
|
||||
|
||||
const updatedWatchers = [...(existingData?.job_watchers || []), insert_job_watchers_one];
|
||||
|
||||
cache.writeQuery({
|
||||
query: GET_JOB_WATCHERS,
|
||||
variables: { jobid },
|
||||
data: {
|
||||
...existingData,
|
||||
job_watchers: updatedWatchers
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Remove watcher mutation with cache update
|
||||
const [removeWatcher, { loading: removing }] = useMutation(REMOVE_JOB_WATCHER, {
|
||||
update(cache, { data: { delete_job_watchers } }) {
|
||||
const existingData = cache.readQuery({
|
||||
query: GET_JOB_WATCHERS,
|
||||
variables: { jobid }
|
||||
});
|
||||
|
||||
const deletedWatcher = delete_job_watchers.returning[0]; // Safely assume one row deleted
|
||||
const updatedWatchers = deletedWatcher
|
||||
? (existingData?.job_watchers || []).filter((watcher) => watcher.user_email !== deletedWatcher.user_email)
|
||||
: existingData?.job_watchers || []; // No change if nothing deleted
|
||||
|
||||
cache.writeQuery({
|
||||
query: GET_JOB_WATCHERS,
|
||||
variables: { jobid },
|
||||
data: {
|
||||
...existingData,
|
||||
job_watchers: updatedWatchers
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle watcher for self
|
||||
const handleToggleSelf = useCallback(() => {
|
||||
(isWatching
|
||||
? removeWatcher({ variables: { jobid, userEmail } })
|
||||
: addWatcher({ variables: { jobid, userEmail } })
|
||||
).catch((err) => console.error(`Error updating job watcher: ${err.message}`));
|
||||
}, [isWatching, addWatcher, removeWatcher, jobid, userEmail]);
|
||||
|
||||
// Handle removing a watcher
|
||||
const handleRemoveWatcher = (userEmail) => {
|
||||
removeWatcher({ variables: { jobid, userEmail } }).catch((err) =>
|
||||
console.error(`Error removing job watcher: ${err.message}`)
|
||||
);
|
||||
};
|
||||
|
||||
const handleWatcherSelect = (selectedUser) => {
|
||||
const employee = bodyshop.employees.find((e) => e.id === selectedUser);
|
||||
if (!employee) return;
|
||||
|
||||
const isAlreadyWatching = jobWatchers.some((w) => w.user_email === employee.user_email);
|
||||
|
||||
if (isAlreadyWatching) {
|
||||
handleRemoveWatcher(employee.user_email);
|
||||
} else {
|
||||
addWatcher({ variables: { jobid, userEmail: employee.user_email } }).catch((err) =>
|
||||
console.error(`Error adding job watcher: ${err.message}`)
|
||||
);
|
||||
}
|
||||
|
||||
setSelectedWatcher(null);
|
||||
};
|
||||
|
||||
const handleTeamSelect = (team) => {
|
||||
const selectedTeamMembers = JSON.parse(team);
|
||||
|
||||
const newWatchers = selectedTeamMembers.filter(
|
||||
(email) => !jobWatchers.some((watcher) => watcher.user_email === email)
|
||||
);
|
||||
|
||||
newWatchers.forEach((email) => {
|
||||
addWatcher({ variables: { jobid, userEmail: email } }).catch((err) =>
|
||||
console.error(`Error adding job watcher: ${err.message}`)
|
||||
);
|
||||
});
|
||||
|
||||
setSelectedTeam(null);
|
||||
};
|
||||
|
||||
const handleRenderItem = (watcher) => {
|
||||
const employee = bodyshop.employees.find((e) => e.user_email === watcher.user_email);
|
||||
const displayName = employee ? `${employee.first_name} ${employee.last_name}` : watcher.user_email;
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button type="link" danger size="small" onClick={() => handleRemoveWatcher(watcher.user_email)}>
|
||||
{t("notifications.actions.remove")}
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar icon={<UserOutlined />} />}
|
||||
title={<Text>{displayName}</Text>}
|
||||
description={watcher.user_email}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const popoverContent = (
|
||||
<div style={{ width: 600 }}>
|
||||
<Button
|
||||
block
|
||||
type="text"
|
||||
icon={isWatching ? <EyeOutlined /> : <EyeFilled />}
|
||||
onClick={handleToggleSelf}
|
||||
loading={adding || removing}
|
||||
>
|
||||
{isWatching ? t("notifications.tooltips.unwatch") : t("notifications.tooltips.watch")}
|
||||
</Button>
|
||||
<Text type="secondary" style={{ marginBottom: 8, display: "block" }}>
|
||||
{t("notifications.labels.watching-issue")}
|
||||
</Text>
|
||||
{watcherLoading ? <LoadingSpinner /> : <List dataSource={jobWatchers} renderItem={handleRenderItem} />}
|
||||
<Divider />
|
||||
|
||||
<Text type="secondary">{t("notifications.labels.add-watchers")}</Text>
|
||||
<EmployeeSearchSelectComponent
|
||||
style={{ minWidth: "100%" }}
|
||||
options={bodyshop.employees.filter((e) => jobWatchers.every((w) => w.user_email !== e.user_email))}
|
||||
placeholder={t("notifications.labels.employee-search")}
|
||||
value={selectedWatcher}
|
||||
onChange={(value) => {
|
||||
setSelectedWatcher(value);
|
||||
handleWatcherSelect(value);
|
||||
}}
|
||||
/>
|
||||
{Enhanced_Payroll && bodyshop?.employee_teams?.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Text type="secondary">{t("notifications.labels.add-watchers-team")}</Text>
|
||||
<Select
|
||||
showSearch
|
||||
style={{ minWidth: "100%" }}
|
||||
placeholder={t("notifications.labels.teams-search")}
|
||||
value={selectedTeam}
|
||||
onChange={handleTeamSelect}
|
||||
options={bodyshop.employee_teams.map((team) => {
|
||||
const teamMembers = team.employee_team_members
|
||||
.map((member) => {
|
||||
const employee = bodyshop.employees.find((e) => e.id === member.employeeid);
|
||||
return employee ? employee.user_email : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
value: JSON.stringify(teamMembers),
|
||||
label: team.name
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={popoverContent} trigger="click" open={open} onOpenChange={setOpen}>
|
||||
<Tooltip title={isWatching ? t("notifications.tooltips.unwatch") : t("notifications.tooltips.watch")}>
|
||||
<Button
|
||||
shape="circle"
|
||||
type={isWatching ? "primary" : "default"}
|
||||
icon={isWatching ? <EyeFilled /> : <EyeOutlined />}
|
||||
loading={watcherLoading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(JobWatcherToggle);
|
||||
@@ -56,8 +56,8 @@ import { DateTimeFormat } from "../../utils/DateFormatter";
|
||||
import dayjs from "../../utils/day";
|
||||
import UndefinedToNull from "../../utils/undefinedtonull";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import JobWatcherToggle from "./job-watcher-toggle.component.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import JobWatcherToggleContainer from "../../components/job-watcher-toggle/job-watcher-toggle.container.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
@@ -325,7 +325,7 @@ export function JobsDetailPage({
|
||||
|
||||
title={
|
||||
<Space>
|
||||
{scenarioNotificationsOn && <JobWatcherToggle job={job} />}
|
||||
{scenarioNotificationsOn && <JobWatcherToggleContainer job={job} />}
|
||||
{job.ro_number || t("general.labels.na")}
|
||||
</Space>
|
||||
}
|
||||
|
||||
@@ -3782,7 +3782,10 @@
|
||||
"show-unread-only": "Show Unread",
|
||||
"mark-all-read": "Mark Read",
|
||||
"notification-popup-title": "Changes for Job #{{ro_number}}",
|
||||
"ro-number": "RO #{{ro_number}}"
|
||||
"ro-number": "RO #{{ro_number}}",
|
||||
"no-watchers": "No Watchers",
|
||||
"notification-settings-success": "Notification Settings saved successfully.",
|
||||
"notification-settings-failure": "Error saving Notification Settings. {{error}}"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "remove"
|
||||
|
||||
@@ -3782,7 +3782,10 @@
|
||||
"show-unread-only": "",
|
||||
"mark-all-read": "",
|
||||
"notification-popup-title": "",
|
||||
"ro-number": ""
|
||||
"ro-number": "",
|
||||
"no-watchers": "",
|
||||
"notification-settings-success": "",
|
||||
"notification-settings-failure": ""
|
||||
},
|
||||
"actions": {
|
||||
"remove": ""
|
||||
|
||||
@@ -3782,7 +3782,10 @@
|
||||
"show-unread-only": "",
|
||||
"mark-all-read": "",
|
||||
"notification-popup-title": "",
|
||||
"ro-number": ""
|
||||
"ro-number": "",
|
||||
"no-watchers": "",
|
||||
"notification-settings-success": "",
|
||||
"notification-settings-failure": ""
|
||||
},
|
||||
"actions": {
|
||||
"remove": ""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS "public"."idx_job_watchers_jobid_user_email_unique";
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE UNIQUE INDEX "idx_job_watchers_jobid_user_email_unique" on
|
||||
"public"."job_watchers" using btree ("jobid", "user_email");
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
const getJobAssignmentType = (data) => {
|
||||
switch (data) {
|
||||
case "employee_pre":
|
||||
case "employee_prep":
|
||||
return "Prep";
|
||||
case "employee_body":
|
||||
return "Body";
|
||||
|
||||
Reference in New Issue
Block a user