From f8a3d0f85424372bf618997bc9673b807418d265 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Mon, 7 Jul 2025 15:06:37 -0400 Subject: [PATCH 01/17] feature/IO-3291-Tasks-Notifications: Pre-cleanup --- .../bill-line-search-select.component.jsx | 4 ++-- .../contract-status-select.component.jsx | 4 ++-- .../courtesy-car-fuel-select.component.jsx | 2 +- .../notification-center/notification-center.component.jsx | 2 ++ client/src/components/task-list/task-list.container.jsx | 3 +-- client/src/pages/tasks/myTasksPageContainer.jsx | 2 +- client/src/pages/tasks/tasks.page.component.jsx | 3 +-- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/client/src/components/bill-line-search-select/bill-line-search-select.component.jsx b/client/src/components/bill-line-search-select/bill-line-search-select.component.jsx index 0da329d6f..ac97c8e81 100644 --- a/client/src/components/bill-line-search-select/bill-line-search-select.component.jsx +++ b/client/src/components/bill-line-search-select/bill-line-search-select.component.jsx @@ -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: ( -
+
{`${item.removed ? `(REMOVED) ` : ""}${item.line_desc}${ item.oem_partno ? ` - ${item.oem_partno}` : "" diff --git a/client/src/components/contract-status-select/contract-status-select.component.jsx b/client/src/components/contract-status-select/contract-status-select.component.jsx index 8bd048195..3a4ee82ee 100644 --- a/client/src/components/contract-status-select/contract-status-select.component.jsx +++ b/client/src/components/contract-status-select/contract-status-select.component.jsx @@ -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(); diff --git a/client/src/components/courtesy-car-fuel-select/courtesy-car-fuel-select.component.jsx b/client/src/components/courtesy-car-fuel-select/courtesy-car-fuel-select.component.jsx index ffe1c1f97..a189e0660 100644 --- a/client/src/components/courtesy-car-fuel-select/courtesy-car-fuel-select.component.jsx +++ b/client/src/components/courtesy-car-fuel-select/courtesy-car-fuel-select.component.jsx @@ -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) => { diff --git a/client/src/components/notification-center/notification-center.component.jsx b/client/src/components/notification-center/notification-center.component.jsx index 35dac2af6..a29e675fd 100644 --- a/client/src/components/notification-center/notification-center.component.jsx +++ b/client/src/components/notification-center/notification-center.component.jsx @@ -131,4 +131,6 @@ const NotificationCenterComponent = forwardRef( } ); +NotificationCenterComponent.displayName = "NotificationCenterComponent"; + export default NotificationCenterComponent; diff --git a/client/src/components/task-list/task-list.container.jsx b/client/src/components/task-list/task-list.container.jsx index bcf81b9ce..1dd11a7bf 100644 --- a/client/src/components/task-list/task-list.container.jsx +++ b/client/src/components/task-list/task-list.container.jsx @@ -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); diff --git a/client/src/pages/tasks/myTasksPageContainer.jsx b/client/src/pages/tasks/myTasksPageContainer.jsx index 4477e3878..1cca7b3a6 100644 --- a/client/src/pages/tasks/myTasksPageContainer.jsx +++ b/client/src/pages/tasks/myTasksPageContainer.jsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import TasksPageComponent from "./tasks.page.component"; diff --git a/client/src/pages/tasks/tasks.page.component.jsx b/client/src/pages/tasks/tasks.page.component.jsx index 75aa9092c..80d02aba8 100644 --- a/client/src/pages/tasks/tasks.page.component.jsx +++ b/client/src/pages/tasks/tasks.page.component.jsx @@ -1,4 +1,3 @@ -import React from "react"; import TaskListContainer from "../../components/task-list/task-list.container.jsx"; import { QUERY_ALL_TASKS_PAGINATED, QUERY_MY_TASKS_PAGINATED } from "../../graphql/tasks.queries.js"; import taskPageTypes from "./taskPageTypes.jsx"; @@ -10,7 +9,7 @@ const mapStateToProps = createStructuredSelector({ currentUser: selectCurrentUser, bodyshop: selectBodyshop }); -const mapDispatchToProps = (dispatch) => ({}); +const mapDispatchToProps = () => ({}); export default connect(mapStateToProps, mapDispatchToProps)(TasksPageComponent); From 2061a49e0ec4775b46c5a2b338499aef3ec1cb5b Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Mon, 7 Jul 2025 17:35:00 -0400 Subject: [PATCH 02/17] 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 + } + } + } +`; From 9845c1cea546e5d0adf8bd6001039005875f853f Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Mon, 7 Jul 2025 18:10:40 -0400 Subject: [PATCH 03/17] feature/IO-3291-Tasks-Notifications: Checkpoint --- .../components/header/buildLeftMenuItems.jsx | 390 +++++++++++++++++ .../components/header/header.component.jsx | 394 +----------------- 2 files changed, 406 insertions(+), 378 deletions(-) create mode 100644 client/src/components/header/buildLeftMenuItems.jsx diff --git a/client/src/components/header/buildLeftMenuItems.jsx b/client/src/components/header/buildLeftMenuItems.jsx new file mode 100644 index 000000000..166779572 --- /dev/null +++ b/client/src/components/header/buildLeftMenuItems.jsx @@ -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: , + 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") + } + ] + : []), + { + 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")} + } + ] + } + ]; +}; + +export default buildLeftMenuItems; diff --git a/client/src/components/header/header.component.jsx b/client/src/components/header/header.component.jsx index 32522a0d8..fe73718f1 100644 --- a/client/src/components/header/header.component.jsx +++ b/client/src/components/header/header.component.jsx @@ -1,45 +1,13 @@ // noinspection RegExpAnonymousGroup -import { - BarChartOutlined, - BellFilled, - 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 { BellFilled } from "@ant-design/icons"; import { useQuery } from "@apollo/client"; import { useSplitTreatments } from "@splitsoftware/splitio-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, FaTasks } from "react-icons/fa"; -import { FiLogOut } from "react-icons/fi"; -import { 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 { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js"; @@ -49,12 +17,11 @@ 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 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 --- const mapStateToProps = createStructuredSelector({ @@ -247,6 +214,8 @@ function Header(props) { [handleMenuClick] ); // --- Menu Items --- + + // built externally to keep the component clean const accountingChildren = useMemo( () => buildAccountingChildren({ @@ -275,349 +244,18 @@ function Header(props) { ] ); + // Built externally to keep the component clean 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: "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") - } - ] - : []), - { - 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")} - } - ] - } - ], + () => + buildLeftMenuItems({ + t, + bodyshop, + recentItems, + setTaskUpsertContext, + setReportCenterContext, + signOutStart, + accountingChildren + }), [t, bodyshop, recentItems, setTaskUpsertContext, setReportCenterContext, signOutStart, accountingChildren] ); From 443ed717cbc3c2e74bf6c3f764f38308b376a0ca Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 8 Jul 2025 11:38:18 -0400 Subject: [PATCH 04/17] feature/IO-3291-Tasks-Notifications: Checkpoint --- .../components/header/header.component.jsx | 5 +- .../task-center/task-center.styles.scss | 137 ++++++++++++++---- 2 files changed, 113 insertions(+), 29 deletions(-) diff --git a/client/src/components/header/header.component.jsx b/client/src/components/header/header.component.jsx index fe73718f1..d1d6a44b1 100644 --- a/client/src/components/header/header.component.jsx +++ b/client/src/components/header/header.component.jsx @@ -169,6 +169,7 @@ function Header(props) { setTaskCenterVisible(false); // Close task center return; } + if (isTaskCenterClick) { setNotificationVisible(scenarioNotificationsOn ? false : notificationVisible); // Close notification center if enabled return; @@ -177,6 +178,7 @@ function Header(props) { if (taskCenterVisible && taskCenterRef.current && !taskCenterRef.current.contains(event.target)) { setTaskCenterVisible(false); } + if ( scenarioNotificationsOn && notificationVisible && @@ -213,9 +215,10 @@ function Header(props) { }, [handleMenuClick] ); + // --- Menu Items --- - // built externally to keep the component clean + // built externally to keep the component clean, but on this level to prevent unnecessary re-renders const accountingChildren = useMemo( () => buildAccountingChildren({ diff --git a/client/src/components/task-center/task-center.styles.scss b/client/src/components/task-center/task-center.styles.scss index e7b85a353..2970f4b39 100644 --- a/client/src/components/task-center/task-center.styles.scss +++ b/client/src/components/task-center/task-center.styles.scss @@ -2,65 +2,146 @@ position: absolute; top: 64px; right: 0; - z-index: 1000; width: 400px; - max-height: 500px; + max-width: 400px; background: #fff; - border-left: 1px solid #ccc; - box-shadow: -2px 2px 8px rgba(0, 0, 0, 0.1); - overflow: hidden; + 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 16px; + border-bottom: 1px solid #f0f0f0; display: flex; justify-content: space-between; align-items: center; - padding: 8px 12px; - background-color: #f5f5f5; - border-bottom: 1px solid #ddd; - } + background: #fafafa; - .task-toggle { - display: flex; - align-items: center; - gap: 4px; - - .anticon { + h3 { + margin: 0; font-size: 14px; + color: rgba(0, 0, 0, 0.85); + } + + .task-controls { + display: flex; + align-items: center; + gap: 8px; + + .task-toggle { + align-items: center; + + .anticon { + font-size: 14px; + color: #1677ff; + vertical-align: middle; + } + } + + .ant-switch { + &.ant-switch-small { + min-width: 28px; + height: 16px; + line-height: 16px; + + .ant-switch-handle { + width: 12px; + height: 12px; + } + + &.ant-switch-checked { + background-color: #1677ff; + + .ant-switch-handle { + left: calc(100% - 14px); + } + } + } + } + + .ant-btn-link { + padding: 0; + color: #1677ff; + + &:hover { + color: #69b1ff; + } + + &:disabled { + color: rgba(0, 0, 0, 0.25); + cursor: not-allowed; + } + } } } .task-item { - padding: 12px; + padding: 12px 16px; border-bottom: 1px solid #f0f0f0; + display: block; + overflow: visible; + width: 100%; + box-sizing: border-box; cursor: pointer; - transition: background-color 0.2s; &:hover { - background-color: #fafafa; + background: #fafafa; } &.task-completed { - opacity: 0.6; + background: #fff; + color: rgba(0, 0, 0, 0.65); } - .ro-number { - font-weight: bold; - margin-right: 8px; + &.task-incomplete { + background: #f5f5f5; + color: rgba(0, 0, 0, 0.85); + } + + .task-content { + width: 100%; + } + + .task-title { + margin: 0; + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + box-sizing: border-box; + + .ro-number { + margin: 0; + color: #1677ff; + flex-shrink: 0; + white-space: nowrap; + } + + .relative-time { + margin: 0; + font-size: 12px; + color: rgba(0, 0, 0, 0.45); + white-space: nowrap; + flex-shrink: 0; + margin-left: auto; + } } .task-body { - display: block; margin-top: 4px; - } - - .relative-time { - float: right; - font-size: 0.8em; + color: inherit; } } + + .ant-badge { + width: 100%; + } } From 9b53bd9b4034023527e3430d5b6d84841200bc5e Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 8 Jul 2025 12:29:23 -0400 Subject: [PATCH 05/17] feature/IO-3291-Tasks-Notifications: Checkpoint --- .../task-center/task-center.component.jsx | 14 ++++--- .../task-center/task-center.container.jsx | 21 +++++++--- .../task-list/task-list.component.jsx | 41 ++----------------- .../task-upsert-modal.component.jsx | 1 - .../src/pages/tasks/allTasksPageContainer.jsx | 2 +- client/src/utils/tasksPriorityLabel.jsx | 38 +++++++++++++++++ 6 files changed, 65 insertions(+), 52 deletions(-) create mode 100644 client/src/utils/tasksPriorityLabel.jsx diff --git a/client/src/components/task-center/task-center.component.jsx b/client/src/components/task-center/task-center.component.jsx index 697beed09..7fb07ef2c 100644 --- a/client/src/components/task-center/task-center.component.jsx +++ b/client/src/components/task-center/task-center.component.jsx @@ -1,4 +1,3 @@ -// 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"; @@ -6,12 +5,12 @@ 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 +import "./task-center.styles.scss"; const { Text, Title } = Typography; const TaskCenterComponent = forwardRef( - ({ visible, onClose, tasks, loading, showIncompleteOnly, toggleIncomplete, markAllComplete, onTaskClick }, ref) => { + ({ visible, tasks, loading, showIncompleteOnly, toggleIncomplete, markAllComplete, onTaskClick }, ref) => { const { t } = useTranslation(); const virtuosoRef = useRef(null); @@ -21,9 +20,12 @@ const TaskCenterComponent = forwardRef( } }, [showIncompleteOnly]); + // Filter tasks based on showIncompleteOnly + const filteredTasks = showIncompleteOnly ? tasks.filter((task) => !task.completed) : tasks; + const renderTask = (index, task) => { const handleClick = () => { - onTaskClick(task.id); + onTaskClick(task.id); // Use the prop handler }; return ( @@ -80,8 +82,8 @@ const TaskCenterComponent = forwardRef(
diff --git a/client/src/components/task-center/task-center.container.jsx b/client/src/components/task-center/task-center.container.jsx index 43fe077ef..dad0f97e7 100644 --- a/client/src/components/task-center/task-center.container.jsx +++ b/client/src/components/task-center/task-center.container.jsx @@ -1,4 +1,3 @@ -// 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"; @@ -10,6 +9,7 @@ import { useNotification } from "../../contexts/Notifications/notificationContex import { MUTATION_TOGGLE_TASK_COMPLETED, QUERY_MY_TASKS_PAGINATED } from "../../graphql/tasks.queries"; import TaskCenterComponent from "./task-center.component"; import dayjs from "../../utils/day"; +import { setModalContext } from "../../redux/modals/modals.actions"; // Import setModalContext const POLL_INTERVAL = 60; // seconds @@ -18,7 +18,11 @@ const mapStateToProps = createStructuredSelector({ currentUser: selectCurrentUser }); -const TaskCenterContainer = ({ visible, onClose, bodyshop, currentUser }) => { +const mapDispatchToProps = (dispatch) => ({ + setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" })) +}); + +const TaskCenterContainer = ({ visible, onClose, bodyshop, currentUser, setTaskUpsertContext }) => { const [tasks, setTasks] = useState([]); const [showIncompleteOnly, setShowIncompleteOnly] = useState(true); const [loading, setLoading] = useState(false); @@ -93,10 +97,15 @@ const TaskCenterContainer = ({ visible, onClose, bodyshop, currentUser }) => { (id) => { const task = tasks.find((t) => t.id === id); if (task) { - window.location.href = `/manage/jobs/${task.jobid}`; + setTaskUpsertContext({ + context: { + taskId: task.id, + view: true + } + }); } }, - [tasks] + [tasks, setModalContext] ); return ( @@ -108,9 +117,9 @@ const TaskCenterContainer = ({ visible, onClose, bodyshop, currentUser }) => { showIncompleteOnly={showIncompleteOnly} toggleIncomplete={handleToggleIncomplete} markAllComplete={handleMarkAllComplete} - onTaskClick={handleTaskClick} + onTaskClick={handleTaskClick} // Pass the updated handler /> ); }; -export default connect(mapStateToProps)(TaskCenterContainer); +export default connect(mapStateToProps, mapDispatchToProps)(TaskCenterContainer); diff --git a/client/src/components/task-list/task-list.component.jsx b/client/src/components/task-list/task-list.component.jsx index 3258f29a2..96a587908 100644 --- a/client/src/components/task-list/task-list.component.jsx +++ b/client/src/components/task-list/task-list.component.jsx @@ -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 ( -
- High -
- ); - case 2: - return ( -
- Medium -
- ); - case 3: - return ( -
- Low -
- ); - default: - return ( -
- None -
- ); - } -}; - const mapDispatchToProps = (dispatch) => ({ // Existing dispatch props... setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" })) }); -const mapStateToProps = (state) => ({}); +const mapStateToProps = () => ({}); export default connect(mapStateToProps, mapDispatchToProps)(TaskListComponent); diff --git a/client/src/components/task-upsert-modal/task-upsert-modal.component.jsx b/client/src/components/task-upsert-modal/task-upsert-modal.component.jsx index 562141a40..6e1f8209b 100644 --- a/client/src/components/task-upsert-modal/task-upsert-modal.component.jsx +++ b/client/src/components/task-upsert-modal/task-upsert-modal.component.jsx @@ -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"; diff --git a/client/src/pages/tasks/allTasksPageContainer.jsx b/client/src/pages/tasks/allTasksPageContainer.jsx index 4b63347cb..3979a961e 100644 --- a/client/src/pages/tasks/allTasksPageContainer.jsx +++ b/client/src/pages/tasks/allTasksPageContainer.jsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import TasksPageComponent from "./tasks.page.component"; import queryString from "query-string"; diff --git a/client/src/utils/tasksPriorityLabel.jsx b/client/src/utils/tasksPriorityLabel.jsx new file mode 100644 index 000000000..a33b557f3 --- /dev/null +++ b/client/src/utils/tasksPriorityLabel.jsx @@ -0,0 +1,38 @@ +import { ExclamationCircleFilled } from "@ant-design/icons"; + +/** + * Priority Label Component + * @param priority + * @returns {Element} + * @constructor + */ +const PriorityLabel = ({ priority }) => { + switch (priority) { + case 1: + return ( +
+ High +
+ ); + case 2: + return ( +
+ Medium +
+ ); + case 3: + return ( +
+ Low +
+ ); + default: + return ( +
+ None +
+ ); + } +}; + +export default PriorityLabel; From 2e3944099bc42e99179a14eabb994dd18878cb59 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 8 Jul 2025 13:52:59 -0400 Subject: [PATCH 06/17] feature/IO-3291-Tasks-Notifications: Checkpoint --- .../task-center/task-center.component.jsx | 141 +++++++++--------- .../task-center/task-center.container.jsx | 79 ++++------ .../task-center/task-center.styles.scss | 77 ++-------- client/src/graphql/tasks.queries.js | 21 +++ 4 files changed, 133 insertions(+), 185 deletions(-) diff --git a/client/src/components/task-center/task-center.component.jsx b/client/src/components/task-center/task-center.component.jsx index 7fb07ef2c..1b8587623 100644 --- a/client/src/components/task-center/task-center.component.jsx +++ b/client/src/components/task-center/task-center.component.jsx @@ -1,95 +1,88 @@ 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 { Badge, Spin, Typography } from "antd"; import { useTranslation } from "react-i18next"; -import { forwardRef, useEffect, useRef } from "react"; +import { forwardRef, useMemo, useRef } from "react"; import { DateTimeFormat } from "../../utils/DateFormatter.jsx"; import day from "../../utils/day.js"; import "./task-center.styles.scss"; const { Text, Title } = Typography; -const TaskCenterComponent = forwardRef( - ({ visible, tasks, loading, showIncompleteOnly, toggleIncomplete, markAllComplete, onTaskClick }, ref) => { - const { t } = useTranslation(); - const virtuosoRef = useRef(null); +const TaskCenterComponent = forwardRef(({ visible, tasks, loading, onTaskClick }, ref) => { + const { t } = useTranslation(); + const virtuosoRef = useRef(null); - useEffect(() => { - if (virtuosoRef.current) { - virtuosoRef.current.scrollToIndex({ index: 0, behavior: "smooth" }); - } - }, [showIncompleteOnly]); + // Organize tasks into sections + const sections = useMemo(() => { + const now = day(); + const today = now.startOf("day"); - // Filter tasks based on showIncompleteOnly - const filteredTasks = showIncompleteOnly ? tasks.filter((task) => !task.completed) : tasks; + const overdue = tasks.filter((task) => task.due_date && day(task.due_date).isBefore(today)); + const dueToday = tasks.filter((task) => task.due_date && day(task.due_date).isSame(today, "day")); + const upcoming = tasks.filter((task) => task.due_date && day(task.due_date).isAfter(today)); + const noDueDate = tasks.filter((task) => !task.due_date); - const renderTask = (index, task) => { - const handleClick = () => { - onTaskClick(task.id); // Use the prop handler - }; + return [ + { title: t("tasks.labels.overdue"), data: overdue }, + { title: t("tasks.labels.due_today"), data: dueToday }, + { title: t("tasks.labels.upcoming"), data: upcoming }, + { title: t("tasks.labels.no_due_date"), data: noDueDate } + ].filter((section) => section.data.length > 0); + }, [tasks, t]); - 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} - -
-
-
- ); + const renderTask = (index, task) => { + const handleClick = () => { + onTaskClick(task.id); }; return ( -
-
- -

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

- {loading && } -
-
- - - {showIncompleteOnly ? : } - toggleIncomplete(checked)} size="small" /> - - - -
); - } -); + }; + + const renderSection = (section, sectionIndex) => ( +
+ + {section.title} + + {section.data.map((task, index) => renderTask(`${sectionIndex}-${index}`, task))} +
+ ); + + return ( +
+
+

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

+ {loading && } +
+ renderSection(section, index)} + /> +
+ ); +}); TaskCenterComponent.displayName = "TaskCenterComponent"; diff --git a/client/src/components/task-center/task-center.container.jsx b/client/src/components/task-center/task-center.container.jsx index dad0f97e7..cfecedf75 100644 --- a/client/src/components/task-center/task-center.container.jsx +++ b/client/src/components/task-center/task-center.container.jsx @@ -1,15 +1,14 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { useMutation, useQuery } from "@apollo/client"; +import { 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 { QUERY_MY_TASKS_PAGINATED } from "../../graphql/tasks.queries"; import TaskCenterComponent from "./task-center.component"; import dayjs from "../../utils/day"; -import { setModalContext } from "../../redux/modals/modals.actions"; // Import setModalContext +import { setModalContext } from "../../redux/modals/modals.actions"; const POLL_INTERVAL = 60; // seconds @@ -24,20 +23,28 @@ const mapDispatchToProps = (dispatch) => ({ const TaskCenterContainer = ({ visible, onClose, bodyshop, currentUser, setTaskUpsertContext }) => { 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 { + // Compute assignedToId with useMemo to ensure stability + const assignedToId = useMemo(() => { + const employee = bodyshop?.employees?.find((e) => e.user_email === currentUser?.email); + if (employee?.id) { + console.log("AssignedToId computed:", employee.id); // Debug log + return employee.id; + } + return null; + }, [bodyshop, currentUser]); + + const where = useMemo( + () => ({ assigned_to: { _eq: assignedToId }, deleted: { _eq: false }, - ...(showIncompleteOnly ? { completed: { _eq: false } } : {}) - }; - }, [assignedToId, showIncompleteOnly]); + completed: { _eq: false } + }), + [assignedToId] + ); const { data, @@ -50,49 +57,24 @@ const TaskCenterContainer = ({ visible, onClose, bodyshop, currentUser, setTaskU where, offset: 0, limit: 50, - order: [{ created_at: "desc" }] + order: [{ due_date: "asc_nulls_last" }, { created_at: "desc" }] }, - skip: !bodyshop?.id || !assignedToId || !isEmployee, + // Skip query if any required data is missing + skip: !bodyshop?.id || !assignedToId || !isEmployee || !currentUser?.email, fetchPolicy: "cache-and-network", - pollInterval: isConnected ? 0 : dayjs.duration(POLL_INTERVAL, "seconds").asMilliseconds() + pollInterval: isConnected ? 0 : dayjs.duration(POLL_INTERVAL, "seconds").asMilliseconds(), + // Log errors for debugging + onError: (error) => { + console.error("Query error:", error); + } }); - 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); @@ -105,7 +87,7 @@ const TaskCenterContainer = ({ visible, onClose, bodyshop, currentUser, setTaskU }); } }, - [tasks, setModalContext] + [tasks, setTaskUpsertContext] ); return ( @@ -114,10 +96,7 @@ const TaskCenterContainer = ({ visible, onClose, bodyshop, currentUser, setTaskU onClose={onClose} tasks={tasks} loading={loading || queryLoading} - showIncompleteOnly={showIncompleteOnly} - toggleIncomplete={handleToggleIncomplete} - markAllComplete={handleMarkAllComplete} - onTaskClick={handleTaskClick} // Pass the updated handler + onTaskClick={handleTaskClick} /> ); }; diff --git a/client/src/components/task-center/task-center.styles.scss b/client/src/components/task-center/task-center.styles.scss index 2970f4b39..0f62c1306 100644 --- a/client/src/components/task-center/task-center.styles.scss +++ b/client/src/components/task-center/task-center.styles.scss @@ -18,7 +18,7 @@ } .task-header { - padding: 4px 16px; + padding: 8px 16px; border-bottom: 1px solid #f0f0f0; display: flex; justify-content: space-between; @@ -30,57 +30,20 @@ font-size: 14px; color: rgba(0, 0, 0, 0.85); } + } - .task-controls { - display: flex; - align-items: center; - gap: 8px; + .task-section { + margin-bottom: 8px; + } - .task-toggle { - align-items: center; - - .anticon { - font-size: 14px; - color: #1677ff; - vertical-align: middle; - } - } - - .ant-switch { - &.ant-switch-small { - min-width: 28px; - height: 16px; - line-height: 16px; - - .ant-switch-handle { - width: 12px; - height: 12px; - } - - &.ant-switch-checked { - background-color: #1677ff; - - .ant-switch-handle { - left: calc(100% - 14px); - } - } - } - } - - .ant-btn-link { - padding: 0; - color: #1677ff; - - &:hover { - color: #69b1ff; - } - - &:disabled { - color: rgba(0, 0, 0, 0.25); - cursor: not-allowed; - } - } - } + .section-title { + padding: 8px 16px; + background: #f5f5f5; + margin: 0; + font-size: 14px; + color: rgba(0, 0, 0, 0.85); + font-weight: 500; + border-bottom: 1px solid #e8e8e8; } .task-item { @@ -91,21 +54,12 @@ width: 100%; box-sizing: border-box; cursor: pointer; + background: #fff; &:hover { background: #fafafa; } - &.task-completed { - background: #fff; - color: rgba(0, 0, 0, 0.65); - } - - &.task-incomplete { - background: #f5f5f5; - color: rgba(0, 0, 0, 0.85); - } - .task-content { width: 100%; } @@ -137,7 +91,8 @@ .task-body { margin-top: 4px; - color: inherit; + color: rgba(0, 0, 0, 0.85); + font-weight: 500; } } diff --git a/client/src/graphql/tasks.queries.js b/client/src/graphql/tasks.queries.js index 554c21a18..bd4f8e3e8 100644 --- a/client/src/graphql/tasks.queries.js +++ b/client/src/graphql/tasks.queries.js @@ -287,6 +287,27 @@ export const QUERY_JOB_TASKS_PAGINATED = gql` } `; +export const QUERY_MY_COMPLETE_TASKS_PAGINATED = gql` + ${PARTIAL_TASK_FIELDS} + query QUERY_MY_TASKS_PAGINATED( + $offset: Int + $limit: Int + $assigned_to: uuid! + $bodyshop: uuid! + $where: tasks_bool_exp + $order: [tasks_order_by!]! + ) { + tasks(offset: $offset, limit: $limit, order_by: $order, where: $where) { + ...TaskFields + } + tasks_aggregate(where: $where) { + aggregate { + count + } + } + } +`; + export const QUERY_MY_TASKS_PAGINATED = gql` ${PARTIAL_TASK_FIELDS} query QUERY_MY_TASKS_PAGINATED( From 9ab2fdc868ad91d9f8e65ea13faf3ebb2a0a9915 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Wed, 9 Jul 2025 11:14:04 -0400 Subject: [PATCH 07/17] feature/IO-3291-Tasks-Notifications: Checkpoint --- .../task-center/task-center.component.jsx | 8 ++- .../task-center/task-center.container.jsx | 49 +++++++++---------- .../task-upsert-modal.component.jsx | 28 ++++++++--- .../task-upsert-modal.container.jsx | 2 +- client/src/graphql/tasks.queries.js | 37 ++++++++++++++ 5 files changed, 88 insertions(+), 36 deletions(-) diff --git a/client/src/components/task-center/task-center.component.jsx b/client/src/components/task-center/task-center.component.jsx index 1b8587623..da4ebaf52 100644 --- a/client/src/components/task-center/task-center.component.jsx +++ b/client/src/components/task-center/task-center.component.jsx @@ -8,11 +8,10 @@ import "./task-center.styles.scss"; const { Text, Title } = Typography; -const TaskCenterComponent = forwardRef(({ visible, tasks, loading, onTaskClick }, ref) => { +const TaskCenterComponent = forwardRef(({ visible, tasks, loading, onTaskClick, onLoadMore, totalTasks }, ref) => { const { t } = useTranslation(); const virtuosoRef = useRef(null); - // Organize tasks into sections const sections = useMemo(() => { const now = day(); const today = now.startOf("day"); @@ -80,6 +79,11 @@ const TaskCenterComponent = forwardRef(({ visible, tasks, loading, onTaskClick } totalCount={sections.length} itemContent={(index, section) => renderSection(section, index)} /> + {tasks.length < totalTasks && ( + + )}
); }); diff --git a/client/src/components/task-center/task-center.container.jsx b/client/src/components/task-center/task-center.container.jsx index cfecedf75..e51cce810 100644 --- a/client/src/components/task-center/task-center.container.jsx +++ b/client/src/components/task-center/task-center.container.jsx @@ -5,10 +5,10 @@ import { createStructuredSelector } from "reselect"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { useSocket } from "../../contexts/SocketIO/useSocket"; import { useIsEmployee } from "../../utils/useIsEmployee"; -import { QUERY_MY_TASKS_PAGINATED } from "../../graphql/tasks.queries"; import TaskCenterComponent from "./task-center.component"; import dayjs from "../../utils/day"; import { setModalContext } from "../../redux/modals/modals.actions"; +import { QUERY_MY_ACTIVE_TASKS_PAGINATED } from "../../graphql/tasks.queries"; const POLL_INTERVAL = 60; // seconds @@ -23,47 +23,29 @@ const mapDispatchToProps = (dispatch) => ({ const TaskCenterContainer = ({ visible, onClose, bodyshop, currentUser, setTaskUpsertContext }) => { const [tasks, setTasks] = useState([]); - const [loading, setLoading] = useState(false); const { isConnected } = useSocket(); const isEmployee = useIsEmployee(bodyshop, currentUser); - // Compute assignedToId with useMemo to ensure stability const assignedToId = useMemo(() => { const employee = bodyshop?.employees?.find((e) => e.user_email === currentUser?.email); - if (employee?.id) { - console.log("AssignedToId computed:", employee.id); // Debug log - return employee.id; - } - return null; + return employee?.id || null; }, [bodyshop, currentUser]); - const where = useMemo( - () => ({ - assigned_to: { _eq: assignedToId }, - deleted: { _eq: false }, - completed: { _eq: false } - }), - [assignedToId] - ); - const { data, loading: queryLoading, - refetch - } = useQuery(QUERY_MY_TASKS_PAGINATED, { + fetchMore + } = useQuery(QUERY_MY_ACTIVE_TASKS_PAGINATED, { variables: { bodyshop: bodyshop?.id, assigned_to: assignedToId, - where, offset: 0, limit: 50, order: [{ due_date: "asc_nulls_last" }, { created_at: "desc" }] }, - // Skip query if any required data is missing skip: !bodyshop?.id || !assignedToId || !isEmployee || !currentUser?.email, fetchPolicy: "cache-and-network", pollInterval: isConnected ? 0 : dayjs.duration(POLL_INTERVAL, "seconds").asMilliseconds(), - // Log errors for debugging onError: (error) => { console.error("Query error:", error); } @@ -75,14 +57,28 @@ const TaskCenterContainer = ({ visible, onClose, bodyshop, currentUser, setTaskU } }, [data]); + const handleLoadMore = () => { + fetchMore({ + variables: { + offset: tasks.length + }, + updateQuery: (prev, { fetchMoreResult }) => { + if (!fetchMoreResult) return prev; + return { + ...prev, + tasks: [...prev.tasks, ...fetchMoreResult.tasks] + }; + } + }); + }; + const handleTaskClick = useCallback( (id) => { const task = tasks.find((t) => t.id === id); if (task) { setTaskUpsertContext({ context: { - taskId: task.id, - view: true + existingTask: task } }); } @@ -95,10 +91,11 @@ const TaskCenterContainer = ({ visible, onClose, bodyshop, currentUser, setTaskU visible={visible} onClose={onClose} tasks={tasks} - loading={loading || queryLoading} + loading={queryLoading} onTaskClick={handleTaskClick} + onLoadMore={handleLoadMore} + totalTasks={data?.tasks_aggregate?.aggregate?.count} /> ); }; - export default connect(mapStateToProps, mapDispatchToProps)(TaskCenterContainer); diff --git a/client/src/components/task-upsert-modal/task-upsert-modal.component.jsx b/client/src/components/task-upsert-modal/task-upsert-modal.component.jsx index 6e1f8209b..0c6482c9d 100644 --- a/client/src/components/task-upsert-modal/task-upsert-modal.component.jsx +++ b/client/src/components/task-upsert-modal/task-upsert-modal.component.jsx @@ -7,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, @@ -41,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) { @@ -96,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(); }; @@ -162,6 +158,13 @@ export function TaskUpsertModalComponent({ required: true } ]} + extra={ + existingTask && selectedJobId ? ( +
+ {t("tasks.labels.go_to_job")} +
+ ) : null + } > - + + {t("tasks.links.go_to_bill")} ( + {selectedJobDetails?.bills?.find((bill) => bill.id === form.getFieldValue("billid"))?.invoice_number}) + + ) : null + } + >