Merged in feature/IO-3291-Tasks-Notifications (pull request #2404)

Feature/IO-3291 Tasks Notifications
This commit is contained in:
Dave Richer
2025-07-11 17:40:06 +00:00
29 changed files with 2237 additions and 1190 deletions

View File

@@ -1,5 +1,5 @@
import { Select } from "antd";
import React, { forwardRef } from "react";
import { forwardRef } from "react";
import { useTranslation } from "react-i18next";
import InstanceRenderMgr from "../../utils/instanceRenderMgr";
@@ -43,7 +43,7 @@ const BillLineSearchSelect = ({ options, disabled, allowRemoved, ...restProps },
item.oem_partno ? ` - ${item.oem_partno}` : ""
}${item.alt_partno ? ` (${item.alt_partno})` : ""}`.trim(),
label: (
<div style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
<div style={{ whiteSpace: "normal", wordBreak: "break-word" }}>
<span>
{`${item.removed ? `(REMOVED) ` : ""}${item.line_desc}${
item.oem_partno ? ` - ${item.oem_partno}` : ""

View File

@@ -1,10 +1,10 @@
import React, { forwardRef, useEffect, useState } from "react";
import { forwardRef, useEffect, useState } from "react";
import { Select } from "antd";
import { useTranslation } from "react-i18next";
const { Option } = Select;
const ContractStatusComponent = ({ value, onChange }, ref) => {
const ContractStatusComponent = ({ value, onChange }) => {
const [option, setOption] = useState(value);
const { t } = useTranslation();

View File

@@ -1,5 +1,5 @@
import { Slider } from "antd";
import React, { forwardRef } from "react";
import { forwardRef } from "react";
import { useTranslation } from "react-i18next";
const CourtesyCarFuelComponent = (props, ref) => {

View File

@@ -0,0 +1,190 @@
import { Link } from "react-router-dom";
import { FaCreditCard, FaFileInvoiceDollar } from "react-icons/fa";
import { GiPayMoney, GiPlayerTime } from "react-icons/gi";
import { BankFilled, ExportOutlined, FieldTimeOutlined } from "@ant-design/icons";
import LockWrapper from "../../components/lock-wrapper/lock-wrapper.component.jsx";
import { HasFeatureAccess } from "../../components/feature-wrapper/feature-wrapper.component";
// --- Menu Item Builders ---
const buildAccountingChildren = ({
t,
bodyshop,
currentUser,
setBillEnterContext,
setPaymentContext,
setCardPaymentContext,
setTimeTicketContext,
ImEXPay,
DmsAp,
Simple_Inventory
}) => [
{
key: "bills",
id: "header-accounting-bills",
icon: <FaFileInvoiceDollar />,
label: (
<Link to="/manage/bills">
<LockWrapper featureName="bills" bodyshop={bodyshop}>
{t("menus.header.bills")}
</LockWrapper>
</Link>
)
},
{
key: "enterbills",
id: "header-accounting-enterbills",
icon: <GiPayMoney />,
label: (
<LockWrapper featureName="bills" bodyshop={bodyshop}>
{t("menus.header.enterbills")}
</LockWrapper>
),
onClick: () =>
HasFeatureAccess({ featureName: "bills", bodyshop }) && setBillEnterContext({ actions: {}, context: {} })
},
...(Simple_Inventory.treatment === "on"
? [
{ type: "divider" },
{
key: "inventory",
id: "header-accounting-inventory",
icon: <FaFileInvoiceDollar />,
label: <Link to="/manage/inventory">{t("menus.header.inventory")}</Link>
}
]
: []),
{ type: "divider" },
{
key: "allpayments",
id: "header-accounting-allpayments",
icon: <BankFilled />,
label: <Link to="/manage/payments">{t("menus.header.allpayments")}</Link>
},
{
key: "enterpayments",
id: "header-accounting-enterpayments",
icon: <FaCreditCard />,
label: t("menus.header.enterpayment"),
onClick: () => setPaymentContext({ actions: {}, context: null })
},
...(ImEXPay.treatment === "on"
? [
{
key: "entercardpayments",
id: "header-accounting-entercardpayments",
icon: <FaCreditCard />,
label: t("menus.header.entercardpayment"),
onClick: () => setCardPaymentContext({ actions: {}, context: {} })
}
]
: []),
{ type: "divider" },
{
key: "timetickets",
id: "header-accounting-timetickets",
icon: <FieldTimeOutlined />,
label: (
<Link to="/manage/timetickets">
<LockWrapper featureName="timetickets" bodyshop={bodyshop}>
{t("menus.header.timetickets")}
</LockWrapper>
</Link>
)
},
...(bodyshop?.md_tasks_presets?.use_approvals
? [
{
key: "ttapprovals",
id: "header-accounting-ttapprovals",
icon: <FieldTimeOutlined />,
label: <Link to="/manage/ttapprovals">{t("menus.header.ttapprovals")}</Link>
}
]
: []),
{
key: "entertimetickets",
id: "header-accounting-entertimetickets",
icon: <GiPlayerTime />,
label: (
<LockWrapper featureName="timetickets" bodyshop={bodyshop}>
{t("menus.header.entertimeticket")}
</LockWrapper>
),
onClick: () =>
HasFeatureAccess({ featureName: "timetickets", bodyshop }) &&
setTimeTicketContext({
actions: {},
context: {
created_by: currentUser.displayName ? `${currentUser.email} | ${currentUser.displayName}` : currentUser.email
}
})
},
{ type: "divider" },
{
key: "accountingexport",
id: "header-accounting-export",
icon: <ExportOutlined />,
label: (
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export")}
</LockWrapper>
),
children: [
{
key: "receivables",
id: "header-accounting-receivables",
label: (
<Link to="/manage/accounting/receivables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-receivables")}
</LockWrapper>
</Link>
)
},
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) || DmsAp.treatment === "on"
? [
{
key: "payables",
id: "header-accounting-payables",
label: (
<Link to="/manage/accounting/payables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payables")}
</LockWrapper>
</Link>
)
}
]
: []),
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber))
? [
{
key: "payments",
id: "header-accounting-payments",
label: (
<Link to="/manage/accounting/payments">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payments")}
</LockWrapper>
</Link>
)
}
]
: []),
{ type: "divider" },
{
key: "exportlogs",
id: "header-accounting-exportlogs",
label: (
<Link to="/manage/accounting/exportlogs">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export-logs")}
</LockWrapper>
</Link>
)
}
]
}
];
export default buildAccountingChildren;

View File

@@ -0,0 +1,390 @@
import { Link } from "react-router-dom";
import {
BarChartOutlined,
CarFilled,
CheckCircleOutlined,
ClockCircleFilled,
DashboardFilled,
DollarCircleFilled,
FileAddFilled,
FileAddOutlined,
FileFilled,
HomeFilled,
ImportOutlined,
LineChartOutlined,
OneToOneOutlined,
PaperClipOutlined,
PhoneOutlined,
PlusCircleOutlined,
QuestionCircleFilled,
ScheduleOutlined,
SettingOutlined,
TeamOutlined,
ToolFilled,
UnorderedListOutlined,
UsergroupAddOutlined,
UserOutlined
} from "@ant-design/icons";
import { FaCalendarAlt, FaCarCrash, FaTasks } from "react-icons/fa";
import { BsKanban } from "react-icons/bs";
import { FiLogOut } from "react-icons/fi";
import { GiPlayerTime, GiSettingsKnobs } from "react-icons/gi";
import { RiSurveyLine } from "react-icons/ri";
import { IoBusinessOutline } from "react-icons/io5";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import LockWrapper from "../../components/lock-wrapper/lock-wrapper.component.jsx";
const buildLeftMenuItems = ({
t,
bodyshop,
recentItems,
setTaskUpsertContext,
setReportCenterContext,
signOutStart,
accountingChildren
}) => {
return [
{
key: "home",
id: "header-home",
icon: <HomeFilled />,
label: <Link to="/manage/">{t("menus.header.home")}</Link>
},
{
key: "schedule",
id: "header-schedule",
icon: <FaCalendarAlt />,
label: <Link to="/manage/schedule">{t("menus.header.schedule")}</Link>
},
{
key: "jobssubmenu",
id: "header-jobs",
icon: <FaCarCrash />,
label: t("menus.header.jobs"),
children: [
{
key: "activejobs",
id: "header-active-jobs",
icon: <FileFilled />,
label: <Link to="/manage/jobs">{t("menus.header.activejobs")}</Link>
},
{
key: "readyjobs",
id: "header-ready-jobs",
icon: <CheckCircleOutlined />,
label: <Link to="/manage/jobs/ready">{t("menus.header.readyjobs")}</Link>
},
{
key: "parts-queue",
id: "header-parts-queue",
icon: <ToolFilled />,
label: <Link to="/manage/partsqueue">{t("menus.header.parts-queue")}</Link>
},
{
key: "availablejobs",
id: "header-jobs-available",
icon: <ImportOutlined />,
label: <Link to="/manage/available">{t("menus.header.availablejobs")}</Link>
},
{
key: "newjob",
id: "header-new-job",
icon: <FileAddOutlined />,
label: <Link to="/manage/jobs/new">{t("menus.header.newjob")}</Link>
},
{ type: "divider" },
{
key: "alljobs",
id: "header-all-jobs",
icon: <UnorderedListOutlined />,
label: <Link to="/manage/jobs/all">{t("menus.header.alljobs")}</Link>
},
{ type: "divider" },
{
key: "productionlist",
id: "header-production-list",
icon: <ScheduleOutlined />,
label: <Link to="/manage/production/list">{t("menus.header.productionlist")}</Link>
},
{
key: "productionboard",
id: "header-production-board",
icon: <BsKanban />,
label: (
<Link to="/manage/production/board">
<LockWrapper featureName="visualboard" bodyshop={bodyshop}>
{t("menus.header.productionboard")}
</LockWrapper>
</Link>
)
},
{ type: "divider" },
{
key: "scoreboard",
id: "header-scoreboard",
icon: <LineChartOutlined />,
label: (
<Link to="/manage/scoreboard">
<LockWrapper featureName="scoreboard" bodyshop={bodyshop}>
{t("menus.header.scoreboard")}
</LockWrapper>
</Link>
)
}
]
},
{
key: "customers",
id: "header-customers",
icon: <UserOutlined />,
label: t("menus.header.customers"),
children: [
{
key: "owners",
id: "header-owners",
icon: <TeamOutlined />,
label: <Link to="/manage/owners">{t("menus.header.owners")}</Link>
},
{
key: "vehicles",
id: "header-vehicles",
icon: <CarFilled />,
label: <Link to="/manage/vehicles">{t("menus.header.vehicles")}</Link>
}
]
},
{
key: "ccs",
id: "header-css",
icon: <CarFilled />,
label: (
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars")}
</LockWrapper>
),
children: [
{
key: "courtesycarsall",
id: "header-courtesycars-all",
icon: <CarFilled />,
label: (
<Link to="/manage/courtesycars">
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars-all")}
</LockWrapper>
</Link>
)
},
{
key: "contracts",
id: "header-contracts",
icon: <FileFilled />,
label: (
<Link to="/manage/courtesycars/contracts">
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars-contracts")}
</LockWrapper>
</Link>
)
},
{
key: "newcontract",
id: "header-newcontract",
icon: <FileAddFilled />,
label: (
<Link to="/manage/courtesycars/contracts/new">
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars-newcontract")}
</LockWrapper>
</Link>
)
}
]
},
...(accountingChildren.length > 0
? [
{
key: "accounting",
id: "header-accounting",
icon: <DollarCircleFilled />,
label: t("menus.header.accounting"),
children: accountingChildren
}
]
: []),
{
key: "phonebook",
id: "header-phonebook",
icon: <PhoneOutlined />,
label: <Link to="/manage/phonebook">{t("menus.header.phonebook")}</Link>
},
{
key: "temporarydocs",
id: "header-temporarydocs",
icon: <PaperClipOutlined />,
label: (
<Link to="/manage/temporarydocs">
<LockWrapper featureName="media" bodyshop={bodyshop}>
{t("menus.header.temporarydocs")}
</LockWrapper>
</Link>
)
},
{
key: "tasks",
id: "tasks",
icon: <FaTasks />,
label: t("menus.header.tasks"),
children: [
{
key: "createTask",
id: "header-create-task",
icon: <PlusCircleOutlined />,
label: t("menus.header.create_task"),
onClick: () => setTaskUpsertContext({ actions: {}, context: {} })
},
{
key: "mytasks",
id: "header-my-tasks",
icon: <FaTasks />,
label: <Link to="/manage/tasks/mytasks">{t("menus.header.my_tasks")}</Link>
},
{
key: "all_tasks",
id: "header-all-tasks",
icon: <FaTasks />,
label: <Link to="/manage/tasks/alltasks">{t("menus.header.all_tasks")}</Link>
}
]
},
{
key: "shopsubmenu",
id: "header-shopsubmenu",
icon: <SettingOutlined />,
label: t("menus.header.shop"),
children: [
{
key: "shop",
id: "header-shop",
icon: <GiSettingsKnobs />,
label: <Link to="/manage/shop?tab=info">{t("menus.header.shop_config")}</Link>
},
{
key: "dashboard",
id: "header-dashboard",
icon: <DashboardFilled />,
label: (
<Link to="/manage/dashboard">
<LockWrapper featureName="bills">{t("menus.header.dashboard")}</LockWrapper>
</Link>
)
},
{
key: "reportcenter",
id: "header-reportcenter",
icon: <BarChartOutlined />,
label: t("menus.header.reportcenter"),
onClick: () => setReportCenterContext({ actions: {}, context: {} })
},
{
key: "shop-vendors",
id: "header-shop-vendors",
icon: <IoBusinessOutline />,
label: <Link to="/manage/shop/vendors">{t("menus.header.shop_vendors")}</Link>
},
{
key: "shop-csi",
id: "header-shop-csi",
icon: <RiSurveyLine />,
label: (
<Link to="/manage/shop/csi">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.shop_csi")}
</LockWrapper>
</Link>
)
}
]
},
{
key: "recent",
id: "header-recent",
icon: <ClockCircleFilled />,
label: t("menus.header.recent"),
children: recentItems.map((i, idx) => ({
key: idx,
id: `header-recent-${idx}`,
label: <Link to={i.url}>{i.label}</Link>
}))
},
{
key: "user",
id: "header-user",
icon: <UserOutlined />,
label: t("menus.currentuser.profile"),
children: [
{
key: "signout",
id: "header-signout",
icon: <FiLogOut />,
danger: true,
label: t("user.actions.signout"),
onClick: () => signOutStart()
},
{
key: "help",
id: "header-help",
icon: <QuestionCircleFilled />,
label: t("menus.header.help"),
onClick: () => window.open("https://help.imex.online/", "_blank")
},
{
key: "remoteassist",
id: "header-remote-assist",
icon: <OneToOneOutlined />,
label: t("menus.header.remoteassist"),
children: [
...(InstanceRenderManager({ imex: true, rome: false })
? [
{
key: "rescue",
id: "header-rescue",
icon: <PlusCircleOutlined />,
label: t("menus.header.rescueme"),
onClick: () => window.open("https://imexrescue.com/", "_blank")
}
]
: []),
{
key: "rescue-zoho",
id: "header-rescue-zoho",
icon: <UsergroupAddOutlined />,
label: t("menus.header.rescuemezoho"),
onClick: () => window.open("https://join.zoho.com/", "_blank")
}
]
},
{
key: "shiftclock",
id: "header-shiftclock",
icon: <GiPlayerTime />,
label: (
<Link to="/manage/shiftclock">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.shiftclock")}
</LockWrapper>
</Link>
)
},
{
key: "profile",
id: "header-profile",
icon: <UserOutlined />,
label: <Link to="/manage/profile">{t("menus.currentuser.profile")}</Link>
}
]
}
];
};
export default buildLeftMenuItems;

View File

@@ -1,61 +1,29 @@
import {
BankFilled,
BarChartOutlined,
BellFilled,
CarFilled,
CheckCircleOutlined,
ClockCircleFilled,
DashboardFilled,
DollarCircleFilled,
ExportOutlined,
FieldTimeOutlined,
FileAddFilled,
FileAddOutlined,
FileFilled,
HomeFilled,
ImportOutlined,
LineChartOutlined,
OneToOneOutlined,
PaperClipOutlined,
PhoneOutlined,
PlusCircleOutlined,
QuestionCircleFilled,
ScheduleOutlined,
SettingOutlined,
TeamOutlined,
ToolFilled,
UnorderedListOutlined,
UsergroupAddOutlined,
UserOutlined
} from "@ant-design/icons";
// noinspection RegExpAnonymousGroup
import { BellFilled } from "@ant-design/icons";
import { useQuery } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Badge, Layout, Menu, Spin } from "antd";
import { useEffect, useRef, useState } from "react";
import { Badge, Layout, Menu, Spin, Tooltip } from "antd";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BsKanban } from "react-icons/bs";
import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa";
import { FiLogOut } from "react-icons/fi";
import { GiPayMoney, GiPlayerTime, GiSettingsKnobs } from "react-icons/gi";
import { IoBusinessOutline } from "react-icons/io5";
import { RiSurveyLine } from "react-icons/ri";
import { FaTasks } from "react-icons/fa";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { TASKS_CENTER_POLL_INTERVAL, useSocket } from "../../contexts/SocketIO/useSocket.js";
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
import { QUERY_MY_TASKS_COUNT } from "../../graphql/tasks.queries.js";
import { selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions";
import { signOutStart } from "../../redux/user/user.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import day from "../../utils/day.js";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
import TaskCenterContainer from "../task-center/task-center.container.jsx";
import buildAccountingChildren from "./buildAccountingChildren.jsx";
import buildLeftMenuItems from "./buildLeftMenuItems.jsx";
// Redux mappings
// --- Redux mappings ---
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
recentItems: selectRecentItems,
@@ -73,36 +41,8 @@ const mapDispatchToProps = (dispatch) => ({
setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" }))
});
function Header({
handleMenuClick,
currentUser,
bodyshop,
selectedHeader,
signOutStart,
setBillEnterContext,
setTimeTicketContext,
setPaymentContext,
setReportCenterContext,
recentItems,
setCardPaymentContext,
setTaskUpsertContext
}) {
const {
treatments: { ImEXPay, DmsAp, Simple_Inventory }
} = useSplitTreatments({
attributes: {},
names: ["ImEXPay", "DmsAp", "Simple_Inventory"],
splitKey: bodyshop && bodyshop.imexshopid
});
const { t } = useTranslation();
const { isConnected, scenarioNotificationsOn } = useSocket();
const [notificationVisible, setNotificationVisible] = useState(false);
const baseTitleRef = useRef(document.title || "");
const lastSetTitleRef = useRef("");
const userAssociationId = bodyshop?.associations?.[0]?.id;
const isEmployee = useIsEmployee(bodyshop, currentUser);
// --- Utility Hooks ---
function useUnreadNotifications(userAssociationId, isConnected, scenarioNotificationsOn) {
const {
data: unreadData,
refetch: refetchUnread,
@@ -128,633 +68,286 @@ function Header({
}
}, [isConnected, unreadLoading, refetchUnread, userAssociationId]);
// Keep The unread count in the title.
return { unreadCount, unreadLoading };
}
function useIncompleteTaskCount(assignedToId, bodyshopId, isEmployee, isConnected) {
const { data: taskCountData, loading: taskCountLoading } = useQuery(QUERY_MY_TASKS_COUNT, {
variables: { assigned_to: assignedToId, bodyshopid: bodyshopId },
skip: !assignedToId || !bodyshopId || !isEmployee,
fetchPolicy: "network-only",
pollInterval: isConnected ? 0 : TASKS_CENTER_POLL_INTERVAL
});
const incompleteTaskCount = taskCountData?.tasks_aggregate?.aggregate?.count ?? 0;
return { incompleteTaskCount, taskCountLoading };
}
// --- Main Component ---
function Header(props) {
const {
handleMenuClick,
currentUser,
bodyshop,
selectedHeader,
signOutStart,
setBillEnterContext,
setTimeTicketContext,
setPaymentContext,
setReportCenterContext,
recentItems,
setCardPaymentContext,
setTaskUpsertContext
} = props;
// Feature flags
const {
treatments: { ImEXPay, DmsAp, Simple_Inventory }
} = useSplitTreatments({
attributes: {},
names: ["ImEXPay", "DmsAp", "Simple_Inventory"],
splitKey: bodyshop && bodyshop.imexshopid
});
// Contexts and hooks
const { t } = useTranslation();
const { isConnected, scenarioNotificationsOn } = useSocket();
const [notificationVisible, setNotificationVisible] = useState(false);
const [taskCenterVisible, setTaskCenterVisible] = useState(false);
const baseTitleRef = useRef(document.title || "");
const lastSetTitleRef = useRef("");
const taskCenterRef = useRef(null);
const notificationRef = useRef(null);
const userAssociationId = bodyshop?.associations?.[0]?.id;
const isEmployee = useIsEmployee(bodyshop, currentUser);
// Data hooks
const { unreadCount, unreadLoading } = useUnreadNotifications(
userAssociationId,
isConnected,
scenarioNotificationsOn
);
const assignedToId = bodyshop?.employees?.find((e) => e.user_email === currentUser.email)?.id;
const { incompleteTaskCount, taskCountLoading } = useIncompleteTaskCount(
assignedToId,
bodyshop?.id,
isEmployee,
isConnected
);
// --- Effects ---
// Update document title with unread count
useEffect(() => {
const updateTitle = () => {
const currentTitle = document.title;
// Check if the current title differs from what we last set
if (currentTitle !== lastSetTitleRef.current) {
// Extract base title by removing any unread count prefix
const baseTitleMatch = currentTitle.match(/^\(\d+\)\s*(.*)$/);
baseTitleRef.current = baseTitleMatch ? baseTitleMatch[1] : currentTitle;
}
// Apply unread count to the base title
const newTitle = unreadCount > 0 ? `(${unreadCount}) ${baseTitleRef.current}` : baseTitleRef.current;
// Only update if the title has changed to avoid unnecessary DOM writes
if (document.title !== newTitle) {
document.title = newTitle;
lastSetTitleRef.current = newTitle; // Store what we set
lastSetTitleRef.current = newTitle;
}
};
updateTitle();
const interval = setInterval(updateTitle, 100);
return () => {
clearInterval(interval);
document.title = baseTitleRef.current;
};
}, [unreadCount]);
// Handle outside clicks for popovers
useEffect(() => {
const handleClickOutside = (event) => {
const isNotificationClick = event.target.closest("#header-notifications");
const isTaskCenterClick = event.target.closest("#header-taskcenter");
if (isNotificationClick && scenarioNotificationsOn) {
setTaskCenterVisible(false); // Close task center
return;
}
if (isTaskCenterClick) {
setNotificationVisible(scenarioNotificationsOn ? false : notificationVisible); // Close notification center if enabled
return;
}
if (taskCenterVisible && taskCenterRef.current && !taskCenterRef.current.contains(event.target)) {
setTaskCenterVisible(false);
}
if (
scenarioNotificationsOn &&
notificationVisible &&
notificationRef.current &&
!notificationRef.current.contains(event.target)
) {
setNotificationVisible(false);
}
};
// Initial update
updateTitle();
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [taskCenterVisible, notificationVisible, scenarioNotificationsOn]);
// Poll every 100ms to catch child component changes
const interval = setInterval(updateTitle, 100);
// --- Event Handlers ---
const handleTaskCenterClick = useCallback(
(e) => {
setTaskCenterVisible((prev) => {
if (prev) return false;
return true;
});
if (handleMenuClick) handleMenuClick(e);
},
[handleMenuClick]
);
// Cleanup
return () => {
clearInterval(interval);
document.title = baseTitleRef.current; // Reset to base title on unmount
};
}, [unreadCount]); // Re-run when unreadCount changes
const handleNotificationClick = useCallback(
(e) => {
setNotificationVisible((prev) => {
if (prev) return false;
return true;
});
if (handleMenuClick) handleMenuClick(e);
},
[handleMenuClick]
);
const handleNotificationClick = (e) => {
setNotificationVisible(!notificationVisible);
if (handleMenuClick) handleMenuClick(e);
};
// --- Menu Items ---
const accountingChildren = [
{
key: "bills",
id: "header-accounting-bills",
icon: <FaFileInvoiceDollar />,
label: (
<Link to="/manage/bills">
<LockWrapper featureName="bills" bodyshop={bodyshop}>
{t("menus.header.bills")}
</LockWrapper>
</Link>
)
},
{
key: "enterbills",
id: "header-accounting-enterbills",
icon: <GiPayMoney />,
label: (
<LockWrapper featureName="bills" bodyshop={bodyshop}>
{t("menus.header.enterbills")}
</LockWrapper>
),
onClick: () =>
HasFeatureAccess({ featureName: "bills", bodyshop }) &&
setBillEnterContext({
actions: {},
context: {}
})
},
...(Simple_Inventory.treatment === "on"
? [
{ type: "divider" },
{
key: "inventory",
id: "header-accounting-inventory",
icon: <FaFileInvoiceDollar />,
label: <Link to="/manage/inventory">{t("menus.header.inventory")}</Link>
}
]
: []),
{ type: "divider" },
{
key: "allpayments",
id: "header-accounting-allpayments",
icon: <BankFilled />,
label: <Link to="/manage/payments">{t("menus.header.allpayments")}</Link>
},
{
key: "enterpayments",
id: "header-accounting-enterpayments",
icon: <FaCreditCard />,
label: t("menus.header.enterpayment"),
onClick: () =>
setPaymentContext({
actions: {},
context: null
})
},
...(ImEXPay.treatment === "on"
? [
{
key: "entercardpayments",
id: "header-accounting-entercardpayments",
icon: <FaCreditCard />,
label: t("menus.header.entercardpayment"),
onClick: () => setCardPaymentContext({ actions: {}, context: {} })
}
]
: []),
{ type: "divider" },
{
key: "timetickets",
id: "header-accounting-timetickets",
icon: <FieldTimeOutlined />,
label: (
<Link to="/manage/timetickets">
<LockWrapper featureName="timetickets" bodyshop={bodyshop}>
{t("menus.header.timetickets")}
</LockWrapper>
</Link>
)
},
...(bodyshop?.md_tasks_presets?.use_approvals
? [
{
key: "ttapprovals",
id: "header-accounting-ttapprovals",
icon: <FieldTimeOutlined />,
label: <Link to="/manage/ttapprovals">{t("menus.header.ttapprovals")}</Link>
}
]
: []),
{
key: "entertimetickets",
id: "header-accounting-entertimetickets",
icon: <GiPlayerTime />,
label: (
<LockWrapper featureName="timetickets" bodyshop={bodyshop}>
{t("menus.header.entertimeticket")}
</LockWrapper>
),
onClick: () =>
HasFeatureAccess({ featureName: "timetickets", bodyshop }) &&
setTimeTicketContext({
actions: {},
context: {
created_by: currentUser.displayName
? `${currentUser.email} | ${currentUser.displayName}`
: currentUser.email
}
})
},
{ type: "divider" },
{
key: "accountingexport",
id: "header-accounting-export",
icon: <ExportOutlined />,
label: (
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export")}
</LockWrapper>
),
children: [
{
key: "receivables",
id: "header-accounting-receivables",
label: (
<Link to="/manage/accounting/receivables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-receivables")}
</LockWrapper>
</Link>
)
},
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) ||
DmsAp.treatment === "on"
? [
{
key: "payables",
id: "header-accounting-payables",
label: (
<Link to="/manage/accounting/payables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payables")}
</LockWrapper>
</Link>
)
}
]
: []),
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber))
? [
{
key: "payments",
id: "header-accounting-payments",
label: (
<Link to="/manage/accounting/payments">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payments")}
</LockWrapper>
</Link>
)
}
]
: []),
{ type: "divider" },
{
key: "exportlogs",
id: "header-accounting-exportlogs",
label: (
<Link to="/manage/accounting/exportlogs">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export-logs")}
</LockWrapper>
</Link>
)
}
]
// built externally to keep the component clean, but on this level to prevent unnecessary re-renders
const accountingChildren = useMemo(
() =>
buildAccountingChildren({
t,
bodyshop,
currentUser,
setBillEnterContext,
setPaymentContext,
setCardPaymentContext,
setTimeTicketContext,
ImEXPay,
DmsAp,
Simple_Inventory
}),
[
t,
bodyshop,
currentUser,
setBillEnterContext,
setPaymentContext,
setCardPaymentContext,
setTimeTicketContext,
ImEXPay,
DmsAp,
Simple_Inventory
]
);
// Built externally to keep the component clean
const leftMenuItems = useMemo(
() =>
buildLeftMenuItems({
t,
bodyshop,
recentItems,
setTaskUpsertContext,
setReportCenterContext,
signOutStart,
accountingChildren
}),
[t, bodyshop, recentItems, setTaskUpsertContext, setReportCenterContext, signOutStart, accountingChildren]
);
const rightMenuItems = useMemo(() => {
const items = [];
if (scenarioNotificationsOn) {
items.push({
key: "notifications",
id: "header-notifications",
icon: unreadLoading ? (
<Spin size="small" />
) : (
<Badge offset={[8, 0]} size="small" count={isEmployee ? unreadCount : 0}>
<BellFilled />
</Badge>
),
onClick: handleNotificationClick
});
}
];
// Left menu items (includes original navigation items)
const leftMenuItems = [
{
key: "home",
id: "header-home",
icon: <HomeFilled />,
label: <Link to="/manage/">{t("menus.header.home")}</Link>
},
{
key: "schedule",
id: "header-schedule",
icon: <FaCalendarAlt />,
label: <Link to="/manage/schedule">{t("menus.header.schedule")}</Link>
},
{
key: "jobssubmenu",
id: "header-jobs",
icon: <FaCarCrash />,
label: t("menus.header.jobs"),
children: [
{
key: "activejobs",
id: "header-active-jobs",
icon: <FileFilled />,
label: <Link to="/manage/jobs">{t("menus.header.activejobs")}</Link>
},
{
key: "readyjobs",
id: "header-ready-jobs",
icon: <CheckCircleOutlined />,
label: <Link to="/manage/jobs/ready">{t("menus.header.readyjobs")}</Link>
},
{
key: "parts-queue",
id: "header-parts-queue",
icon: <ToolFilled />,
label: <Link to="/manage/partsqueue">{t("menus.header.parts-queue")}</Link>
},
{
key: "availablejobs",
id: "header-jobs-available",
icon: <ImportOutlined />,
label: <Link to="/manage/available">{t("menus.header.availablejobs")}</Link>
},
{
key: "newjob",
id: "header-new-job",
icon: <FileAddOutlined />,
label: <Link to="/manage/jobs/new">{t("menus.header.newjob")}</Link>
},
{ type: "divider" },
{
key: "alljobs",
id: "header-all-jobs",
icon: <UnorderedListOutlined />,
label: <Link to="/manage/jobs/all">{t("menus.header.alljobs")}</Link>
},
{ type: "divider" },
{
key: "productionlist",
id: "header-production-list",
icon: <ScheduleOutlined />,
label: <Link to="/manage/production/list">{t("menus.header.productionlist")}</Link>
},
{
key: "productionboard",
id: "header-production-board",
icon: <BsKanban />,
label: (
<Link to="/manage/production/board">
<LockWrapper featureName="visualboard" bodyshop={bodyshop}>
{t("menus.header.productionboard")}
</LockWrapper>
</Link>
)
},
{ type: "divider" },
{
key: "scoreboard",
id: "header-scoreboard",
icon: <LineChartOutlined />,
label: (
<Link to="/manage/scoreboard">
<LockWrapper featureName="scoreboard" bodyshop={bodyshop}>
{t("menus.header.scoreboard")}
</LockWrapper>
</Link>
)
}
]
},
{
key: "customers",
id: "header-customers",
icon: <UserOutlined />,
label: t("menus.header.customers"),
children: [
{
key: "owners",
id: "header-owners",
icon: <TeamOutlined />,
label: <Link to="/manage/owners">{t("menus.header.owners")}</Link>
},
{
key: "vehicles",
id: "header-vehicles",
icon: <CarFilled />,
label: <Link to="/manage/vehicles">{t("menus.header.vehicles")}</Link>
}
]
},
{
key: "ccs",
id: "header-css",
icon: <CarFilled />,
label: (
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars")}
</LockWrapper>
items.push({
key: "taskcenter",
id: "header-taskcenter",
icon: taskCountLoading ? (
<Spin size="small" />
) : (
<Badge offset={[8, 0]} size="small" count={incompleteTaskCount > 0 ? incompleteTaskCount : 0} showZero={false}>
<Tooltip title={t("menus.header.tasks")}>
<FaTasks />
</Tooltip>
</Badge>
),
children: [
{
key: "courtesycarsall",
id: "header-courtesycars-all",
icon: <CarFilled />,
label: (
<Link to="/manage/courtesycars">
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars-all")}
</LockWrapper>
</Link>
)
},
{
key: "contracts",
id: "header-contracts",
icon: <FileFilled />,
label: (
<Link to="/manage/courtesycars/contracts">
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars-contracts")}
</LockWrapper>
</Link>
)
},
{
key: "newcontract",
id: "header-newcontract",
icon: <FileAddFilled />,
label: (
<Link to="/manage/courtesycars/contracts/new">
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars-newcontract")}
</LockWrapper>
</Link>
)
}
]
},
...(accountingChildren.length > 0
? [
{
key: "accounting",
id: "header-accounting",
icon: <DollarCircleFilled />,
label: t("menus.header.accounting"),
children: accountingChildren
}
]
: []),
{
key: "phonebook",
id: "header-phonebook",
icon: <PhoneOutlined />,
label: <Link to="/manage/phonebook">{t("menus.header.phonebook")}</Link>
},
{
key: "temporarydocs",
id: "header-temporarydocs",
icon: <PaperClipOutlined />,
label: (
<Link to="/manage/temporarydocs">
<LockWrapper featureName="media" bodyshop={bodyshop}>
{t("menus.header.temporarydocs")}
</LockWrapper>
</Link>
)
},
{
key: "tasks",
id: "tasks",
icon: <FaTasks />,
label: t("menus.header.tasks"),
children: [
{
key: "createTask",
id: "header-create-task",
icon: <PlusCircleOutlined />,
label: t("menus.header.create_task"),
onClick: () => setTaskUpsertContext({ actions: {}, context: {} })
},
{
key: "mytasks",
id: "header-my-tasks",
icon: <FaTasks />,
label: <Link to="/manage/tasks/mytasks">{t("menus.header.my_tasks")}</Link>
},
{
key: "all_tasks",
id: "header-all-tasks",
icon: <FaTasks />,
label: <Link to="/manage/tasks/alltasks">{t("menus.header.all_tasks")}</Link>
}
]
},
{
key: "shopsubmenu",
id: "header-shopsubmenu",
icon: <SettingOutlined />,
label: t("menus.header.shop"),
children: [
{
key: "shop",
id: "header-shop",
icon: <GiSettingsKnobs />,
label: <Link to="/manage/shop?tab=info">{t("menus.header.shop_config")}</Link>
},
{
key: "dashboard",
id: "header-dashboard",
icon: <DashboardFilled />,
label: (
<Link to="/manage/dashboard">
<LockWrapper featureName="bills">{t("menus.header.dashboard")}</LockWrapper>
</Link>
)
},
{
key: "reportcenter",
id: "header-reportcenter",
icon: <BarChartOutlined />,
label: t("menus.header.reportcenter"),
onClick: () => setReportCenterContext({ actions: {}, context: {} })
},
{
key: "shop-vendors",
id: "header-shop-vendors",
icon: <IoBusinessOutline />,
label: <Link to="/manage/shop/vendors">{t("menus.header.shop_vendors")}</Link>
},
{
key: "shop-csi",
id: "header-shop-csi",
icon: <RiSurveyLine />,
label: (
<Link to="/manage/shop/csi">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.shop_csi")}
</LockWrapper>
</Link>
)
}
]
},
{
key: "recent",
id: "header-recent",
icon: <ClockCircleFilled />,
label: t("menus.header.recent"),
children: recentItems.map((i, idx) => ({
key: idx,
id: `header-recent-${idx}`,
label: <Link to={i.url}>{i.label}</Link>
}))
},
{
key: "user",
id: "header-user",
icon: <UserOutlined />,
label: t("menus.currentuser.profile"),
children: [
{
key: "signout",
id: "header-signout",
icon: <FiLogOut />,
danger: true,
label: t("user.actions.signout"),
onClick: () => signOutStart()
},
{
key: "help",
id: "header-help",
icon: <QuestionCircleFilled />,
label: t("menus.header.help"),
onClick: () => window.open("https://help.imex.online/", "_blank")
},
{
key: "remoteassist",
id: "header-remote-assist",
icon: <OneToOneOutlined />,
label: t("menus.header.remoteassist"),
children: [
...(InstanceRenderManager({ imex: true, rome: false })
? [
{
key: "rescue",
id: "header-rescue",
icon: <PlusCircleOutlined />,
label: t("menus.header.rescueme"),
onClick: () => window.open("https://imexrescue.com/", "_blank")
}
]
: []),
{
key: "rescue-zoho",
id: "header-rescue-zoho",
icon: <UsergroupAddOutlined />,
label: t("menus.header.rescuemezoho"),
onClick: () => window.open("https://join.zoho.com/", "_blank")
}
]
},
{
key: "shiftclock",
id: "header-shiftclock",
icon: <GiPlayerTime />,
label: (
<Link to="/manage/shiftclock">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.shiftclock")}
</LockWrapper>
</Link>
)
},
{
key: "profile",
id: "header-profile",
icon: <UserOutlined />,
label: <Link to="/manage/profile">{t("menus.currentuser.profile")}</Link>
}
]
}
];
// Notifications item (always on the right)
const notificationItem = scenarioNotificationsOn
? [
{
key: "notifications",
id: "header-notifications",
icon: unreadLoading ? (
<Spin size="small" />
) : (
<Badge offset={[8, 0]} size="small" count={isEmployee ? unreadCount : 0}>
<BellFilled />
</Badge>
),
onClick: handleNotificationClick
}
]
: [];
onClick: handleTaskCenterClick
});
return items;
}, [
scenarioNotificationsOn,
unreadLoading,
unreadCount,
taskCountLoading,
incompleteTaskCount,
isEmployee,
handleNotificationClick,
handleTaskCenterClick,
t
]);
// --- Render ---
return (
<Layout.Header style={{ padding: 0, background: "#001529" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
height: "100%",
overflow: "hidden"
}}
>
<Menu
mode="horizontal"
theme="dark"
selectedKeys={[selectedHeader]}
onClick={handleMenuClick}
subMenuCloseDelay={0.3}
items={leftMenuItems}
style={{
flex: "1 1 auto",
minWidth: 0,
overflowX: "auto",
borderBottom: "none",
background: "transparent"
}}
/>
{scenarioNotificationsOn && (
<div style={{ display: "flex", alignItems: "center", height: "100%", overflow: "hidden" }}>
<div style={{ flexGrow: 1, overflowX: "auto", whiteSpace: "nowrap" }}>
<Menu
mode="horizontal"
theme="dark"
selectedKeys={[selectedHeader]}
onClick={handleMenuClick}
subMenuCloseDelay={0.3}
items={notificationItem}
style={{ flex: "0 0 auto", minWidth: 0, borderBottom: "none", background: "transparent" }}
items={leftMenuItems}
style={{ borderBottom: "none", background: "transparent", minWidth: "100%" }}
/>
)}
</div>
<div style={{ width: 120, flexShrink: 0 }}>
<Menu
mode="horizontal"
theme="dark"
selectedKeys={[selectedHeader]}
onClick={handleMenuClick}
subMenuCloseDelay={0.3}
items={rightMenuItems}
style={{ borderBottom: "none", background: "transparent", justifyContent: "flex-end" }}
/>
</div>
</div>
{scenarioNotificationsOn && (
<NotificationCenterContainer
visible={notificationVisible}
onClose={() => setNotificationVisible(false)}
unreadCount={unreadCount}
/>
<div ref={notificationRef}>
<NotificationCenterContainer
visible={notificationVisible}
onClose={() => setNotificationVisible(false)}
unreadCount={unreadCount}
/>
</div>
)}
<div ref={taskCenterRef}>
<TaskCenterContainer
incompleteTaskCount={incompleteTaskCount}
visible={taskCenterVisible}
onClose={() => setTaskCenterVisible(false)}
/>
</div>
</Layout.Header>
);
}

View File

@@ -131,4 +131,6 @@ const NotificationCenterComponent = forwardRef(
}
);
NotificationCenterComponent.displayName = "NotificationCenterComponent";
export default NotificationCenterComponent;

View File

@@ -0,0 +1,156 @@
import { Virtuoso } from "react-virtuoso";
import { Badge, Button, Spin } from "antd";
import { useTranslation } from "react-i18next";
import { forwardRef, useMemo, useRef } from "react";
import day from "../../utils/day.js";
import "./task-center.styles.scss";
import {
ArrowRightOutlined,
CalendarOutlined,
ClockCircleOutlined,
PlusCircleOutlined,
QuestionCircleOutlined
} from "@ant-design/icons";
const TaskCenterComponent = forwardRef(
({ visible, tasks, loading, error, onTaskClick, onLoadMore, hasMore, createNewTask, incompleteTaskCount }, ref) => {
const { t } = useTranslation();
const virtuosoRef = useRef(null);
const sectionIcons = {
[t("tasks.labels.overdue")]: <ClockCircleOutlined style={{ marginRight: 8 }} />,
[t("tasks.labels.due_today")]: <CalendarOutlined style={{ marginRight: 8 }} />,
[t("tasks.labels.upcoming")]: <ArrowRightOutlined style={{ marginRight: 8 }} />,
[t("tasks.labels.no_due_date")]: <QuestionCircleOutlined style={{ marginRight: 8 }} />
};
const groups = useMemo(() => {
const now = day();
const today = now.startOf("day");
const overdue = tasks.filter((t) => t.due_date && day(t.due_date).isBefore(today));
const dueToday = tasks.filter((t) => t.due_date && day(t.due_date).isSame(today, "day"));
const upcoming = tasks.filter(
(t) => t.due_date && day(t.due_date).isAfter(today) && !day(t.due_date).isSame(today, "day")
);
const noDueDate = tasks.filter((t) => !t.due_date);
return [
{ label: t("tasks.labels.overdue"), tasks: overdue },
{ label: t("tasks.labels.due_today"), tasks: dueToday },
{ label: t("tasks.labels.upcoming"), tasks: upcoming },
{ label: t("tasks.labels.no_due_date"), tasks: noDueDate }
].filter((group) => group.tasks.length > 0);
}, [tasks, t]);
const groupCounts = useMemo(() => groups.map((group) => group.tasks.length), [groups]);
const flatTasks = useMemo(() => groups.flatMap((group) => group.tasks), [groups]);
const priorityColors = {
1: "red",
2: "orange",
3: "green"
};
const getPriorityColor = (priority) => priorityColors[priority] || null;
const groupContent = (groupIndex) => {
const { label, tasks } = groups[groupIndex];
let displayCount = tasks.length;
if (label === t("tasks.labels.no_due_date")) {
displayCount =
incompleteTaskCount -
groups.reduce((sum, group, idx) => (idx !== groupIndex ? sum + group.tasks.length : sum), 0);
}
return (
<div className="section-title">
{sectionIcons[label]}
{label} ({displayCount})
</div>
);
};
const itemContent = (index) => {
const task = flatTasks[index];
const priorityColor = getPriorityColor(task.priority);
return (
<div
className="task-row"
onClick={() => onTaskClick(task.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onTaskClick(task.id);
}
}}
>
<div className="task-title-cell">
<div className="task-row-container">
<div className="task-title">{task.title}</div>
<div className="task-ro-number">
{t("tasks.labels.ro-number", {
ro_number: task.job?.ro_number || t("general.labels.na")
})}
</div>
</div>
</div>
<div className="task-due-cell">
{task.due_date && <span>{day(task.due_date).fromNow()}</span>}
{!!priorityColor && <Badge color={priorityColor} dot style={{ marginLeft: 6 }} />}
</div>
</div>
);
};
if (error) {
return (
<div className={`task-center ${visible ? "visible" : ""}`} ref={ref}>
<div className="task-header">
<h3>{t("tasks.labels.my_tasks_center")}</h3>
</div>
<div className="error-message">{t("tasks.errors.load_failed")}</div>
</div>
);
}
return (
<div className={`task-center ${visible ? "visible" : ""}`} ref={ref}>
<div className="task-header">
<Badge count={incompleteTaskCount} size="medium" offset={[13, -5]}>
<h3>{t("tasks.labels.my_tasks_center")}</h3>
</Badge>
<div className="task-header-actions">
<Button className="create-task-button" type="link" icon={<PlusCircleOutlined />} onClick={createNewTask} />
{loading && <Spin spinning={loading} size="small" />}
</div>
</div>
{tasks.length === 0 && !loading ? (
<div className="no-tasks-message">{t("tasks.labels.no_tasks")}</div>
) : (
<Virtuoso
ref={virtuosoRef}
style={{ height: "550px", width: "100%" }}
groupCounts={groupCounts}
groupContent={groupContent}
itemContent={itemContent}
endReached={hasMore && !loading ? onLoadMore : undefined}
components={{
Footer: () =>
loading ? (
<div className="loading-footer">
<Spin />
</div>
) : null
}}
/>
)}
</div>
);
}
);
TaskCenterComponent.displayName = "TaskCenterComponent";
export default TaskCenterComponent;

View File

@@ -0,0 +1,135 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQuery } from "@apollo/client";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { INITIAL_TASKS, TASKS_CENTER_POLL_INTERVAL, useSocket } from "../../contexts/SocketIO/useSocket";
import { useIsEmployee } from "../../utils/useIsEmployee";
import TaskCenterComponent from "./task-center.component";
import { setModalContext } from "../../redux/modals/modals.actions";
import { QUERY_TASKS_NO_DUE_DATE_PAGINATED, QUERY_TASKS_WITH_DUE_DATES } from "../../graphql/tasks.queries";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
});
const mapDispatchToProps = (dispatch) => ({
setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" }))
});
const TaskCenterContainer = ({
visible,
onClose,
bodyshop,
currentUser,
setTaskUpsertContext,
incompleteTaskCount
}) => {
const [tasks, setTasks] = useState([]);
const { isConnected } = useSocket();
const isEmployee = useIsEmployee(bodyshop, currentUser);
const assignedToId = useMemo(() => {
const employee = bodyshop?.employees?.find((e) => e.user_email === currentUser?.email);
return employee?.id || null;
}, [bodyshop, currentUser]);
// Query 1: Tasks with due dates
const {
data: dueDateData,
loading: dueLoading,
error: dueError
} = useQuery(QUERY_TASKS_WITH_DUE_DATES, {
variables: {
bodyshop: bodyshop?.id,
assigned_to: assignedToId,
order: [{ due_date: "asc" }, { created_at: "desc" }]
},
skip: !bodyshop?.id || !assignedToId || !isEmployee || !currentUser?.email,
fetchPolicy: "cache-and-network",
pollInterval: isConnected ? 0 : TASKS_CENTER_POLL_INTERVAL
});
// Query 2: Tasks with no due date (paginated)
const {
data: noDueDateData,
loading: noDueLoading,
error: noDueError,
fetchMore
} = useQuery(QUERY_TASKS_NO_DUE_DATE_PAGINATED, {
variables: {
bodyshop: bodyshop?.id,
assigned_to: assignedToId,
order: [{ priority: "asc" }, { created_at: "desc" }],
limit: INITIAL_TASKS, // Adjust this constant as needed
offset: 0
},
skip: !bodyshop?.id || !assignedToId || !isEmployee || !currentUser?.email,
fetchPolicy: "cache-and-network",
pollInterval: isConnected ? 0 : TASKS_CENTER_POLL_INTERVAL
});
// Combine tasks from both queries
useEffect(() => {
const dueDateTasks = dueDateData?.tasks || [];
const noDueDateTasks = noDueDateData?.tasks || [];
setTasks([...dueDateTasks, ...noDueDateTasks]);
}, [dueDateData, noDueDateData]);
const noDueDateLength = noDueDateData?.tasks?.length || 0;
const totalNoDueDate = noDueDateData?.tasks_aggregate?.aggregate?.count || 0;
const hasMore = noDueDateLength < totalNoDueDate;
// Handle pagination for no-due-date tasks
const handleLoadMore = () => {
fetchMore({
variables: {
offset: noDueDateData?.tasks?.length || 0
},
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev;
return {
...prev,
tasks: [...prev.tasks, ...fetchMoreResult.tasks],
tasks_aggregate: fetchMoreResult.tasks_aggregate
};
}
});
};
const handleTaskClick = useCallback(
(id) => {
const task = tasks.find((t) => t.id === id);
if (task) {
setTaskUpsertContext({
context: {
existingTask: task
}
});
}
},
[tasks, setTaskUpsertContext]
);
const createNewTask = () => {
setTaskUpsertContext({ actions: {}, context: {} });
};
return (
<TaskCenterComponent
visible={visible}
onClose={onClose}
tasks={tasks}
loading={dueLoading || noDueLoading}
error={dueError || noDueError}
onTaskClick={handleTaskClick}
onLoadMore={handleLoadMore}
hasMore={hasMore}
createNewTask={createNewTask}
incompleteTaskCount={incompleteTaskCount}
/>
);
};
export default connect(mapStateToProps, mapDispatchToProps)(TaskCenterContainer);

View File

@@ -0,0 +1,147 @@
.task-center {
position: absolute;
top: 64px;
right: 0;
width: 500px;
max-width: 500px;
background: #fff;
color: rgba(0, 0, 0, 0.85);
border: 1px solid #d9d9d9;
border-radius: 6px;
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;
&.visible {
display: block;
}
.task-header {
padding: 4px 10px;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
background: #fafafa;
h3 {
font-size: 14px;
margin: 0;
}
.create-task-button {
border: none;
color: white;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
&:hover {
background-color: #40a9ff;
}
}
}
.task-section {
margin: 0;
padding: 0;
}
.section-title {
padding: 0px 10px;
margin: 0px;
//font-size: 12px;
background: #f5f5f5;
font-weight: 650;
border-bottom: 1px solid #e8e8e8;
position: sticky;
top: 0;
z-index: 1;
}
.task-row-container {
margin-top: 15px;
margin-bottom: 15px;
}
.task-row {
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: flex-start;
&:hover {
background: #f5f5f5;
}
.task-title-cell {
flex: 1;
padding: 6px 8px;
vertical-align: top;
//font-size: 12px;
line-height: 1.2;
max-width: 350px; // or whatever fits your layout
.task-title {
font-size: 16px;
font-weight: 550;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%; // Or a specific width if you want more control
display: inline-block;
vertical-align: middle;
}
.task-ro-number {
margin-top: 20px;
color: #1677ff;
}
}
.task-due-cell {
padding: 6px 8px;
vertical-align: top;
//font-size: 12px;
line-height: 1.2;
text-align: right;
white-space: nowrap;
color: rgba(0, 0, 0, 0.45);
}
}
button {
margin: 8px auto;
padding: 4px 10px;
background-color: #1677ff;
color: white;
border: none;
border-radius: 4px;
//font-size: 12px;
cursor: pointer;
&:hover {
background-color: #4096ff;
}
&:disabled {
background-color: #d9d9d9;
cursor: not-allowed;
}
}
.no-tasks-message,
.error-message {
padding: 16px;
text-align: center;
color: rgba(0, 0, 0, 0.45);
}
.loading-footer {
padding: 16px;
text-align: center;
}
}

View File

@@ -4,13 +4,12 @@ import {
DeleteFilled,
DeleteOutlined,
EditFilled,
ExclamationCircleFilled,
PlusCircleFilled,
SyncOutlined
} from "@ant-design/icons";
import { Button, Card, Space, Switch, Table } from "antd";
import queryString from "query-string";
import React, { useCallback, useEffect } from "react";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
@@ -19,6 +18,7 @@ import { pageLimit } from "../../utils/config";
import { DateFormatter, DateTimeFormatter } from "../../utils/DateFormatter.jsx";
import dayjs from "../../utils/day";
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
import PriorityLabel from "../../utils/tasksPriorityLabel.jsx";
/**
* Task List Component
@@ -54,47 +54,12 @@ const RemindAtRecord = ({ remindAt }) => {
);
};
/**
* Priority Label Component
* @param priority
* @returns {Element}
* @constructor
*/
const PriorityLabel = ({ priority }) => {
switch (priority) {
case 1:
return (
<div>
High <ExclamationCircleFilled style={{ marginLeft: "5px", color: "red" }} />
</div>
);
case 2:
return (
<div>
Medium <ExclamationCircleFilled style={{ marginLeft: "5px", color: "yellow" }} />
</div>
);
case 3:
return (
<div>
Low <ExclamationCircleFilled style={{ marginLeft: "5px", color: "green" }} />
</div>
);
default:
return (
<div>
None <ExclamationCircleFilled style={{ marginLeft: "5px" }} />
</div>
);
}
};
const mapDispatchToProps = (dispatch) => ({
// Existing dispatch props...
setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" }))
});
const mapStateToProps = (state) => ({});
const mapStateToProps = () => ({});
export default connect(mapStateToProps, mapDispatchToProps)(TaskListComponent);

View File

@@ -4,7 +4,6 @@ import { useMutation, useQuery } from "@apollo/client";
import { MUTATION_TOGGLE_TASK_COMPLETED, MUTATION_TOGGLE_TASK_DELETED } from "../../graphql/tasks.queries.js";
import { pageLimit } from "../../utils/config.js";
import AlertComponent from "../alert/alert.component.jsx";
import React from "react";
import TaskListComponent from "./task-list.component.jsx";
import { useTranslation } from "react-i18next";
import { connect, useDispatch } from "react-redux";
@@ -20,7 +19,7 @@ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser
});
const mapDispatchToProps = (dispatch) => ({});
const mapDispatchToProps = () => ({});
export default connect(mapStateToProps, mapDispatchToProps)(TaskListContainer);
@@ -55,8 +54,8 @@ export function TaskListContainer({
bodyshop: bodyshop.id,
[relationshipType]: relationshipId,
deleted: deleted === "true",
completed: completed === "true", //TODO: Find where mine is set.
assigned_to: onlyMine ? bodyshop?.employees?.find((e) => e.user_email === currentUser.email)?.id : undefined, // replace currentUserID with the actual ID of the current user
completed: completed === "true",
assigned_to: onlyMine ? bodyshop?.employees?.find((e) => e.user_email === currentUser.email)?.id : undefined,
offset: page ? (page - 1) * pageLimit : 0,
limit: pageLimit,
order: [

View File

@@ -1,5 +1,4 @@
import { Col, Form, Input, Row, Select, Switch } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
@@ -8,6 +7,7 @@ import { connect } from "react-redux";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component.jsx";
import JobSearchSelectComponent from "../job-search-select/job-search-select.component.jsx";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import { Link } from "react-router-dom";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -42,7 +42,7 @@ export function TaskUpsertModalComponent({
];
const generatePresets = (job) => {
if (!job || !selectedJobDetails) return datePickerPresets; // return default presets if no job selected
if (!job || !selectedJobDetails) return datePickerPresets;
const relativePresets = [];
if (selectedJobDetails?.scheduled_completion) {
@@ -97,13 +97,8 @@ export function TaskUpsertModalComponent({
});
};
/**
* Change the selected job id
* @param jobId
*/
const changeJobId = (jobId) => {
setSelectedJobId(jobId || null);
// Reset the form fields when selectedJobId changes
clearRelations();
};
@@ -163,6 +158,13 @@ export function TaskUpsertModalComponent({
required: true
}
]}
extra={
existingTask && selectedJobId ? (
<div style={{ textAlign: "right" }}>
<Link to={`/manage/jobs/${selectedJobId}`}>{t("tasks.labels.go_to_job")}</Link>
</div>
) : null
}
>
<JobSearchSelectComponent
placeholder={t("tasks.placeholders.jobid")}
@@ -203,7 +205,18 @@ export function TaskUpsertModalComponent({
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label={t("tasks.fields.billid")} name="billid">
<Form.Item
label={t("tasks.fields.billid")}
name="billid"
extra={
form.getFieldValue("billid") ? (
<Link to={`/manage/bills?billid=${form.getFieldValue("billid")}`}>
{t("tasks.links.go_to_bill")} (
{selectedJobDetails?.bills?.find((bill) => bill.id === form.getFieldValue("billid"))?.invoice_number})
</Link>
) : null
}
>
<Select
allowClear
placeholder={t("tasks.placeholders.billid")}

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery } from "@apollo/client";
import { Form, Modal } from "antd";
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";