From 49bfb0849d82717c69593d298062c0fd77d394f0 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 21 Jan 2026 17:55:57 -0500 Subject: [PATCH] feature/IO-3499-React-19 Checkpoint --- .../job-watcher-toggle.container.jsx | 140 +++++------ .../jobs-detail-header-actions.component.jsx | 238 ++++++++++++------ .../jobs-documents-gallery.container.jsx | 2 +- 3 files changed, 226 insertions(+), 154 deletions(-) diff --git a/client/src/components/job-watcher-toggle/job-watcher-toggle.container.jsx b/client/src/components/job-watcher-toggle/job-watcher-toggle.container.jsx index 61a007c65..dbd4915c6 100644 --- a/client/src/components/job-watcher-toggle/job-watcher-toggle.container.jsx +++ b/client/src/components/job-watcher-toggle/job-watcher-toggle.container.jsx @@ -8,6 +8,8 @@ import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx"; import { useIsEmployee } from "../../utils/useIsEmployee.js"; +const EMPTY_ARRAY = Object.freeze([]); + const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, currentUser: selectCurrentUser @@ -27,21 +29,24 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) { const [selectedWatcher, setSelectedWatcher] = useState(null); const [selectedTeam, setSelectedTeam] = useState(null); - const userEmail = currentUser.email; - const jobid = job.id; + const userEmail = currentUser?.email; + const jobid = job?.id; + const watcherVars = useMemo(() => ({ jobid }), [jobid]); - // Fetch current watchers with refetch capability const { data: watcherData, loading: watcherLoading, refetch } = useQuery(GET_JOB_WATCHERS, { - variables: { jobid }, - fetchPolicy: "cache-and-network" // Ensure fresh data from server + variables: watcherVars, + skip: !jobid, + fetchPolicy: "cache-first", + notifiyOnNetworkStatusChange: true }); - // Refetch jobWatchers when the popover opens (open changes to true) + // Refetch jobWatchers when the popover opens useEffect(() => { + if (!jobid) return; if (open) { refetch().catch((err) => console.error(`Something went wrong fetching Notification Watchers on popover open: ${err?.message}`, { @@ -49,18 +54,17 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) { }) ); } - }, [open, refetch]); + }, [open, refetch, jobid]); - const jobWatchers = useMemo(() => (watcherData?.job_watchers ? [...watcherData.job_watchers] : []), [watcherData]); - const isWatching = jobWatchers.some((w) => w.user_email === userEmail); + // Do NOT clone arrays; keep referential stability for React Compiler and to reduce rerenders. + const jobWatchers = watcherData?.job_watchers ?? EMPTY_ARRAY; + const watcherEmailSet = useMemo( + () => new Set((jobWatchers ?? EMPTY_ARRAY).map((w) => w?.user_email).filter(Boolean)), + [jobWatchers] + ); + const isWatching = !!userEmail && watcherEmailSet.has(userEmail); const [addWatcher, { loading: adding }] = useMutation(ADD_JOB_WATCHER, { - onCompleted: () => - refetch().catch((err) => - console.error(`Something went wrong fetching Notification Watchers after add: ${err?.message}`, { - stack: err?.stack - }) - ), onError: (err) => { if (err.graphQLErrors && err.graphQLErrors.length > 0) { const errorMessage = err.graphQLErrors[0].message; @@ -69,12 +73,13 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) { errorMessage.includes("idx_job_watchers_jobid_user_email_unique") ) { console.warn("Watcher already exists for this job and user."); - refetch().catch((err) => + // Only refetch for this edge case to ensure UI is accurate + refetch().catch((e) => console.error( - `Something went wrong fetching Notification Watchers after uniqueness violation: ${err?.message}`, - { stack: err?.stack } + `Something went wrong fetching Notification Watchers after uniqueness violation: ${e?.message}`, + { stack: e?.stack } ) - ); // Sync with server to ensure UI reflects actual state + ); } else { console.error(`Error adding job watcher: ${errorMessage}`); } @@ -83,65 +88,41 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) { } }, update(cache, { data }) { - if (!data || !data.insert_job_watchers_one) { - console.warn("No data or insert_job_watchers_one returned from mutation, skipping cache update."); - refetch().catch((err) => - console.error(`Something went wrong updating Notification Watchers after add: ${err?.message}`, { - stack: err?.stack - }) - ); - return; - } + const inserted = data?.insert_job_watchers_one; + if (!inserted) return; - const insert_job_watchers_one = data.insert_job_watchers_one; - const existingData = cache.readQuery({ - query: GET_JOB_WATCHERS, - variables: { jobid } - }); + cache.updateQuery({ query: GET_JOB_WATCHERS, variables: watcherVars }, (existing) => { + const prev = existing?.job_watchers ?? []; + if (prev.some((w) => w.user_email === inserted.user_email)) return existing; - cache.writeQuery({ - query: GET_JOB_WATCHERS, - variables: { jobid }, - data: { - ...existingData, - job_watchers: [...(existingData?.job_watchers || []), insert_job_watchers_one] - } + return { + ...existing, + job_watchers: [...prev, inserted] + }; }); } }); const [removeWatcher, { loading: removing }] = useMutation(REMOVE_JOB_WATCHER, { - onCompleted: () => - refetch().catch((err) => - console.error(`Something went wrong fetching Notification Watchers after remove: ${err?.message}`, { - stack: err?.stack - }) - ), // Refetch to sync with server after success onError: (err) => console.error(`Error removing job watcher: ${err.message}`), - update(cache, { data: { delete_job_watchers } }) { - const existingData = cache.readQuery({ - query: GET_JOB_WATCHERS, - variables: { jobid } - }); + update(cache, { data }) { + const deleted = data?.delete_job_watchers?.returning?.[0]; + if (!deleted?.user_email) return; - const deletedWatcher = delete_job_watchers.returning[0]; - const updatedWatchers = deletedWatcher - ? (existingData?.job_watchers || []).filter((watcher) => watcher.user_email !== deletedWatcher.user_email) - : existingData?.job_watchers || []; - - cache.writeQuery({ - query: GET_JOB_WATCHERS, - variables: { jobid }, - data: { - ...existingData, - job_watchers: updatedWatchers - } + cache.updateQuery({ query: GET_JOB_WATCHERS, variables: watcherVars }, (existing) => { + const prev = existing?.job_watchers ?? []; + return { + ...existing, + job_watchers: prev.filter((w) => w.user_email !== deleted.user_email) + }; }); } }); const handleToggleSelf = useCallback(async () => { + if (!jobid || !userEmail) return; if (adding || removing || !isEmployee) return; + if (isWatching) { await removeWatcher({ variables: { jobid, userEmail } }); } else { @@ -151,7 +132,9 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) { const handleRemoveWatcher = useCallback( async (email) => { + if (!jobid) return; if (removing) return; + if (!email) return; await removeWatcher({ variables: { jobid, userEmail: email } }); }, [removeWatcher, jobid, removing] @@ -159,36 +142,45 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) { const handleWatcherSelect = useCallback( async (selectedUser) => { + if (!jobid) return; if (adding || removing) return; - const employee = bodyshop.employees.find((e) => e.id === selectedUser); - if (!employee) return; + + const employee = bodyshop?.employees?.find((e) => e.id === selectedUser); + if (!employee?.user_email) return; const email = employee.user_email; - const isAlreadyWatching = jobWatchers.some((w) => w.user_email === email); + const isAlreadyWatching = watcherEmailSet.has(email); if (isAlreadyWatching) { await handleRemoveWatcher(email); } else { await addWatcher({ variables: { jobid, userEmail: email } }); } + setSelectedWatcher(null); }, - [jobWatchers, addWatcher, handleRemoveWatcher, jobid, bodyshop, adding, removing] + [watcherEmailSet, addWatcher, handleRemoveWatcher, jobid, bodyshop, adding, removing] ); const handleTeamSelect = useCallback( async (team) => { + if (!jobid) return; if (adding) return; - const selectedTeamMembers = JSON.parse(team); - const newWatchers = selectedTeamMembers.filter( - (email) => !jobWatchers.some((watcher) => watcher.user_email === email) - ); + let selectedTeamMembers = []; + try { + selectedTeamMembers = Array.isArray(team) ? team : JSON.parse(team); + } catch { + selectedTeamMembers = []; + } + + const newWatchers = (selectedTeamMembers ?? []).filter(Boolean).filter((email) => !watcherEmailSet.has(email)); if (newWatchers.length === 0) { console.warn("All selected team members are already watchers."); setSelectedTeam(null); return; } + await Promise.all( newWatchers.map((email) => addWatcher({ @@ -199,8 +191,10 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) { }) ) ); + + setSelectedTeam(null); }, - [jobWatchers, addWatcher, jobid, adding] + [watcherEmailSet, addWatcher, jobid, adding] ); return ( @@ -223,7 +217,7 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) { handleWatcherSelect={handleWatcherSelect} handleTeamSelect={handleTeamSelect} currentUser={currentUser} - isEmployee={isEmployee} // Pass isEmployee to the component + isEmployee={isEmployee} /> ); } diff --git a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx index c5ea1a308..8e62c5988 100644 --- a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx +++ b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx @@ -1,10 +1,10 @@ import { DownCircleFilled } from "@ant-design/icons"; -import { useApolloClient, useMutation } from "@apollo/client/react"; +import { useApolloClient, useMutation, useQuery } from "@apollo/client/react"; import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { Button, Card, Dropdown, Form, Input, Modal, Popconfirm, Popover, Select, Space } from "antd"; import axios from "axios"; import parsePhoneNumber from "libphonenumber-js"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { Link, useNavigate } from "react-router-dom"; @@ -14,7 +14,7 @@ import { useSocket } from "../../contexts/SocketIO/useSocket.js"; import { auth, logImEXEvent } from "../../firebase/firebase.utils"; import { CANCEL_APPOINTMENTS_BY_JOB_ID, INSERT_MANUAL_APPT } from "../../graphql/appointments.queries"; import { GET_CURRENT_QUESTIONSET_ID, INSERT_CSI } from "../../graphql/csi.queries"; -import { DELETE_JOB, UPDATE_JOB, VOID_JOB } from "../../graphql/jobs.queries"; +import { DELETE_JOB, GET_JOB_WATCHERS, UPDATE_JOB, VOID_JOB } from "../../graphql/jobs.queries"; import { insertAuditTrail } from "../../redux/application/application.actions"; import { selectJobReadOnly } from "../../redux/application/application.selectors"; import { setEmailOptions } from "../../redux/email/email.actions"; @@ -34,6 +34,8 @@ import AddToProduction from "./jobs-detail-header-actions.addtoproduction.util"; import DuplicateJob from "./jobs-detail-header-actions.duplicate.util"; import JobsDetailHeaderActionsToggleProduction from "./jobs-detail-header-actions.toggle-production"; +const EMPTY_ARRAY = Object.freeze([]); + const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, jobRO: selectJobReadOnly, @@ -123,6 +125,14 @@ export function JobsDetailHeaderActions({ const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); + const [visibility, setVisibility] = useState(false); + const jobId = job?.id; + const watcherVars = useMemo(() => ({ jobid: jobId }), [jobId]); + + // Option A: coordinated Dropdown + Popconfirm open state so the menu doesn't unmount before Popconfirm renders. + const [confirmKey, setConfirmKey] = useState(null); + const confirmKeyRef = useRef(null); + const [isCancelScheduleModalVisible, setIsCancelScheduleModalVisible] = useState(false); const [insertAppointment] = useMutation(INSERT_MANUAL_APPT); const [deleteJob] = useMutation(DELETE_JOB); @@ -143,6 +153,15 @@ export function JobsDetailHeaderActions({ const hasValidEmail = (emails) => emails.some((email) => userEmail.endsWith(email)); const canSubmitForTesting = (isDevEnv && hasValidEmail(devEmails)) || (isProdEnv && hasValidEmail(prodEmails)); + const { data: jobWatchersData } = useQuery(GET_JOB_WATCHERS, { + variables: watcherVars, + skip: !jobId, + fetchPolicy: "cache-first", + notifyOnNetworkStatusChange: true + }); + + const jobWatchersCount = jobWatchersData?.job_watchers?.length ?? job?.job_watchers?.length ?? 0; + const { treatments: { ImEXPay } } = useTreatmentsWithConfig({ @@ -151,18 +170,85 @@ export function JobsDetailHeaderActions({ splitKey: bodyshop && bodyshop.imexshopid }); - const jobInProduction = useMemo(() => { - return bodyshop.md_ro_statuses.production_statuses.includes(job.status); - }, [job, bodyshop.md_ro_statuses.production_statuses]); - const [visibility, setVisibility] = useState(false); + const productionStatuses = bodyshop?.md_ro_statuses?.production_statuses ?? EMPTY_ARRAY; + const preProductionStatuses = bodyshop?.md_ro_statuses?.pre_production_statuses ?? EMPTY_ARRAY; + const postProductionStatuses = bodyshop?.md_ro_statuses?.post_production_statuses ?? EMPTY_ARRAY; + const jobStatus = job?.status; - const jobInPreProduction = useMemo(() => { - return bodyshop.md_ro_statuses.pre_production_statuses.includes(job.status); - }, [job.status, bodyshop.md_ro_statuses.pre_production_statuses]); + const jobInProduction = productionStatuses.includes(jobStatus); + const jobInPreProduction = preProductionStatuses.includes(jobStatus); + const jobInPostProduction = postProductionStatuses.includes(jobStatus); - const jobInPostProduction = useMemo(() => { - return bodyshop.md_ro_statuses.post_production_statuses.includes(job.status); - }, [job.status, bodyshop.md_ro_statuses.post_production_statuses]); + const openConfirm = useCallback((key) => { + confirmKeyRef.current = key; + setConfirmKey(key); + setDropdownOpen(true); + }, []); + + const closeConfirm = useCallback(() => { + confirmKeyRef.current = null; + setConfirmKey(null); + }, []); + + const handleDropdownOpenChange = useCallback( + (nextOpen, info) => { + if (!nextOpen && info?.source === "menu" && confirmKeyRef.current) return; + setDropdownOpen(nextOpen); + if (!nextOpen) closeConfirm(); + }, + [closeConfirm] + ); + + const renderPopconfirmMenuLabel = ({ + key, + text, + title, + okText, + cancelText, + showCancel = true, + closeDropdownOnConfirm = true, + onConfirm + }) => ( + { + if (nextOpen) openConfirm(key); + else closeConfirm(); + }} + onConfirm={(e) => { + e?.stopPropagation?.(); + closeConfirm(); + + // Critical: for informational popconfirms, keep the dropdown open so the Popconfirm can cleanly close. + if (closeDropdownOnConfirm) { + setDropdownOpen(false); + } + + onConfirm?.(e); + }} + onCancel={(e) => { + e?.stopPropagation?.(); + closeConfirm(); + // Keep dropdown open on cancel so the user can continue using the menu. + }} + getPopupContainer={() => document.body} + > +
{ + e.preventDefault(); + e.stopPropagation(); + openConfirm(key); + }} + > + {text} +
+
+ ); // Function to show modal const showCancelScheduleModal = () => { @@ -549,12 +635,6 @@ export function JobsDetailHeaderActions({ }); }; - // Function to handle OK - const handleCancelScheduleOK = async () => { - await form.submit(); // Assuming 'form' is the Form instance from useForm() - setIsCancelScheduleModalVisible(false); - }; - const handleLostSaleFinish = async ({ lost_sale_reason }) => { const jobUpdate = await cancelAllAppointments({ variables: { @@ -884,34 +964,26 @@ export function JobsDetailHeaderActions({ { key: "duplicate", id: "job-actions-duplicate", - label: ( - e.stopPropagation()} - onConfirm={handleDuplicate} - getPopupContainer={(trigger) => trigger.parentNode} - > - {t("menus.jobsactions.duplicate")} - - ) + label: renderPopconfirmMenuLabel({ + key: "confirm-duplicate", + text: t("menus.jobsactions.duplicate"), + title: t("jobs.labels.duplicateconfirm"), + okText: t("general.labels.yes"), + cancelText: t("general.labels.no"), + onConfirm: handleDuplicate + }) }, { key: "duplicatenolines", id: "job-actions-duplicatenolines", - label: ( - e.stopPropagation()} - onConfirm={handleDuplicateConfirm} - getPopupContainer={(trigger) => trigger.parentNode} - > - {t("menus.jobsactions.duplicatenolines")} - - ) + label: renderPopconfirmMenuLabel({ + key: "confirm-duplicate-nolines", + text: t("menus.jobsactions.duplicatenolines"), + title: t("jobs.labels.duplicateconfirm"), + okText: t("general.labels.yes"), + cancelText: t("general.labels.no"), + onConfirm: handleDuplicateConfirm + }) } ] }, @@ -1085,21 +1157,25 @@ export function JobsDetailHeaderActions({ key: "deletejob", id: "job-actions-deletejob", label: - job.job_watchers.length === 0 ? ( - e.stopPropagation()} - onConfirm={handleDeleteJob} - > - {t("menus.jobsactions.deletejob")} - - ) : ( - e.stopPropagation()} showCancel={false}> - {t("menus.jobsactions.deletejob")} - - ) + jobWatchersCount === 0 + ? renderPopconfirmMenuLabel({ + key: "confirm-deletejob", + text: t("menus.jobsactions.deletejob"), + title: t("jobs.labels.deleteconfirm"), + okText: t("general.labels.yes"), + cancelText: t("general.labels.no"), + onConfirm: handleDeleteJob + }) + : renderPopconfirmMenuLabel({ + key: "confirm-deletejob-watchers", + text: t("menus.jobsactions.deletejob"), + title: t("jobs.labels.deletewatchers"), + showCancel: false, + closeDropdownOnConfirm: false, // <-- FIX: keep dropdown mounted so Popconfirm can close cleanly + onConfirm: () => { + // informational confirm only + } + }) }); } @@ -1118,15 +1194,14 @@ export function JobsDetailHeaderActions({ id: "job-actions-voidjob", label: ( - e.stopPropagation()} - onConfirm={handleVoidJob} - > - {t("menus.jobsactions.void")} - + {renderPopconfirmMenuLabel({ + key: "confirm-voidjob", + text: t("menus.jobsactions.void"), + title: t("jobs.labels.voidjob"), + okText: t("general.labels.yes"), + cancelText: t("general.labels.no"), + onConfirm: handleVoidJob + })} ) }); @@ -1163,20 +1238,12 @@ export function JobsDetailHeaderActions({ {t("general.actions.cancel")} , - ]} @@ -1184,9 +1251,12 @@ export function JobsDetailHeaderActions({
{ - console.log(s); - handleLostSaleFinish(s); + onFinish={async (s) => { + try { + await handleLostSaleFinish(s); + } finally { + setIsCancelScheduleModalVisible(false); + } }} >
- + + + ); diff --git a/client/src/components/jobs-documents-gallery/jobs-documents-gallery.container.jsx b/client/src/components/jobs-documents-gallery/jobs-documents-gallery.container.jsx index 123e68ab7..af45e3874 100644 --- a/client/src/components/jobs-documents-gallery/jobs-documents-gallery.container.jsx +++ b/client/src/components/jobs-documents-gallery/jobs-documents-gallery.container.jsx @@ -44,7 +44,7 @@ export function JobsDocumentsContainer({ variables: { jobId: jobId }, fetchPolicy: "network-only", nextFetchPolicy: "network-only", - skip: !!billId + skip: !jobId || !!billId }); if (loading) return ;