From 2061a49e0ec4775b46c5a2b338499aef3ec1cb5b Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Mon, 7 Jul 2025 17:35:00 -0400 Subject: [PATCH] feature/IO-3291-Tasks-Notifications: Checkpoint --- .../header/buildAccountingChildren.jsx | 190 +++ .../components/header/header.component.jsx | 1196 ++++++++--------- .../task-center/task-center.component.jsx | 94 ++ .../task-center/task-center.container.jsx | 116 ++ .../task-center/task-center.styles.scss | 66 + client/src/graphql/tasks.queries.js | 21 + 6 files changed, 1059 insertions(+), 624 deletions(-) create mode 100644 client/src/components/header/buildAccountingChildren.jsx create mode 100644 client/src/components/task-center/task-center.component.jsx create mode 100644 client/src/components/task-center/task-center.container.jsx create mode 100644 client/src/components/task-center/task-center.styles.scss diff --git a/client/src/components/header/buildAccountingChildren.jsx b/client/src/components/header/buildAccountingChildren.jsx new file mode 100644 index 000000000..574dbf603 --- /dev/null +++ b/client/src/components/header/buildAccountingChildren.jsx @@ -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: , + label: ( + + + {t("menus.header.bills")} + + + ) + }, + { + key: "enterbills", + id: "header-accounting-enterbills", + icon: , + label: ( + + {t("menus.header.enterbills")} + + ), + onClick: () => + HasFeatureAccess({ featureName: "bills", bodyshop }) && setBillEnterContext({ actions: {}, context: {} }) + }, + ...(Simple_Inventory.treatment === "on" + ? [ + { type: "divider" }, + { + key: "inventory", + id: "header-accounting-inventory", + icon: , + label: {t("menus.header.inventory")} + } + ] + : []), + { type: "divider" }, + { + key: "allpayments", + id: "header-accounting-allpayments", + icon: , + label: {t("menus.header.allpayments")} + }, + { + key: "enterpayments", + id: "header-accounting-enterpayments", + icon: , + label: t("menus.header.enterpayment"), + onClick: () => setPaymentContext({ actions: {}, context: null }) + }, + ...(ImEXPay.treatment === "on" + ? [ + { + key: "entercardpayments", + id: "header-accounting-entercardpayments", + icon: , + label: t("menus.header.entercardpayment"), + onClick: () => setCardPaymentContext({ actions: {}, context: {} }) + } + ] + : []), + { type: "divider" }, + { + key: "timetickets", + id: "header-accounting-timetickets", + icon: , + label: ( + + + {t("menus.header.timetickets")} + + + ) + }, + ...(bodyshop?.md_tasks_presets?.use_approvals + ? [ + { + key: "ttapprovals", + id: "header-accounting-ttapprovals", + icon: , + label: {t("menus.header.ttapprovals")} + } + ] + : []), + { + key: "entertimetickets", + id: "header-accounting-entertimetickets", + icon: , + label: ( + + {t("menus.header.entertimeticket")} + + ), + 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: , + label: ( + + {t("menus.header.export")} + + ), + children: [ + { + key: "receivables", + id: "header-accounting-receivables", + label: ( + + + {t("menus.header.accounting-receivables")} + + + ) + }, + ...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) || DmsAp.treatment === "on" + ? [ + { + key: "payables", + id: "header-accounting-payables", + label: ( + + + {t("menus.header.accounting-payables")} + + + ) + } + ] + : []), + ...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) + ? [ + { + key: "payments", + id: "header-accounting-payments", + label: ( + + + {t("menus.header.accounting-payments")} + + + ) + } + ] + : []), + { type: "divider" }, + { + key: "exportlogs", + id: "header-accounting-exportlogs", + label: ( + + + {t("menus.header.export-logs")} + + + ) + } + ] + } +]; + +export default buildAccountingChildren; diff --git a/client/src/components/header/header.component.jsx b/client/src/components/header/header.component.jsx index 826edea54..32522a0d8 100644 --- a/client/src/components/header/header.component.jsx +++ b/client/src/components/header/header.component.jsx @@ -1,5 +1,6 @@ +// noinspection RegExpAnonymousGroup + import { - BankFilled, BarChartOutlined, BellFilled, CarFilled, @@ -7,8 +8,6 @@ import { ClockCircleFilled, DashboardFilled, DollarCircleFilled, - ExportOutlined, - FieldTimeOutlined, FileAddFilled, FileAddOutlined, FileFilled, @@ -30,13 +29,13 @@ import { } 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 { FaCalendarAlt, FaCarCrash, FaTasks } from "react-icons/fa"; import { FiLogOut } from "react-icons/fi"; -import { GiPayMoney, GiPlayerTime, GiSettingsKnobs } from "react-icons/gi"; +import { GiPlayerTime, GiSettingsKnobs } from "react-icons/gi"; import { IoBusinessOutline } from "react-icons/io5"; import { RiSurveyLine } from "react-icons/ri"; import { connect } from "react-redux"; @@ -44,6 +43,7 @@ import { Link } from "react-router-dom"; import { createStructuredSelector } from "reselect"; import { 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"; @@ -51,11 +51,12 @@ import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selecto 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"; -// Redux mappings +// --- Redux mappings --- const mapStateToProps = createStructuredSelector({ currentUser: selectCurrentUser, recentItems: selectRecentItems, @@ -73,36 +74,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 +101,608 @@ 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 : day.duration(60, "seconds").asMilliseconds() + }); + + 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); - - // Cleanup - return () => { - clearInterval(interval); - document.title = baseTitleRef.current; // Reset to base title on unmount - }; - }, [unreadCount]); // Re-run when unreadCount changes - - const handleNotificationClick = (e) => { - setNotificationVisible(!notificationVisible); - if (handleMenuClick) handleMenuClick(e); - }; - - const accountingChildren = [ - { - key: "bills", - id: "header-accounting-bills", - icon: , - label: ( - - - {t("menus.header.bills")} - - - ) + // --- Event Handlers --- + const handleTaskCenterClick = useCallback( + (e) => { + setTaskCenterVisible((prev) => { + if (prev) return false; + return true; + }); + if (handleMenuClick) handleMenuClick(e); }, - { - key: "enterbills", - id: "header-accounting-enterbills", - icon: , - label: ( - - {t("menus.header.enterbills")} - - ), - onClick: () => - HasFeatureAccess({ featureName: "bills", bodyshop }) && - setBillEnterContext({ - actions: {}, - context: {} - }) + [handleMenuClick] + ); + + const handleNotificationClick = useCallback( + (e) => { + setNotificationVisible((prev) => { + if (prev) return false; + return true; + }); + if (handleMenuClick) handleMenuClick(e); }, - ...(Simple_Inventory.treatment === "on" - ? [ + [handleMenuClick] + ); + // --- Menu Items --- + 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 + ] + ); + + const leftMenuItems = useMemo( + () => [ + { + key: "home", + id: "header-home", + icon: , + label: {t("menus.header.home")} + }, + { + key: "schedule", + id: "header-schedule", + icon: , + label: {t("menus.header.schedule")} + }, + { + key: "jobssubmenu", + id: "header-jobs", + icon: , + label: t("menus.header.jobs"), + children: [ + { + key: "activejobs", + id: "header-active-jobs", + icon: , + label: {t("menus.header.activejobs")} + }, + { + key: "readyjobs", + id: "header-ready-jobs", + icon: , + label: {t("menus.header.readyjobs")} + }, + { + key: "parts-queue", + id: "header-parts-queue", + icon: , + label: {t("menus.header.parts-queue")} + }, + { + key: "availablejobs", + id: "header-jobs-available", + icon: , + label: {t("menus.header.availablejobs")} + }, + { + key: "newjob", + id: "header-new-job", + icon: , + label: {t("menus.header.newjob")} + }, { type: "divider" }, { - key: "inventory", - id: "header-accounting-inventory", - icon: , - label: {t("menus.header.inventory")} - } - ] - : []), - { type: "divider" }, - { - key: "allpayments", - id: "header-accounting-allpayments", - icon: , - label: {t("menus.header.allpayments")} - }, - { - key: "enterpayments", - id: "header-accounting-enterpayments", - icon: , - label: t("menus.header.enterpayment"), - onClick: () => - setPaymentContext({ - actions: {}, - context: null - }) - }, - ...(ImEXPay.treatment === "on" - ? [ + key: "alljobs", + id: "header-all-jobs", + icon: , + label: {t("menus.header.alljobs")} + }, + { type: "divider" }, { - key: "entercardpayments", - id: "header-accounting-entercardpayments", - icon: , - label: t("menus.header.entercardpayment"), - onClick: () => setCardPaymentContext({ actions: {}, context: {} }) + key: "productionlist", + id: "header-production-list", + icon: , + label: {t("menus.header.productionlist")} + }, + { + key: "productionboard", + id: "header-production-board", + icon: , + label: ( + + + {t("menus.header.productionboard")} + + + ) + }, + { type: "divider" }, + { + key: "scoreboard", + id: "header-scoreboard", + icon: , + label: ( + + + {t("menus.header.scoreboard")} + + + ) } ] - : []), - { type: "divider" }, - { - key: "timetickets", - id: "header-accounting-timetickets", - icon: , - label: ( - - - {t("menus.header.timetickets")} + }, + { + key: "customers", + id: "header-customers", + icon: , + label: t("menus.header.customers"), + children: [ + { + key: "owners", + id: "header-owners", + icon: , + label: {t("menus.header.owners")} + }, + { + key: "vehicles", + id: "header-vehicles", + icon: , + label: {t("menus.header.vehicles")} + } + ] + }, + { + key: "ccs", + id: "header-css", + icon: , + label: ( + + {t("menus.header.courtesycars")} - - ) - }, - ...(bodyshop?.md_tasks_presets?.use_approvals - ? [ + ), + children: [ { - key: "ttapprovals", - id: "header-accounting-ttapprovals", - icon: , - label: {t("menus.header.ttapprovals")} + key: "courtesycarsall", + id: "header-courtesycars-all", + icon: , + label: ( + + + {t("menus.header.courtesycars-all")} + + + ) + }, + { + key: "contracts", + id: "header-contracts", + icon: , + label: ( + + + {t("menus.header.courtesycars-contracts")} + + + ) + }, + { + key: "newcontract", + id: "header-newcontract", + icon: , + label: ( + + + {t("menus.header.courtesycars-newcontract")} + + + ) } ] - : []), - { - key: "entertimetickets", - id: "header-accounting-entertimetickets", - icon: , - label: ( - - {t("menus.header.entertimeticket")} - - ), - 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: , - label: ( - - {t("menus.header.export")} - - ), - children: [ - { - key: "receivables", - id: "header-accounting-receivables", - label: ( - - - {t("menus.header.accounting-receivables")} - - - ) - }, - ...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) || - DmsAp.treatment === "on" - ? [ - { - key: "payables", - id: "header-accounting-payables", - label: ( - - - {t("menus.header.accounting-payables")} - - - ) - } - ] - : []), - ...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) - ? [ - { - key: "payments", - id: "header-accounting-payments", - label: ( - - - {t("menus.header.accounting-payments")} - - - ) - } - ] - : []), - { type: "divider" }, - { - key: "exportlogs", - id: "header-accounting-exportlogs", - label: ( - - - {t("menus.header.export-logs")} - - - ) - } - ] - } - ]; - - // Left menu items (includes original navigation items) - const leftMenuItems = [ - { - key: "home", - id: "header-home", - icon: , - label: {t("menus.header.home")} - }, - { - key: "schedule", - id: "header-schedule", - icon: , - label: {t("menus.header.schedule")} - }, - { - key: "jobssubmenu", - id: "header-jobs", - icon: , - label: t("menus.header.jobs"), - children: [ - { - key: "activejobs", - id: "header-active-jobs", - icon: , - label: {t("menus.header.activejobs")} - }, - { - key: "readyjobs", - id: "header-ready-jobs", - icon: , - label: {t("menus.header.readyjobs")} - }, - { - key: "parts-queue", - id: "header-parts-queue", - icon: , - label: {t("menus.header.parts-queue")} - }, - { - key: "availablejobs", - id: "header-jobs-available", - icon: , - label: {t("menus.header.availablejobs")} - }, - { - key: "newjob", - id: "header-new-job", - icon: , - label: {t("menus.header.newjob")} - }, - { type: "divider" }, - { - key: "alljobs", - id: "header-all-jobs", - icon: , - label: {t("menus.header.alljobs")} - }, - { type: "divider" }, - { - key: "productionlist", - id: "header-production-list", - icon: , - label: {t("menus.header.productionlist")} - }, - { - key: "productionboard", - id: "header-production-board", - icon: , - label: ( - - - {t("menus.header.productionboard")} - - - ) - }, - { type: "divider" }, - { - key: "scoreboard", - id: "header-scoreboard", - icon: , - label: ( - - - {t("menus.header.scoreboard")} - - - ) - } - ] - }, - { - key: "customers", - id: "header-customers", - icon: , - label: t("menus.header.customers"), - children: [ - { - key: "owners", - id: "header-owners", - icon: , - label: {t("menus.header.owners")} - }, - { - key: "vehicles", - id: "header-vehicles", - icon: , - label: {t("menus.header.vehicles")} - } - ] - }, - { - key: "ccs", - id: "header-css", - icon: , - label: ( - - {t("menus.header.courtesycars")} - - ), - children: [ - { - key: "courtesycarsall", - id: "header-courtesycars-all", - icon: , - label: ( - - - {t("menus.header.courtesycars-all")} - - - ) - }, - { - key: "contracts", - id: "header-contracts", - icon: , - label: ( - - - {t("menus.header.courtesycars-contracts")} - - - ) - }, - { - key: "newcontract", - id: "header-newcontract", - icon: , - label: ( - - - {t("menus.header.courtesycars-newcontract")} - - - ) - } - ] - }, - ...(accountingChildren.length > 0 - ? [ - { - key: "accounting", - id: "header-accounting", - icon: , - label: t("menus.header.accounting"), - children: accountingChildren - } - ] - : []), - { - key: "phonebook", - id: "header-phonebook", - icon: , - label: {t("menus.header.phonebook")} - }, - { - key: "temporarydocs", - id: "header-temporarydocs", - icon: , - label: ( - - - {t("menus.header.temporarydocs")} - - - ) - }, - { - key: "tasks", - id: "tasks", - icon: , - label: t("menus.header.tasks"), - children: [ - { - key: "createTask", - id: "header-create-task", - icon: , - label: t("menus.header.create_task"), - onClick: () => setTaskUpsertContext({ actions: {}, context: {} }) - }, - { - key: "mytasks", - id: "header-my-tasks", - icon: , - label: {t("menus.header.my_tasks")} - }, - { - key: "all_tasks", - id: "header-all-tasks", - icon: , - label: {t("menus.header.all_tasks")} - } - ] - }, - { - key: "shopsubmenu", - id: "header-shopsubmenu", - icon: , - label: t("menus.header.shop"), - children: [ - { - key: "shop", - id: "header-shop", - icon: , - label: {t("menus.header.shop_config")} - }, - { - key: "dashboard", - id: "header-dashboard", - icon: , - label: ( - - {t("menus.header.dashboard")} - - ) - }, - { - key: "reportcenter", - id: "header-reportcenter", - icon: , - label: t("menus.header.reportcenter"), - onClick: () => setReportCenterContext({ actions: {}, context: {} }) - }, - { - key: "shop-vendors", - id: "header-shop-vendors", - icon: , - label: {t("menus.header.shop_vendors")} - }, - { - key: "shop-csi", - id: "header-shop-csi", - icon: , - label: ( - - - {t("menus.header.shop_csi")} - - - ) - } - ] - }, - { - key: "recent", - id: "header-recent", - icon: , - label: t("menus.header.recent"), - children: recentItems.map((i, idx) => ({ - key: idx, - id: `header-recent-${idx}`, - label: {i.label} - })) - }, - { - key: "user", - id: "header-user", - icon: , - label: t("menus.currentuser.profile"), - children: [ - { - key: "signout", - id: "header-signout", - icon: , - danger: true, - label: t("user.actions.signout"), - onClick: () => signOutStart() - }, - { - key: "help", - id: "header-help", - icon: , - label: t("menus.header.help"), - onClick: () => window.open("https://help.imex.online/", "_blank") - }, - { - key: "remoteassist", - id: "header-remote-assist", - icon: , - label: t("menus.header.remoteassist"), - children: [ - ...(InstanceRenderManager({ imex: true, rome: false }) - ? [ - { - key: "rescue", - id: "header-rescue", - icon: , - label: t("menus.header.rescueme"), - onClick: () => window.open("https://imexrescue.com/", "_blank") - } - ] - : []), + }, + ...(accountingChildren.length > 0 + ? [ { - key: "rescue-zoho", - id: "header-rescue-zoho", - icon: , - label: t("menus.header.rescuemezoho"), - onClick: () => window.open("https://join.zoho.com/", "_blank") + key: "accounting", + id: "header-accounting", + icon: , + label: t("menus.header.accounting"), + children: accountingChildren } ] - }, - { - key: "shiftclock", - id: "header-shiftclock", - icon: , - label: ( - - - {t("menus.header.shiftclock")} - - - ) - }, - { - key: "profile", - id: "header-profile", - icon: , - label: {t("menus.currentuser.profile")} - } - ] + : []), + { + key: "phonebook", + id: "header-phonebook", + icon: , + label: {t("menus.header.phonebook")} + }, + { + key: "temporarydocs", + id: "header-temporarydocs", + icon: , + label: ( + + + {t("menus.header.temporarydocs")} + + + ) + }, + { + key: "tasks", + id: "tasks", + icon: , + label: t("menus.header.tasks"), + children: [ + { + key: "createTask", + id: "header-create-task", + icon: , + label: t("menus.header.create_task"), + onClick: () => setTaskUpsertContext({ actions: {}, context: {} }) + }, + { + key: "mytasks", + id: "header-my-tasks", + icon: , + label: {t("menus.header.my_tasks")} + }, + { + key: "all_tasks", + id: "header-all-tasks", + icon: , + label: {t("menus.header.all_tasks")} + } + ] + }, + { + key: "shopsubmenu", + id: "header-shopsubmenu", + icon: , + label: t("menus.header.shop"), + children: [ + { + key: "shop", + id: "header-shop", + icon: , + label: {t("menus.header.shop_config")} + }, + { + key: "dashboard", + id: "header-dashboard", + icon: , + label: ( + + {t("menus.header.dashboard")} + + ) + }, + { + key: "reportcenter", + id: "header-reportcenter", + icon: , + label: t("menus.header.reportcenter"), + onClick: () => setReportCenterContext({ actions: {}, context: {} }) + }, + { + key: "shop-vendors", + id: "header-shop-vendors", + icon: , + label: {t("menus.header.shop_vendors")} + }, + { + key: "shop-csi", + id: "header-shop-csi", + icon: , + label: ( + + + {t("menus.header.shop_csi")} + + + ) + } + ] + }, + { + key: "recent", + id: "header-recent", + icon: , + label: t("menus.header.recent"), + children: recentItems.map((i, idx) => ({ + key: idx, + id: `header-recent-${idx}`, + label: {i.label} + })) + }, + { + key: "user", + id: "header-user", + icon: , + label: t("menus.currentuser.profile"), + children: [ + { + key: "signout", + id: "header-signout", + icon: , + danger: true, + label: t("user.actions.signout"), + onClick: () => signOutStart() + }, + { + key: "help", + id: "header-help", + icon: , + label: t("menus.header.help"), + onClick: () => window.open("https://help.imex.online/", "_blank") + }, + { + key: "remoteassist", + id: "header-remote-assist", + icon: , + label: t("menus.header.remoteassist"), + children: [ + ...(InstanceRenderManager({ imex: true, rome: false }) + ? [ + { + key: "rescue", + id: "header-rescue", + icon: , + label: t("menus.header.rescueme"), + onClick: () => window.open("https://imexrescue.com/", "_blank") + } + ] + : []), + { + key: "rescue-zoho", + id: "header-rescue-zoho", + icon: , + label: t("menus.header.rescuemezoho"), + onClick: () => window.open("https://join.zoho.com/", "_blank") + } + ] + }, + { + key: "shiftclock", + id: "header-shiftclock", + icon: , + label: ( + + + {t("menus.header.shiftclock")} + + + ) + }, + { + key: "profile", + id: "header-profile", + icon: , + label: {t("menus.currentuser.profile")} + } + ] + } + ], + [t, bodyshop, recentItems, setTaskUpsertContext, setReportCenterContext, signOutStart, accountingChildren] + ); + + const rightMenuItems = useMemo(() => { + const items = []; + if (scenarioNotificationsOn) { + items.push({ + key: "notifications", + id: "header-notifications", + icon: unreadLoading ? ( + + ) : ( + + + + ), + onClick: handleNotificationClick + }); } - ]; - - // Notifications item (always on the right) - const notificationItem = scenarioNotificationsOn - ? [ - { - key: "notifications", - id: "header-notifications", - icon: unreadLoading ? ( - - ) : ( - - - - ), - onClick: handleNotificationClick - } - ] - : []; + items.push({ + key: "taskcenter", + id: "header-taskcenter", + icon: taskCountLoading ? ( + + ) : ( + 0 ? incompleteTaskCount : 0} showZero={false}> + + + + + ), + onClick: handleTaskCenterClick + }); + return items; + }, [ + scenarioNotificationsOn, + unreadLoading, + unreadCount, + taskCountLoading, + incompleteTaskCount, + isEmployee, + handleNotificationClick, + handleTaskCenterClick, + t + ]); + // --- Render --- return ( -
- - {scenarioNotificationsOn && ( +
+
- )} +
+
+ +
{scenarioNotificationsOn && ( - setNotificationVisible(false)} - unreadCount={unreadCount} - /> +
+ setNotificationVisible(false)} + unreadCount={unreadCount} + /> +
)} +
+ setTaskCenterVisible(false)} /> +
); } diff --git a/client/src/components/task-center/task-center.component.jsx b/client/src/components/task-center/task-center.component.jsx new file mode 100644 index 000000000..697beed09 --- /dev/null +++ b/client/src/components/task-center/task-center.component.jsx @@ -0,0 +1,94 @@ +// client/src/components/task-center/task-center.component.jsx +import { Virtuoso } from "react-virtuoso"; +import { Badge, Button, Space, Spin, Switch, Tooltip, Typography } from "antd"; +import { CheckCircleFilled, CheckCircleOutlined, EyeFilled, EyeOutlined } from "@ant-design/icons"; +import { useTranslation } from "react-i18next"; +import { forwardRef, useEffect, useRef } from "react"; +import { DateTimeFormat } from "../../utils/DateFormatter.jsx"; +import day from "../../utils/day.js"; +import "./task-center.styles.scss"; // You can clone this from notification styles for now + +const { Text, Title } = Typography; + +const TaskCenterComponent = forwardRef( + ({ visible, onClose, tasks, loading, showIncompleteOnly, toggleIncomplete, markAllComplete, onTaskClick }, ref) => { + const { t } = useTranslation(); + const virtuosoRef = useRef(null); + + useEffect(() => { + if (virtuosoRef.current) { + virtuosoRef.current.scrollToIndex({ index: 0, behavior: "smooth" }); + } + }, [showIncompleteOnly]); + + const renderTask = (index, task) => { + const handleClick = () => { + onTaskClick(task.id); + }; + + return ( +
+ +
+ + <span className="ro-number"> + {t("notifications.labels.ro-number", { + ro_number: task.job?.ro_number || t("general.labels.na") + })} + </span> + <Text type="secondary" className="relative-time" title={DateTimeFormat(task.created_at)}> + {day(task.created_at).fromNow()} + </Text> + + + {task.title} + +
+
+
+ ); + }; + + return ( +
+
+ +

{t("tasks.labels.my_tasks_center")}

+ {loading && } +
+
+ + + {showIncompleteOnly ? : } + toggleIncomplete(checked)} size="small" /> + + + +
+
+ +
+ ); + } +); + +TaskCenterComponent.displayName = "TaskCenterComponent"; + +export default TaskCenterComponent; diff --git a/client/src/components/task-center/task-center.container.jsx b/client/src/components/task-center/task-center.container.jsx new file mode 100644 index 000000000..43fe077ef --- /dev/null +++ b/client/src/components/task-center/task-center.container.jsx @@ -0,0 +1,116 @@ +// client/src/components/task-center/task-center.container.jsx +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery } from "@apollo/client"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; +import { useSocket } from "../../contexts/SocketIO/useSocket"; +import { useIsEmployee } from "../../utils/useIsEmployee"; +import { useNotification } from "../../contexts/Notifications/notificationContext"; +import { MUTATION_TOGGLE_TASK_COMPLETED, QUERY_MY_TASKS_PAGINATED } from "../../graphql/tasks.queries"; +import TaskCenterComponent from "./task-center.component"; +import dayjs from "../../utils/day"; + +const POLL_INTERVAL = 60; // seconds + +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop, + currentUser: selectCurrentUser +}); + +const TaskCenterContainer = ({ visible, onClose, bodyshop, currentUser }) => { + const [tasks, setTasks] = useState([]); + const [showIncompleteOnly, setShowIncompleteOnly] = useState(true); + const [loading, setLoading] = useState(false); + const { isConnected } = useSocket(); + const isEmployee = useIsEmployee(bodyshop, currentUser); + const notification = useNotification(); + const assignedToId = bodyshop?.employees?.find((e) => e.user_email === currentUser.email)?.id; + + const where = useMemo(() => { + return { + assigned_to: { _eq: assignedToId }, + deleted: { _eq: false }, + ...(showIncompleteOnly ? { completed: { _eq: false } } : {}) + }; + }, [assignedToId, showIncompleteOnly]); + + const { + data, + loading: queryLoading, + refetch + } = useQuery(QUERY_MY_TASKS_PAGINATED, { + variables: { + bodyshop: bodyshop?.id, + assigned_to: assignedToId, + where, + offset: 0, + limit: 50, + order: [{ created_at: "desc" }] + }, + skip: !bodyshop?.id || !assignedToId || !isEmployee, + fetchPolicy: "cache-and-network", + pollInterval: isConnected ? 0 : dayjs.duration(POLL_INTERVAL, "seconds").asMilliseconds() + }); + + const [toggleTaskCompleted] = useMutation(MUTATION_TOGGLE_TASK_COMPLETED); + + useEffect(() => { + if (data?.tasks) { + setTasks(data.tasks); + } + }, [data]); + + const handleToggleIncomplete = (val) => { + setShowIncompleteOnly(val); + }; + + const handleMarkAllComplete = async () => { + setLoading(true); + try { + const incompleteTasks = tasks.filter((t) => !t.completed); + await Promise.all( + incompleteTasks.map((task) => + toggleTaskCompleted({ + variables: { + id: task.id, + completed: true, + completed_at: dayjs().toISOString() + } + }) + ) + ); + notification.success({ message: "Tasks marked complete" }); + refetch(); + } catch (err) { + notification.error({ message: "Failed to mark tasks complete" }); + } finally { + setLoading(false); + } + }; + + const handleTaskClick = useCallback( + (id) => { + const task = tasks.find((t) => t.id === id); + if (task) { + window.location.href = `/manage/jobs/${task.jobid}`; + } + }, + [tasks] + ); + + return ( + + ); +}; + +export default connect(mapStateToProps)(TaskCenterContainer); diff --git a/client/src/components/task-center/task-center.styles.scss b/client/src/components/task-center/task-center.styles.scss new file mode 100644 index 000000000..e7b85a353 --- /dev/null +++ b/client/src/components/task-center/task-center.styles.scss @@ -0,0 +1,66 @@ +.task-center { + position: absolute; + top: 64px; + right: 0; + z-index: 1000; + width: 400px; + max-height: 500px; + background: #fff; + border-left: 1px solid #ccc; + box-shadow: -2px 2px 8px rgba(0, 0, 0, 0.1); + overflow: hidden; + display: none; + + &.visible { + display: block; + } + + .task-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background-color: #f5f5f5; + border-bottom: 1px solid #ddd; + } + + .task-toggle { + display: flex; + align-items: center; + gap: 4px; + + .anticon { + font-size: 14px; + } + } + + .task-item { + padding: 12px; + border-bottom: 1px solid #f0f0f0; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background-color: #fafafa; + } + + &.task-completed { + opacity: 0.6; + } + + .ro-number { + font-weight: bold; + margin-right: 8px; + } + + .task-body { + display: block; + margin-top: 4px; + } + + .relative-time { + float: right; + font-size: 0.8em; + } + } +} diff --git a/client/src/graphql/tasks.queries.js b/client/src/graphql/tasks.queries.js index 90d5606c7..554c21a18 100644 --- a/client/src/graphql/tasks.queries.js +++ b/client/src/graphql/tasks.queries.js @@ -381,3 +381,24 @@ export const MUTATION_UPDATE_TASK = gql` } } `; + +/** + * Query to get the count of my tasks + * @type {DocumentNode} + */ +export const QUERY_MY_TASKS_COUNT = gql` + query QUERY_MY_TASKS_COUNT($assigned_to: uuid!, $bodyshopid: uuid!) { + tasks_aggregate( + where: { + assigned_to: { _eq: $assigned_to } + bodyshopid: { _eq: $bodyshopid } + completed: { _eq: false } + deleted: { _eq: false } + } + ) { + aggregate { + count + } + } + } +`;