diff --git a/client/.env.development.imex b/client/.env.development.imex index a7c9c1349..79d1b4e63 100644 --- a/client/.env.development.imex +++ b/client/.env.development.imex @@ -16,4 +16,5 @@ TEST_USERNAME="test@imex.dev" TEST_PASSWORD="test123" VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com -VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com \ No newline at end of file +VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com +VITE_APP_AMP_KEY=6228a598e57cd66875cfd41604f1f891 \ No newline at end of file diff --git a/client/.env.development.rome b/client/.env.development.rome index 816df9917..eabf048e8 100644 --- a/client/.env.development.rome +++ b/client/.env.development.rome @@ -18,4 +18,5 @@ TEST_USERNAME="test@imex.dev" TEST_PASSWORD="test123" VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com -VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com \ No newline at end of file +VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com +VITE_APP_AMP_KEY=46b1193a867d4e3131ae4c3a64a3fc78 \ No newline at end of file diff --git a/client/.env.production.imex b/client/.env.production.imex index dc1d7fe7a..70c7c01c7 100644 --- a/client/.env.production.imex +++ b/client/.env.production.imex @@ -15,4 +15,5 @@ VITE_APP_SPLIT_API=et9pjkik6bn67he5evpmpr1agoo7gactphgk VITE_APP_INSTANCE=IMEX VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com -VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com \ No newline at end of file +VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com +VITE_APP_AMP_KEY=6228a598e57cd66875cfd41604f1f891 \ No newline at end of file diff --git a/client/.env.production.rome b/client/.env.production.rome index 808c8e199..cb2cd88ac 100644 --- a/client/.env.production.rome +++ b/client/.env.production.rome @@ -15,4 +15,5 @@ VITE_APP_SPLIT_API=et9pjkik6bn67he5evpmpr1agoo7gactphgk VITE_APP_INSTANCE=ROME VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com -VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com \ No newline at end of file +VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com +VITE_APP_AMP_KEY=46b1193a867d4e3131ae4c3a64a3fc78 \ No newline at end of file diff --git a/client/.env.test.imex b/client/.env.test.imex index 2ff9a10d7..0afecd91b 100644 --- a/client/.env.test.imex +++ b/client/.env.test.imex @@ -15,4 +15,5 @@ VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc VITE_APP_INSTANCE=IMEX VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com -VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com \ No newline at end of file +VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com +VITE_APP_AMP_KEY=6228a598e57cd66875cfd41604f1f891 \ No newline at end of file diff --git a/client/.env.test.rome b/client/.env.test.rome index 24c9b7047..558c4528e 100644 --- a/client/.env.test.rome +++ b/client/.env.test.rome @@ -15,4 +15,5 @@ VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc VITE_APP_INSTANCE=ROME VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com -VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com \ No newline at end of file +VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com +VITE_APP_AMP_KEY=46b1193a867d4e3131ae4c3a64a3fc78 \ No newline at end of file diff --git a/client/src/App/App.jsx b/client/src/App/App.jsx index fa24ef07e..ea3be8f5a 100644 --- a/client/src/App/App.jsx +++ b/client/src/App/App.jsx @@ -24,6 +24,7 @@ import InstanceRenderMgr from "../utils/instanceRenderMgr"; import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx"; import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx"; import SocketProvider from "../contexts/SocketIO/socketProvider.jsx"; +import SoundWrapper from "./SoundWrapper.jsx"; const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component")); const ManagePage = lazy(() => import("../pages/manage/manage.page.container")); @@ -72,9 +73,6 @@ export function App({ setIsPartsEntry(isParts); }, [setIsPartsEntry]); - //const b = Grid.useBreakpoint(); - // console.log("Breakpoints:", b); - // Associate event listeners, memoize to prevent multiple listeners being added useEffect(() => { const offlineListener = () => { @@ -164,85 +162,87 @@ export function App({ /> - - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - + + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + + + } + > + } /> + + + + + + + } + > + } /> + + - - - } - > - } /> - - - - - - - } - > - } /> - - - - - } - > - } /> - - }> - } /> - - + + } + > + } /> + + }> + } /> + + + ); diff --git a/client/src/App/SoundWrapper.jsx b/client/src/App/SoundWrapper.jsx new file mode 100644 index 000000000..639fac3c9 --- /dev/null +++ b/client/src/App/SoundWrapper.jsx @@ -0,0 +1,43 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useNotification } from "../contexts/Notifications/notificationContext.jsx"; +import { initNewMessageSound, unlockAudio } from "./../utils/soundManager"; +import { initSingleTabAudioLeader } from "../utils/singleTabAudioLeader"; + +export default function SoundWrapper({ children, bodyshop }) { + const { t } = useTranslation(); + const notification = useNotification(); + + useEffect(() => { + if (!bodyshop?.id) return; + + // 1) Init single-tab leader election (only one tab should play sounds), scoped by bodyshopId + const cleanupLeader = initSingleTabAudioLeader(bodyshop.id); + + // 2) Initialize base audio + initNewMessageSound("https://images.imex.online/app/messageTone.wav", 0.7); + + // 3) Show a one-time prompt when autoplay blocks first play + const onNeedsUnlock = () => { + notification.info({ + description: t("audio.manager.description"), + duration: 3 + }); + }; + window.addEventListener("sound-needs-unlock", onNeedsUnlock); + + // 4) Proactively unlock on first gesture (once per session) + const gesture = () => unlockAudio(bodyshop.id); + window.addEventListener("click", gesture, { once: true, passive: true }); + window.addEventListener("touchstart", gesture, { once: true, passive: true }); + window.addEventListener("keydown", gesture, { once: true }); + + return () => { + cleanupLeader(); + window.removeEventListener("sound-needs-unlock", onNeedsUnlock); + // gesture listeners were added with {once:true} + }; + }, [notification, t, bodyshop?.id]); // include bodyshop.id so this runs when org changes + + return <>{children}>; +} diff --git a/client/src/components/accounting-receivables-table/accounting-receivables-table.component.jsx b/client/src/components/accounting-receivables-table/accounting-receivables-table.component.jsx index 7775fbb8f..a3aacdb08 100644 --- a/client/src/components/accounting-receivables-table/accounting-receivables-table.component.jsx +++ b/client/src/components/accounting-receivables-table/accounting-receivables-table.component.jsx @@ -142,7 +142,16 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r refetch={refetch} /> - {t("jobs.labels.viewallocations")} + + {t("jobs.labels.viewallocations")} + ) diff --git a/client/src/components/bills-list-table/bills-list-table.component.jsx b/client/src/components/bills-list-table/bills-list-table.component.jsx index 8b1d8e09c..166cd0b5b 100644 --- a/client/src/components/bills-list-table/bills-list-table.component.jsx +++ b/client/src/components/bills-list-table/bills-list-table.component.jsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import { FaTasks } from "react-icons/fa"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; +import { logImEXEvent } from "../../firebase/firebase.utils"; import { selectJobReadOnly } from "../../redux/application/application.selectors"; import { setModalContext } from "../../redux/modals/modals.actions"; import { selectBodyshop } from "../../redux/user/user.selectors"; @@ -75,6 +76,7 @@ export function BillsListTableComponent({ { + logImEXEvent("bills_create_task", {}); setTaskUpsertContext({ context: { jobid: job.id, @@ -167,6 +169,7 @@ export function BillsListTableComponent({ const handleTableChange = (pagination, filters, sorter) => { setState({ ...state, filteredInfo: filters, sortedInfo: sorter }); + logImEXEvent("bills_list_sort_filter", { pagination, filters, sorter }); }; const filteredBills = bills @@ -208,6 +211,7 @@ export function BillsListTableComponent({ { + logImEXEvent("bills_reconcile", {}); setReconciliationContext({ actions: { refetch: billsQuery.refetch }, context: { diff --git a/client/src/components/card-payment-modal/card-payment-modal.component.jsx b/client/src/components/card-payment-modal/card-payment-modal.component.jsx index b69644789..de686aa64 100644 --- a/client/src/components/card-payment-modal/card-payment-modal.component.jsx +++ b/client/src/components/card-payment-modal/card-payment-modal.component.jsx @@ -14,7 +14,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors"; import AuditTrailMapping from "../../utils/AuditTrailMappings"; import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component"; import JobSearchSelectComponent from "../job-search-select/job-search-select.component"; -import { getCurrentUser } from "../../firebase/firebase.utils"; +import { getCurrentUser, logImEXEvent } from "../../firebase/firebase.utils"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; const mapStateToProps = createStructuredSelector({ @@ -124,6 +124,7 @@ const CardPaymentModalComponent = ({ const { payments } = form.getFieldsValue(); try { + logImEXEvent("payment_cc_lightbox"); const response = await axios.post("/intellipay/lightbox_credentials", { bodyshop, refresh: !!window.intellipay, @@ -171,6 +172,7 @@ const CardPaymentModalComponent = ({ try { const { payments } = form.getFieldsValue(); + logImEXEvent("payment_cc_shortlink"); const response = await axios.post("/intellipay/generate_payment_url", { bodyshop, amount: payments.reduce((acc, val) => acc + (val?.amount || 0), 0), diff --git a/client/src/components/chat-affix/chat-affix.container.jsx b/client/src/components/chat-affix/chat-affix.container.jsx index b370b221c..06d501dca 100644 --- a/client/src/components/chat-affix/chat-affix.container.jsx +++ b/client/src/components/chat-affix/chat-affix.container.jsx @@ -9,13 +9,13 @@ import "./chat-affix.styles.scss"; import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers"; import { useSocket } from "../../contexts/SocketIO/useSocket.js"; -export function ChatAffixContainer({ bodyshop, chatVisible }) { +export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) { const { t } = useTranslation(); const client = useApolloClient(); const { socket } = useSocket(); useEffect(() => { - if (!bodyshop || !bodyshop.messagingservicesid) return; + if (!bodyshop?.messagingservicesid) return; async function SubscribeToTopicForFCMNotification() { try { @@ -35,8 +35,8 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) { SubscribeToTopicForFCMNotification(); // Register WebSocket handlers - if (socket && socket.connected) { - registerMessagingHandlers({ socket, client }); + if (socket?.connected) { + registerMessagingHandlers({ socket, client, currentUser, bodyshop, t }); return () => { unregisterMessagingHandlers({ socket }); @@ -44,11 +44,11 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) { } }, [bodyshop, socket, t, client]); - if (!bodyshop || !bodyshop.messagingservicesid) return <>>; + if (!bodyshop?.messagingservicesid) return <>>; return ( - {bodyshop && bodyshop.messagingservicesid ? : null} + {bodyshop?.messagingservicesid ? : null} ); } diff --git a/client/src/components/chat-affix/registerMessagingSocketHandlers.js b/client/src/components/chat-affix/registerMessagingSocketHandlers.js index 14d7d4a0e..d5519661e 100644 --- a/client/src/components/chat-affix/registerMessagingSocketHandlers.js +++ b/client/src/components/chat-affix/registerMessagingSocketHandlers.js @@ -1,6 +1,11 @@ -import { CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries"; import { gql } from "@apollo/client"; +import { playNewMessageSound } from "../../utils/soundManager.js"; +import { isLeaderTab } from "../../utils/singleTabAudioLeader"; + +import { CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries"; +import { QUERY_ACTIVE_ASSOCIATION_SOUND } from "../../graphql/user.queries"; + const logLocal = (message, ...args) => { if (import.meta.env.VITE_APP_IS_TEST || !import.meta.env.PROD) { console.log(`==================== ${message} ====================`); @@ -26,16 +31,48 @@ const enrichConversation = (conversation, isOutbound) => ({ __typename: "conversations" }); -export const registerMessagingHandlers = ({ socket, client }) => { +// Can be uncommonted to test the playback of the notification sound +// window.testTone = () => { +// const notificationSound = new Audio(newMessageSound); +// notificationSound.play().catch((error) => { +// console.error("Error playing notification sound:", error); +// }); +// }; + +export const registerMessagingHandlers = ({ socket, client, currentUser, bodyshop }) => { if (!(socket && client)) return; const handleNewMessageSummary = async (message) => { const { conversationId, newConversation, existingConversation, isoutbound } = message; + // True only when DB value is strictly true; falls back to true on cache miss + const isNewMessageSoundEnabled = (client) => { + try { + const email = currentUser?.email; + if (!email) return true; // default allow if we can't resolve user + const res = client.readQuery({ + query: QUERY_ACTIVE_ASSOCIATION_SOUND, + variables: { email } + }); + const flag = res?.associations?.[0]?.new_message_sound; + return flag === true; // strictly true => enabled + } catch { + // If the query hasn't been seeded in cache yet, default ON + return true; + } + }; + logLocal("handleNewMessageSummary - Start", { message, isNew: !existingConversation }); const queryVariables = { offset: 0 }; + if (!isoutbound) { + // Play notification sound for new inbound message (scoped to bodyshop) + if (isLeaderTab(bodyshop.id) && isNewMessageSoundEnabled(client)) { + playNewMessageSound(bodyshop.id); + } + } + if (!existingConversation && conversationId) { // Attempt to read from the cache to determine if this is actually a new conversation try { @@ -291,8 +328,6 @@ export const registerMessagingHandlers = ({ socket, client }) => { case "conversation-unarchived": case "conversation-archived": - // Would like to someday figure out how to get this working without refetch queries, - // But I have but a solid 4 hours into it, and there are just too many weird occurrences try { const listQueryVariables = { offset: 0 }; const detailsQueryVariables = { conversationId }; @@ -328,7 +363,8 @@ export const registerMessagingHandlers = ({ socket, client }) => { } break; - case "tag-added": { // Ensure `job_conversations` is properly formatted + case "tag-added": { + // Ensure `job_conversations` is properly formatted const formattedJobConversations = job_conversations.map((jc) => ({ __typename: "job_conversations", jobid: jc.jobid || jc.job?.id, diff --git a/client/src/components/contracts-find-modal/contracts-find-modal.container.jsx b/client/src/components/contracts-find-modal/contracts-find-modal.container.jsx index c7678e18f..86d14443a 100644 --- a/client/src/components/contracts-find-modal/contracts-find-modal.container.jsx +++ b/client/src/components/contracts-find-modal/contracts-find-modal.container.jsx @@ -34,7 +34,6 @@ export function ContractsFindModalContainer({ contractFinderModal, toggleModalVi logImEXEvent("contract_finder_search"); //Execute contract find - callSearch({ variables: { plate: (values.plate && values.plate !== "" && values.plate) || undefined, diff --git a/client/src/components/dashboard-grid/createDashboardQuery.js b/client/src/components/dashboard-grid/createDashboardQuery.js index 1b0ce0a3f..5f0e7b322 100644 --- a/client/src/components/dashboard-grid/createDashboardQuery.js +++ b/client/src/components/dashboard-grid/createDashboardQuery.js @@ -2,11 +2,13 @@ import { gql } from "@apollo/client"; import dayjs from "../../utils/day.js"; import componentList from "./componentList.js"; -const createDashboardQuery = (state) => { +const createDashboardQuery = (items) => { const componentBasedAdditions = - state && - Array.isArray(state.layout) && - state.layout.map((item) => componentList[item.i].gqlFragment || "").join(""); + Array.isArray(items) && + items + .map((item) => (componentList[item.i] && componentList[item.i].gqlFragment) || "") + .filter(Boolean) + .join(""); return gql` query QUERY_DASHBOARD_DETAILS { ${componentBasedAdditions || ""} monthly_sales: jobs(where: {_and: [ diff --git a/client/src/components/dashboard-grid/dashboard-grid.component.jsx b/client/src/components/dashboard-grid/dashboard-grid.component.jsx index f61a57398..929681bbd 100644 --- a/client/src/components/dashboard-grid/dashboard-grid.component.jsx +++ b/client/src/components/dashboard-grid/dashboard-grid.component.jsx @@ -1,5 +1,5 @@ import Icon, { SyncOutlined } from "@ant-design/icons"; -import { cloneDeep, isEmpty } from "lodash"; +import { cloneDeep } from "lodash"; import { useMutation, useQuery } from "@apollo/client"; import { Button, Dropdown, Space } from "antd"; import { PageHeader } from "@ant-design/pro-layout"; @@ -34,14 +34,25 @@ const mapDispatchToProps = () => ({ export function DashboardGridComponent({ currentUser, bodyshop }) { const { t } = useTranslation(); - const [state, setState] = useState({ - ...(bodyshop.associations[0].user.dashboardlayout - ? bodyshop.associations[0].user.dashboardlayout - : { items: [], layout: {}, layouts: [] }) + const [state, setState] = useState(() => { + const persisted = bodyshop.associations[0].user.dashboardlayout; + // Normalize persisted structure to avoid malformed shapes that can cause recursive layout recalculations + if (persisted) { + return { + items: Array.isArray(persisted.items) ? persisted.items : [], + layout: Array.isArray(persisted.layout) ? persisted.layout : [], + layouts: typeof persisted.layouts === "object" && !Array.isArray(persisted.layouts) ? persisted.layouts : {}, + cols: persisted.cols + }; + } + return { items: [], layout: [], layouts: {}, cols: 12 }; }); const notification = useNotification(); - const { loading, error, data, refetch } = useQuery(createDashboardQuery(state), { + // Memoize the query document so Apollo doesn't treat each render as a brand-new query causing continuous re-fetches + const dashboardQueryDoc = useMemo(() => createDashboardQuery(state.items), [state.items]); + + const { loading, error, data, refetch } = useQuery(dashboardQueryDoc, { fetchPolicy: "network-only", nextFetchPolicy: "network-only" }); @@ -49,21 +60,32 @@ export function DashboardGridComponent({ currentUser, bodyshop }) { const [updateLayout] = useMutation(UPDATE_DASHBOARD_LAYOUT); const handleLayoutChange = async (layout, layouts) => { - logImEXEvent("dashboard_change_layout"); + try { + logImEXEvent("dashboard_change_layout"); - setState({ ...state, layout, layouts }); + setState((prev) => ({ ...prev, layout, layouts })); - const result = await updateLayout({ - variables: { - email: currentUser.email, - layout: { ...state, layout, layouts } + const result = await updateLayout({ + variables: { + email: currentUser.email, + layout: { ...state, layout, layouts } + } + }); + + if (result?.errors && result.errors.length) { + const errorMessages = result.errors.map((e) => e?.message || String(e)); + notification.error({ + message: t("dashboard.errors.updatinglayout", { + message: errorMessages.join("; ") + }) + }); } - }); - - if (!isEmpty(result?.errors)) { + } catch (err) { + // Catch any unexpected errors (including potential cyclic JSON issues) so the promise never rejects unhandled + console.error("Dashboard layout update failed", err); notification.error({ message: t("dashboard.errors.updatinglayout", { - message: JSON.stringify(result.errors) + message: err?.message || String(err) }) }); } @@ -80,19 +102,26 @@ export function DashboardGridComponent({ currentUser, bodyshop }) { }; const handleAddComponent = (e) => { - logImEXEvent("dashboard_add_component", { name: e.key }); - setState({ - ...state, - items: [ - ...state.items, + // Avoid passing the full AntD menu click event (contains circular refs) to analytics + logImEXEvent("dashboard_add_component", { key: e.key }); + const compSpec = componentList[e.key] || {}; + const minW = compSpec.minW || 1; + const minH = compSpec.minH || 1; + const baseW = compSpec.w || 2; + const baseH = compSpec.h || 2; + setState((prev) => { + const nextItems = [ + ...prev.items, { i: e.key, - x: (state.items.length * 2) % (state.cols || 12), - y: 99, // puts it at the bottom - w: componentList[e.key].w || 2, - h: componentList[e.key].h || 2 + // Position near bottom: use a large y so RGL places it last without triggering cascading relayout loops + x: (prev.items.length * 2) % (prev.cols || 12), + y: 1000, + w: Math.max(baseW, minW), + h: Math.max(baseH, minH) } - ] + ]; + return { ...prev, items: nextItems }; }); }; @@ -130,25 +159,33 @@ export function DashboardGridComponent({ currentUser, bodyshop }) { className="layout" breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }} cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }} - width="100%" layouts={state.layouts} onLayoutChange={handleLayoutChange} > {state.items.map((item) => { - const TheComponent = componentList[item.i].component; + const spec = componentList[item.i] || {}; + const TheComponent = spec.component; + const minW = spec.minW || 1; + const minH = spec.minH || 1; + // Ensure current width/height respect minimums to avoid react-grid-layout prop warnings + const safeItem = { + ...item, + w: Math.max(item.w || spec.w || minW, minW), + h: Math.max(item.h || spec.h || minH, minH) + }; return ( handleRemoveComponent(item.i)} + onClick={() => handleRemoveComponent(safeItem.i)} /> - + {TheComponent && } ); diff --git a/client/src/components/global-search/global-search-os.component.jsx b/client/src/components/global-search/global-search-os.component.jsx index 4def18f32..2d4307344 100644 --- a/client/src/components/global-search/global-search-os.component.jsx +++ b/client/src/components/global-search/global-search-os.component.jsx @@ -7,6 +7,7 @@ import { Link, useNavigate } from "react-router-dom"; import PhoneNumberFormatter from "../../utils/PhoneFormatter"; import OwnerNameDisplay, { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component"; import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component"; +import { logImEXEvent } from "../../firebase/firebase.utils"; export default function GlobalSearchOs() { const { t } = useTranslation(); @@ -19,6 +20,8 @@ export default function GlobalSearchOs() { if (v && v && v !== "" && v.length >= 3) { try { setLoading(true); + logImEXEvent("global_search", { search: v }); + const searchData = await axios.post("/search", { search: v }); diff --git a/client/src/components/job-audit-trail/job-audit-trail.component.jsx b/client/src/components/job-audit-trail/job-audit-trail.component.jsx index 22e1c5f1e..dcf4ccb9e 100644 --- a/client/src/components/job-audit-trail/job-audit-trail.component.jsx +++ b/client/src/components/job-audit-trail/job-audit-trail.component.jsx @@ -4,6 +4,7 @@ import { Button, Card, Col, Row, Table, Tag } from "antd"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; +import { logImEXEvent } from "../../firebase/firebase.utils"; import { QUERY_AUDIT_TRAIL } from "../../graphql/audit_trail.queries"; import { selectBodyshop } from "../../redux/user/user.selectors"; import { DateTimeFormatter } from "../../utils/DateFormatter"; @@ -125,6 +126,7 @@ export function JobAuditTrail({ bodyshop, jobId }) { render: (text, record) => ( { + logImEXEvent("jobs_audit_view_email", {}); var win = window.open( "", "Title", diff --git a/client/src/components/job-detail-lines/job-lines.component.jsx b/client/src/components/job-detail-lines/job-lines.component.jsx index 3961fa625..2cadcf4e8 100644 --- a/client/src/components/job-detail-lines/job-lines.component.jsx +++ b/client/src/components/job-detail-lines/job-lines.component.jsx @@ -46,6 +46,7 @@ import PartsOrderModalContainer from "../parts-order-modal/parts-order-modal.con import JobLinesExpander from "./job-lines-expander.component"; import JobLinesPartPriceChange from "./job-lines-part-price-change.component"; import JobLinesExpanderSimple from "./jobs-lines-expander-simple.component"; +import { logImEXEvent } from "../../firebase/firebase.utils"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -397,6 +398,7 @@ export function JobLinesComponent({ filteredInfo: filters, sortedInfo: sorter })); + logImEXEvent("joblines_table_change", { pagination, filters, sorter }); }; const handleMark = (e) => { @@ -413,6 +415,7 @@ export function JobLinesComponent({ ]) ); } + logImEXEvent("joblines_mark_lines", {}); }; const markMenu = { @@ -616,12 +619,18 @@ export function JobLinesComponent({ expanded ? ( onExpand(record, e)} /> ) : ( - onExpand(record, e)} /> + { + onExpand(record, e); + logImEXEvent("joblines_expander", {}); + }} + /> ) }} onRow={(record) => { return { onDoubleClick: () => { + logImEXEvent("joblines_double_click_select", {}); const notMatchingLines = selectedLines.filter((i) => i.id !== record.id); notMatchingLines.length !== selectedLines.length ? setSelectedLines(notMatchingLines) diff --git a/client/src/components/job-lifecycle/job-lifecycle.component.jsx b/client/src/components/job-lifecycle/job-lifecycle.component.jsx index bcffe8699..e5b99a9f2 100644 --- a/client/src/components/job-lifecycle/job-lifecycle.component.jsx +++ b/client/src/components/job-lifecycle/job-lifecycle.component.jsx @@ -1,18 +1,19 @@ -import { useCallback, useEffect, useState } from "react"; -import dayjs from "../../utils/day"; -import axios from "axios"; -import { Badge, Card, Space, Table, Tag } from "antd"; import { gql, useQuery } from "@apollo/client"; -import { DateTimeFormatterFunction } from "../../utils/DateFormatter"; +import { Badge, Card, Space, Table, Tag } from "antd"; +import axios from "axios"; import { isEmpty } from "lodash"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import "./job-lifecycle.styles.scss"; -import BlurWrapperComponent from "../feature-wrapper/blur-wrapper.component"; -import UpsellComponent, { upsellEnum } from "../upsell/upsell.component"; -import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; +import { logImEXEvent } from "../../firebase/firebase.utils"; import { selectBodyshop } from "../../redux/user/user.selectors"; +import { DateTimeFormatterFunction } from "../../utils/DateFormatter"; +import dayjs from "../../utils/day"; +import BlurWrapperComponent from "../feature-wrapper/blur-wrapper.component"; +import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; +import UpsellComponent, { upsellEnum } from "../upsell/upsell.component"; +import "./job-lifecycle.styles.scss"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -57,6 +58,7 @@ export function JobLifecycleComponent({ bodyshop, job, statuses }) { jobids: job.id, statuses: statuses.statuses }); + logImEXEvent("jobs_lifecycle_data", {}); const data = response.data.transition[job.id]; setLifecycleData(data); } catch (err) { diff --git a/client/src/components/job-line-bulk-assign/job-line-bulk-assign.component.jsx b/client/src/components/job-line-bulk-assign/job-line-bulk-assign.component.jsx index 951862b8e..45c1e09e4 100644 --- a/client/src/components/job-line-bulk-assign/job-line-bulk-assign.component.jsx +++ b/client/src/components/job-line-bulk-assign/job-line-bulk-assign.component.jsx @@ -10,6 +10,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors"; import { insertAuditTrail } from "../../redux/application/application.actions"; import AuditTrailMapping from "../../utils/AuditTrailMappings"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +import { logImEXEvent } from "../../firebase/firebase.utils.js"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -32,6 +33,7 @@ export function JoblineBulkAssign({ setSelectedLines, selectedLines, insertAudit const handleConvert = async (values) => { try { setLoading(true); + logImEXEvent("joblines_bulk_assign", {}); const result = await assignLines({ variables: { jobline: { diff --git a/client/src/components/job-line-dispatch-button/job-line-dispatch-button.component.jsx b/client/src/components/job-line-dispatch-button/job-line-dispatch-button.component.jsx index 136b430d9..c688ce13d 100644 --- a/client/src/components/job-line-dispatch-button/job-line-dispatch-button.component.jsx +++ b/client/src/components/job-line-dispatch-button/job-line-dispatch-button.component.jsx @@ -12,6 +12,7 @@ import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selecto import { GenerateDocument } from "../../utils/RenderTemplate"; import { TemplateList } from "../../utils/TemplateConstants"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +import { logImEXEvent } from "../../firebase/firebase.utils.js"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -46,6 +47,7 @@ export function JobLineDispatchButton({ try { setLoading(true); //THIS HAS NOT YET BEEN TESTED. START BY FINISHING THIS FUNCTION. + logImEXEvent("joblines_dispatch", {}); const result = await dispatchLines({ variables: { partsDispatch: { diff --git a/client/src/components/job-remove-from-parst-queue/job-remove-from-parts-queue.component.jsx b/client/src/components/job-remove-from-parst-queue/job-remove-from-parts-queue.component.jsx index 992fea137..c07ab0a6f 100644 --- a/client/src/components/job-remove-from-parst-queue/job-remove-from-parts-queue.component.jsx +++ b/client/src/components/job-remove-from-parst-queue/job-remove-from-parts-queue.component.jsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { UPDATE_JOB } from "../../graphql/jobs.queries"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +import { logImEXEvent } from "../../firebase/firebase.utils.js"; export default function JobRemoveFromPartsQueue({ checked, jobId }) { const [updateJob] = useMutation(UPDATE_JOB); @@ -13,6 +14,8 @@ export default function JobRemoveFromPartsQueue({ checked, jobId }) { const handleChange = async (e) => { setLoading(true); + logImEXEvent("parts_queue_toggle", { estimators: e }); + const result = await updateJob({ variables: { jobId: jobId, job: { queued_for_parts: e.target.checked } } }); diff --git a/client/src/components/jobs-available-scan/jobs-available-scan.component.jsx b/client/src/components/jobs-available-scan/jobs-available-scan.component.jsx index 91c39a6df..27e12917f 100644 --- a/client/src/components/jobs-available-scan/jobs-available-scan.component.jsx +++ b/client/src/components/jobs-available-scan/jobs-available-scan.component.jsx @@ -8,6 +8,7 @@ import { createStructuredSelector } from "reselect"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { selectPartnerVersion } from "../../redux/application/application.selectors"; import { alphaSort } from "../../utils/sorters"; +import { logImEXEvent } from "../../firebase/firebase.utils.js"; const mapStateToProps = createStructuredSelector({ //currentUser: selectCurrentUser @@ -30,11 +31,15 @@ export function JobsAvailableScan({ partnerVersion, refetch }) { const notification = useNotification(); const handleTableChange = (pagination, filters, sorter) => { + logImEXEvent("available_jobs_scan_sort_filter", { pagination, filters, sorter }); + setState({ ...state, filteredInfo: filters, sortedInfo: sorter }); }; const handleImport = async (filepath) => { setLoading(true); + logImEXEvent("available_jobs_scan", {}); + const response = await axios.post("http://localhost:1337/import/", { filepath }); diff --git a/client/src/components/jobs-available-table/jobs-available-table.component.jsx b/client/src/components/jobs-available-table/jobs-available-table.component.jsx index 72879ff07..f42f8dc55 100644 --- a/client/src/components/jobs-available-table/jobs-available-table.component.jsx +++ b/client/src/components/jobs-available-table/jobs-available-table.component.jsx @@ -12,6 +12,7 @@ import CurrencyFormatter from "../../utils/CurrencyFormatter"; import { TimeAgoFormatter } from "../../utils/DateFormatter"; import { alphaSort } from "../../utils/sorters"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +import { logImEXEvent } from "../../firebase/firebase.utils.js"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -34,6 +35,7 @@ export function JobsAvailableComponent({ bodyshop, loading, data, refetch, addJo const handleTableChange = (pagination, filters, sorter) => { setState({ ...state, filteredInfo: filters, sortedInfo: sorter }); + logImEXEvent("available_jobs_sort_filter", { pagination, filters, sorter }); }; const columns = [ diff --git a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.toggle-production.jsx b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.toggle-production.jsx index 2a331769f..6e89cf316 100644 --- a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.toggle-production.jsx +++ b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.toggle-production.jsx @@ -14,6 +14,7 @@ import AuditTrailMapping from "../../utils/AuditTrailMappings"; import { DateTimeFormatterFunction } from "../../utils/DateFormatter"; import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component"; import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx"; +import { logImEXEvent } from "../../firebase/firebase.utils.js"; const mapStateToProps = createStructuredSelector({ //currentUser: selectCurrentUser, @@ -108,6 +109,9 @@ export function JobsDetailHeaderActionsToggleProduction({ DateTimeFormatterFunction(values.actual_completion) ) }); + + logImEXEvent(scenario === "pre" ? "job_intake_quick" : "job-deliver-quick", {}); + setPopOverVisible(false); closeParentMenu(); refetch(); diff --git a/client/src/components/jobs-list-paginated/jobs-list-paginated.component.jsx b/client/src/components/jobs-list-paginated/jobs-list-paginated.component.jsx index 6d36d85f2..26ec011fd 100644 --- a/client/src/components/jobs-list-paginated/jobs-list-paginated.component.jsx +++ b/client/src/components/jobs-list-paginated/jobs-list-paginated.component.jsx @@ -15,6 +15,7 @@ import { alphaSort, statusSort } from "../../utils/sorters"; import useLocalStorage from "../../utils/useLocalStorage"; import StartChatButton from "../chat-open-button/chat-open-button.component"; import OwnerNameDisplay from "../owner-name-display/owner-name-display.component"; +import { logImEXEvent } from "../../firebase/firebase.utils"; const mapStateToProps = createStructuredSelector({ //currentUser: selectCurrentUser @@ -177,6 +178,7 @@ export function JobsList({ bodyshop, refetch, loading, jobs, total }) { } setFilter(filters); history({ search: queryString.stringify(search) }); + logImEXEvent("jobs_all_list_sort_filter", { pagination, filters, sorter }); }; useEffect(() => { diff --git a/client/src/components/jobs-list/jobs-list.component.jsx b/client/src/components/jobs-list/jobs-list.component.jsx index aaf56ba79..49439fa00 100644 --- a/client/src/components/jobs-list/jobs-list.component.jsx +++ b/client/src/components/jobs-list/jobs-list.component.jsx @@ -17,6 +17,7 @@ import AlertComponent from "../alert/alert.component"; import ChatOpenButton from "../chat-open-button/chat-open-button.component"; import OwnerNameDisplay from "../owner-name-display/owner-name-display.component"; import { OwnerNameDisplayFunction } from "./../owner-name-display/owner-name-display.component"; +import { logImEXEvent } from "../../firebase/firebase.utils"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -67,6 +68,7 @@ export function JobsList({ bodyshop }) { : []; const handleTableChange = (pagination, filters, sorter) => { + logImEXEvent("jobs_list_sort_filter", { pagination, filters, sorter }); setState({ ...state, sortedInfo: sorter }); setFilter(filters); }; diff --git a/client/src/components/notification-center/notification-center.container.jsx b/client/src/components/notification-center/notification-center.container.jsx index d0024c9d3..e534375f6 100644 --- a/client/src/components/notification-center/notification-center.container.jsx +++ b/client/src/components/notification-center/notification-center.container.jsx @@ -39,11 +39,15 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount, return showUnreadOnly ? { ...baseWhereClause, read: { _is_null: true } } : baseWhereClause; }, [baseWhereClause, showUnreadOnly]); + // before you call useQuery, compute skip once so you can reuse it + const skipQuery = !userAssociationId || !isEmployee; + const { data, fetchMore, loading: queryLoading, - refetch + refetch, + error } = useQuery(GET_NOTIFICATIONS, { variables: { limit: INITIAL_NOTIFICATIONS, @@ -52,14 +56,26 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount, }, fetchPolicy: "cache-and-network", notifyOnNetworkStatusChange: true, + errorPolicy: "all", pollInterval: isConnected ? 0 : day.duration(NOTIFICATION_POLL_INTERVAL_SECONDS, "seconds").asMilliseconds(), - skip: !userAssociationId || !isEmployee, - onError: (err) => { - console.error(`Error polling Notifications: ${err?.message || ""}`); - setTimeout(() => refetch(), day.duration(2, "seconds").asMilliseconds()); - } + skip: skipQuery }); + // Replace onError with a side-effect that reacts to the hook’s `error` + useEffect(() => { + if (!error || skipQuery) return; + + console.error(`Error polling Notifications: ${error?.message || ""}`); + + const t = setTimeout(() => { + // Guard: if component unmounted or query now skipped, do nothing + if (!skipQuery) { + refetch().catch((e) => console.error("Refetch failed:", e?.message || e)); + } + }, day.duration(2, "seconds").asMilliseconds()); + + return () => clearTimeout(t); + }, [error, refetch, skipQuery]); useEffect(() => { const handleClickOutside = (event) => { // Prevent open + close behavior from the header diff --git a/client/src/components/owner-detail-form/owner-detail-form.container.jsx b/client/src/components/owner-detail-form/owner-detail-form.container.jsx index d78a11f04..30ab80874 100644 --- a/client/src/components/owner-detail-form/owner-detail-form.container.jsx +++ b/client/src/components/owner-detail-form/owner-detail-form.container.jsx @@ -1,17 +1,18 @@ -import { Button, Form, Popconfirm } from "antd"; import { PageHeader } from "@ant-design/pro-layout"; -import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; import { useApolloClient, useMutation } from "@apollo/client"; +import { Button, Form, Popconfirm } from "antd"; +import { phone } from "phone"; // Import phone utility for formatting +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; +import { useNavigate } from "react-router-dom"; import { createStructuredSelector } from "reselect"; +import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +import { logImEXEvent } from "../../firebase/firebase.utils.js"; import { DELETE_OWNER, UPDATE_OWNER } from "../../graphql/owners.queries"; import { selectBodyshop } from "../../redux/user/user.selectors"; // Adjust path import { phoneNumberOptOutService } from "../../utils/phoneOptOutService.js"; // Adjust path import OwnerDetailFormComponent from "./owner-detail-form.component"; -import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; -import { phone } from "phone"; // Import phone utility for formatting // Connect to Redux to access bodyshop const mapStateToProps = createStructuredSelector({ @@ -55,6 +56,7 @@ function OwnerDetailFormContainer({ owner, refetch, bodyshop }) { const handleDelete = async () => { setLoading(true); + logImEXEvent("owner_delete", {}); try { const result = await deleteOwner({ variables: { id: owner.id } @@ -84,6 +86,7 @@ function OwnerDetailFormContainer({ owner, refetch, bodyshop }) { const handleFinish = async (values) => { setLoading(true); + logImEXEvent("owner_update", {}); try { const result = await updateOwner({ variables: { ownerId: owner.id, owner: values } diff --git a/client/src/components/parts-order-modal/parts-order-modal-price-change.component.jsx b/client/src/components/parts-order-modal/parts-order-modal-price-change.component.jsx index ba8843d95..d985af6a0 100644 --- a/client/src/components/parts-order-modal/parts-order-modal-price-change.component.jsx +++ b/client/src/components/parts-order-modal/parts-order-modal-price-change.component.jsx @@ -1,7 +1,7 @@ import { DownOutlined } from "@ant-design/icons"; import { Dropdown, InputNumber, Space } from "antd"; import { useTranslation } from "react-i18next"; - +import { logImEXEvent } from "../../firebase/firebase.utils"; export default function PartsOrderModalPriceChange({ form, field }) { const { t } = useTranslation(); const menu = { @@ -63,6 +63,7 @@ export default function PartsOrderModalPriceChange({ form, field }) { } ], onClick: ({ key }) => { + logImEXEvent("parts_order_manual_discount", {}); if (key === "custom") return; const values = form.getFieldsValue(); const { parts_order_lines } = values; diff --git a/client/src/components/parts-queue-card/parts-queue-card.component.jsx b/client/src/components/parts-queue-card/parts-queue-card.component.jsx index 34c47bf77..78c463523 100644 --- a/client/src/components/parts-queue-card/parts-queue-card.component.jsx +++ b/client/src/components/parts-queue-card/parts-queue-card.component.jsx @@ -8,6 +8,7 @@ import AlertComponent from "../alert/alert.component"; import JobsDetailHeader from "../jobs-detail-header/jobs-detail-header.component"; import LoadingSpinner from "../loading-spinner/loading-spinner.component"; import PartsQueueJobLinesComponent from "./parts-queue-job-lines.component"; +import { logImEXEvent } from "../../firebase/firebase.utils"; export default function PartsQueueDetailCard() { const selectedBreakpoint = Object.entries(Grid.useBreakpoint()) @@ -37,6 +38,8 @@ export default function PartsQueueDetailCard() { const { t } = useTranslation(); const handleDrawerClose = () => { delete searchParams.selected; + logImEXEvent("parts_queue_drawer", {}); + history({ search: queryString.stringify({ ...searchParams diff --git a/client/src/components/parts-queue-list/parts-queue.list.component.jsx b/client/src/components/parts-queue-list/parts-queue.list.component.jsx index e325fe88a..bb4b72b79 100644 --- a/client/src/components/parts-queue-list/parts-queue.list.component.jsx +++ b/client/src/components/parts-queue-list/parts-queue.list.component.jsx @@ -20,6 +20,7 @@ import JobPartsQueueCount from "../job-parts-queue-count/job-parts-queue-count.c import JobRemoveFromPartsQueue from "../job-remove-from-parst-queue/job-remove-from-parts-queue.component"; import OwnerNameDisplay, { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component"; import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component"; +import { logImEXEvent } from "../../firebase/firebase.utils"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -73,6 +74,7 @@ export function PartsQueueListComponent({ bodyshop }) { } setFilter(filters); history({ search: queryString.stringify(searchParams) }); + logImEXEvent("parts_queue_sort_filter", { pagination, filters, sorter }); }; const handleOnRowClick = (record) => { diff --git a/client/src/components/production-board-filters/production-board-filters.component.jsx b/client/src/components/production-board-filters/production-board-filters.component.jsx index e0547c0d8..ef8121c84 100644 --- a/client/src/components/production-board-filters/production-board-filters.component.jsx +++ b/client/src/components/production-board-filters/production-board-filters.component.jsx @@ -10,6 +10,7 @@ import { } from "@ant-design/icons"; import { selectBodyshop } from "../../redux/user/user.selectors"; import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component"; +import { logImEXEvent } from "../../firebase/firebase.utils"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -26,10 +27,12 @@ export function ProductionBoardFilters({ bodyshop, filter, setFilter, loading }) const toggleAlertFilter = () => { setFilter({ ...filter, alert: !filter.alert }); + logImEXEvent("visual_board_filter_alert", {}); }; const toggleUnassignedFilter = () => { setFilter({ ...filter, unassigned: !filter.unassigned }); + logImEXEvent("visual_board_filter_unassigned", {}); }; return ( @@ -40,6 +43,7 @@ export function ProductionBoardFilters({ bodyshop, filter, setFilter, loading }) placeholder={t("general.labels.search")} onChange={(e) => { setFilter({ ...filter, search: e.target.value }); + logImEXEvent("visual_board_filter_search", { search: e.target.value }); }} /> e.active)} value={filter.employeeId} placeholder={t("production.labels.employeesearch")} - onChange={(emp) => setFilter({ ...filter, employeeId: emp })} + onChange={(emp) => { + setFilter({ ...filter, employeeId: emp }); + logImEXEvent("visual_board_filter_alert", { employeeId: emp }); + }} allowClear /> { if (record.refetch) record.refetch(); }); + logImEXEvent("job_add_comment", { estimators: e }); }; const handleChange = (e) => { diff --git a/client/src/components/production-list-table/production-list-table.component.jsx b/client/src/components/production-list-table/production-list-table.component.jsx index ff2feabb2..868e1464a 100644 --- a/client/src/components/production-list-table/production-list-table.component.jsx +++ b/client/src/components/production-list-table/production-list-table.component.jsx @@ -18,6 +18,7 @@ import ProductionListDetail from "../production-list-detail/production-list-deta import { ProductionListConfigManager } from "./production-list-config-manager.component.jsx"; import ProductionListPrint from "./production-list-print.component"; import ResizeableTitle from "./production-list-table.resizeable.component"; +import { logImEXEvent } from "../../firebase/firebase.utils.js"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -114,6 +115,7 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici setState(newState); setHasUnsavedChanges(true); } + logImEXEvent("production_list_sort_filter", { pagination, filters, sorter }); }; const onDragEnd = (fromIndex, toIndex) => { @@ -134,6 +136,7 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici setColumns(newColumns); setHasUnsavedChanges(true); } + logImEXEvent("production_list_remove_column", { key }); }; const handleResize = @@ -156,6 +159,7 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici setColumns(updatedColumns); setHasUnsavedChanges(true); } + logImEXEvent("production_list_add_column", { key: newColumn.key }); }; const headerItem = (col) => { diff --git a/client/src/components/profile-my/profile-my.component.jsx b/client/src/components/profile-my/profile-my.component.jsx index 0b3180d0a..e9c944c11 100644 --- a/client/src/components/profile-my/profile-my.component.jsx +++ b/client/src/components/profile-my/profile-my.component.jsx @@ -1,5 +1,5 @@ -import { Button, Card, Col, Form, Input } from "antd"; -import { LockOutlined } from "@ant-design/icons"; +import { Button, Card, Col, Form, Input, Space, Switch, Tooltip, Typography } from "antd"; +import { AudioMutedOutlined, LockOutlined, SoundOutlined } from "@ant-design/icons"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; @@ -10,6 +10,8 @@ import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useSocket } from "../../contexts/SocketIO/useSocket.js"; import NotificationSettingsForm from "../notification-settings/notification-settings-form.component.jsx"; +import { useMutation, useQuery } from "@apollo/client"; +import { QUERY_ACTIVE_ASSOCIATION_SOUND, UPDATE_NEW_MESSAGE_SOUND } from "../../graphql/user.queries.js"; const mapStateToProps = createStructuredSelector({ currentUser: selectCurrentUser @@ -48,6 +50,28 @@ export default connect( } }; + // ---- Notification sound (associations.new_message_sound) ---- + const email = currentUser?.email; + const { data: assocData, loading: assocLoading } = useQuery(QUERY_ACTIVE_ASSOCIATION_SOUND, { + variables: { email }, + skip: !email, + fetchPolicy: "network-only", + nextFetchPolicy: "cache-first" + }); + const association = assocData?.associations?.[0]; + // Treat null/undefined as ON for backward-compat + const soundEnabled = association?.new_message_sound === true; + + const [updateNewMessageSound, { loading: updatingSound }] = useMutation(UPDATE_NEW_MESSAGE_SOUND, { + update(cache, { data }) { + const updated = data?.update_associations_by_pk; + if (!updated) return; + cache.modify({ + id: cache.identify({ __typename: "associations", id: updated.id }), + fields: { new_message_sound: () => updated.new_message_sound } + }); + } + }); return ( <> @@ -80,6 +104,7 @@ export default connect( + + + {association && ( + + + + {t("user.labels.play_sound_for_new_messages")} + + } + unCheckedChildren={} + checked={!!soundEnabled} + loading={assocLoading || updatingSound} + onChange={(checked) => { + updateNewMessageSound({ + variables: { id: association.id, value: checked }, + optimisticResponse: { + update_associations_by_pk: { + __typename: "associations", + id: association.id, + new_message_sound: checked + } + } + }) + .then(() => { + notification.success({ + message: checked + ? t("user.labels.notification_sound_enabled") + : t("user.labels.notification_sound_disabled") + }); + }) + .catch((e) => { + notification.error({ message: e.message || "Failed to update setting" }); + }); + }} + /> + + + + {t("user.labels.notification_sound_help")} + + + + )} + {scenarioNotificationsOn && ( diff --git a/client/src/components/qbo-authorize/qbo-authorize.component.jsx b/client/src/components/qbo-authorize/qbo-authorize.component.jsx index 7d2e82505..bf8f7a950 100644 --- a/client/src/components/qbo-authorize/qbo-authorize.component.jsx +++ b/client/src/components/qbo-authorize/qbo-authorize.component.jsx @@ -5,6 +5,7 @@ import { useEffect } from "react"; import { useCookies } from "react-cookie"; import { useLocation, useNavigate } from "react-router-dom"; import QboSignIn from "../../assets/qbo/C2QB_green_btn_med_default.svg"; +import { logImEXEvent } from "../../firebase/firebase.utils"; export default function QboAuthorizeComponent() { const location = useLocation(); @@ -12,6 +13,7 @@ export default function QboAuthorizeComponent() { const [setCookie] = useCookies(["access_token", "refresh_token"]); const handleQbSignIn = async () => { + logImEXEvent("qbo_sign_in_clicked"); const result = await Axios.post("/qbo/authorize"); window.location.href = result.data; }; diff --git a/client/src/components/schedule-calendar-wrapper/schedule-calendar-header-graph.component.jsx b/client/src/components/schedule-calendar-wrapper/schedule-calendar-header-graph.component.jsx index a0ff09401..318cf4649 100644 --- a/client/src/components/schedule-calendar-wrapper/schedule-calendar-header-graph.component.jsx +++ b/client/src/components/schedule-calendar-wrapper/schedule-calendar-header-graph.component.jsx @@ -9,6 +9,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors"; import BlurWrapperComponent from "../feature-wrapper/blur-wrapper.component"; import { upsellEnum, UpsellMaskWrapper } from "../upsell/upsell.component"; import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; +import { logImEXEvent } from "../../firebase/firebase.utils"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -79,7 +80,12 @@ export function ScheduleCalendarHeaderGraph({ bodyshop, loadData }) { ); return ( - + open && logImEXEvent("schedule_spider_graph", {})} + content={popContent} + > ); diff --git a/client/src/components/schedule-calendar-wrapper/schedule-calendar-header.component.jsx b/client/src/components/schedule-calendar-wrapper/schedule-calendar-header.component.jsx index faea7a23d..36599df49 100644 --- a/client/src/components/schedule-calendar-wrapper/schedule-calendar-header.component.jsx +++ b/client/src/components/schedule-calendar-wrapper/schedule-calendar-header.component.jsx @@ -19,6 +19,7 @@ import OwnerNameDisplay from "../owner-name-display/owner-name-display.component import ScheduleBlockDay from "../schedule-block-day/schedule-block-day.component"; import UpsellComponent, { upsellEnum } from "../upsell/upsell.component"; import ScheduleCalendarHeaderGraph from "./schedule-calendar-header-graph.component"; +import { logImEXEvent } from "../../firebase/firebase.utils"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -142,6 +143,7 @@ export function ScheduleCalendarHeaderComponent({ bodyshop, label, refetch, date content={jobsInPopup} trigger="hover" title={t("appointments.labels.arrivingjobs")} + onOpenChange={(open) => open && logImEXEvent("schedule_popover_arriving_jobs", {})} > @@ -159,6 +161,7 @@ export function ScheduleCalendarHeaderComponent({ bodyshop, label, refetch, date content={jobsOutPopup} trigger="hover" title={t("appointments.labels.completingjobs")} + onOpenChange={(open) => open && logImEXEvent("schedule_popover_departing_jobs", {})} > diff --git a/client/src/components/schedule-calendar-wrapper/scheduler-calendar-wrapper.component.jsx b/client/src/components/schedule-calendar-wrapper/scheduler-calendar-wrapper.component.jsx index b2d355434..7955f578e 100644 --- a/client/src/components/schedule-calendar-wrapper/scheduler-calendar-wrapper.component.jsx +++ b/client/src/components/schedule-calendar-wrapper/scheduler-calendar-wrapper.component.jsx @@ -13,6 +13,7 @@ import Event from "../job-at-change/schedule-event.container"; import JobDetailCards from "../job-detail-cards/job-detail-cards.component"; import local from "./localizer"; import HeaderComponent from "./schedule-calendar-header.component"; +import { logImEXEvent } from "../../firebase/firebase.utils"; import "./schedule-calendar.styles.scss"; const mapStateToProps = createStructuredSelector({ @@ -139,6 +140,8 @@ export function ScheduleCalendarWrapperComponent({ }} onView={(view) => { search.view = view; + logImEXEvent("schedule_change_view", { view }); + history({ search: queryString.stringify(search) }); }} step={15} diff --git a/client/src/components/schedule-calendar/schedule-calendar.component.jsx b/client/src/components/schedule-calendar/schedule-calendar.component.jsx index 48ae26a6c..6c292bed0 100644 --- a/client/src/components/schedule-calendar/schedule-calendar.component.jsx +++ b/client/src/components/schedule-calendar/schedule-calendar.component.jsx @@ -14,6 +14,7 @@ import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { selectBodyshop } from "../../redux/user/user.selectors"; import _ from "lodash"; +import { logImEXEvent } from "../../firebase/firebase.utils"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -80,6 +81,7 @@ export function ScheduleCalendarComponent({ data, refetch, bodyshop }) { value={[...estimatorsFilter]} onChange={(e) => { setEstimatiorsFilter(e); + logImEXEvent("schedule_filter_by_estimator", { estimators: e }); }} options={estimators.map((e) => ({ label: e, @@ -95,6 +97,7 @@ export function ScheduleCalendarComponent({ data, refetch, bodyshop }) { value={filter?.ins_co_nm ? filter.ins_co_nm : []} onChange={(e) => { setFilter({ ...filter, ins_co_nm: e }); + logImEXEvent("schedule_filter_by_ins_co_nm", { ins_co_nm: e }); }} options={bodyshop.md_ins_cos.map((i) => ({ label: i.name, diff --git a/client/src/components/scoreboard-entry-edit/scoreboard-entry-edit.component.jsx b/client/src/components/scoreboard-entry-edit/scoreboard-entry-edit.component.jsx index ebde42e0e..e8cde45fd 100644 --- a/client/src/components/scoreboard-entry-edit/scoreboard-entry-edit.component.jsx +++ b/client/src/components/scoreboard-entry-edit/scoreboard-entry-edit.component.jsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; import { UPDATE_SCOREBOARD_ENTRY } from "../../graphql/scoreboard.queries"; import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +import { logImEXEvent } from "../../firebase/firebase.utils.js"; export default function ScoreboardEntryEdit({ entry }) { const [open, setOpen] = useState(false); @@ -16,6 +17,7 @@ export default function ScoreboardEntryEdit({ entry }) { const handleFinish = async (values) => { setLoading(true); + logImEXEvent("scoreboard_edit_job", {}); values.date = dayjs(values.date).format("YYYY-MM-DD"); const result = await updateScoreboardentry({ variables: { sbId: entry.id, sbInput: values } diff --git a/client/src/components/share-to-teams/share-to-teams.component.jsx b/client/src/components/share-to-teams/share-to-teams.component.jsx index 6d935db31..119862ff9 100644 --- a/client/src/components/share-to-teams/share-to-teams.component.jsx +++ b/client/src/components/share-to-teams/share-to-teams.component.jsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { selectBodyshop } from "../../redux/user/user.selectors.js"; +import { logImEXEvent } from "../../firebase/firebase.utils.js"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -52,6 +53,7 @@ const ShareToTeamsComponent = ({ const teamsShareUrl = `https://teams.microsoft.com/share?href=${currentUrl}&preText=${messageText}&title=${pageTitle}`; // Function to open the centered share link in a new window/tab const handleShare = () => { + logImEXEvent("share_to_teams", {}); const screenWidth = window.screen.width; const screenHeight = window.screen.height; const windowWidth = 600; diff --git a/client/src/components/simplified-parts-jobs-list/simplified-parts-jobs-list.component.jsx b/client/src/components/simplified-parts-jobs-list/simplified-parts-jobs-list.component.jsx index cfa2d2bfc..52946a809 100644 --- a/client/src/components/simplified-parts-jobs-list/simplified-parts-jobs-list.component.jsx +++ b/client/src/components/simplified-parts-jobs-list/simplified-parts-jobs-list.component.jsx @@ -96,14 +96,14 @@ export function SimplifiedPartsJobsListComponent({ ellipsis: true, sorter: search?.search - ? (a, b) => statusSort(a.status, b.status, bodyshop.md_ro_statuses.parts_active_statuses) + ? (a, b) => statusSort(a.status, b.status, bodyshop.md_ro_statuses?.parts_active_statuses) : true, sortOrder: sortcolumn === "status" && sortorder, render: (text, record) => { return record.status || t("general.labels.na"); }, filteredValue: filter?.status || null, - filters: bodyshop.md_ro_statuses.parts_statuses.map((s) => { + filters: bodyshop.md_ro_statuses?.parts_statuses.map((s) => { return { text: s, value: [s] }; }), onFilter: (value, record) => value.includes(record.status) diff --git a/client/src/components/task-list/task-list.component.jsx b/client/src/components/task-list/task-list.component.jsx index 96a587908..66a7e9825 100644 --- a/client/src/components/task-list/task-list.component.jsx +++ b/client/src/components/task-list/task-list.component.jsx @@ -19,6 +19,7 @@ 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"; +import { logImEXEvent } from "../../firebase/firebase.utils.js"; /** * Task List Component @@ -289,6 +290,7 @@ function TaskListComponent({ } else { delete search[param]; } + logImEXEvent("tasks_filter", { key: param }); history({ search: queryString.stringify(search) }); }, [history, search] diff --git a/client/src/components/task-list/task-list.container.jsx b/client/src/components/task-list/task-list.container.jsx index 7c2058814..4535e987e 100644 --- a/client/src/components/task-list/task-list.container.jsx +++ b/client/src/components/task-list/task-list.container.jsx @@ -13,6 +13,7 @@ import { createStructuredSelector } from "reselect"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js"; import dayjs from "../../utils/day"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +import { logImEXEvent } from "../../firebase/firebase.utils.js"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -107,6 +108,7 @@ export function TaskListContainer({ }) ); } + logImEXEvent("task_completed", {}); notification["success"]({ message: t("tasks.successes.completed") @@ -160,6 +162,7 @@ export function TaskListContainer({ ); } + logImEXEvent("task_deleted", {}); notification["success"]({ message: t("tasks.successes.deleted") }); diff --git a/client/src/components/task-upsert-modal/task-upsert-modal.container.jsx b/client/src/components/task-upsert-modal/task-upsert-modal.container.jsx index 9275c3b2e..1bb64b240 100644 --- a/client/src/components/task-upsert-modal/task-upsert-modal.container.jsx +++ b/client/src/components/task-upsert-modal/task-upsert-modal.container.jsx @@ -17,6 +17,7 @@ import AuditTrailMapping from "../../utils/AuditTrailMappings.js"; import { isEqual } from "lodash"; import refetchRouteMappings from "./task-upsert-modal.route.mappings"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +import { logImEXEvent } from "../../firebase/firebase.utils.js"; const mapStateToProps = createStructuredSelector({ currentUser: selectCurrentUser, @@ -180,7 +181,7 @@ export function TaskUpsertModalContainer({ bodyshop, currentUser, taskUpsert, to notification["success"]({ message: t("tasks.successes.updated") }); - + logImEXEvent("task_update", {}); toggleModalVisible(); }; @@ -217,7 +218,7 @@ export function TaskUpsertModalContainer({ bodyshop, currentUser, taskUpsert, to form.resetFields(); toggleModalVisible(); - + logImEXEvent("task_insert", {}); notification["success"]({ message: t("tasks.successes.created") }); diff --git a/client/src/components/vehicle-detail-form/vehicle-detail-form.container.jsx b/client/src/components/vehicle-detail-form/vehicle-detail-form.container.jsx index 98e868798..911b12e66 100644 --- a/client/src/components/vehicle-detail-form/vehicle-detail-form.container.jsx +++ b/client/src/components/vehicle-detail-form/vehicle-detail-form.container.jsx @@ -1,13 +1,14 @@ -import { useState } from "react"; -import { Button, Form, Popconfirm } from "antd"; import { PageHeader } from "@ant-design/pro-layout"; import { useMutation } from "@apollo/client"; -import VehicleDetailFormComponent from "./vehicle-detail-form.component"; +import { Button, Form, Popconfirm } from "antd"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; -import dayjs from "../../utils/day"; -import { DELETE_VEHICLE, UPDATE_VEHICLE } from "../../graphql/vehicles.queries"; import { useNavigate } from "react-router-dom"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +import { logImEXEvent } from "../../firebase/firebase.utils.js"; +import { DELETE_VEHICLE, UPDATE_VEHICLE } from "../../graphql/vehicles.queries"; +import dayjs from "../../utils/day"; +import VehicleDetailFormComponent from "./vehicle-detail-form.component"; function VehicleDetailFormContainer({ vehicle, refetch }) { const { t } = useTranslation(); @@ -20,6 +21,7 @@ function VehicleDetailFormContainer({ vehicle, refetch }) { const handleDelete = async () => { setLoading(true); + logImEXEvent("vehicle_delete", {}); const result = await deleteVehicle({ variables: { id: vehicle.id } }); @@ -42,6 +44,7 @@ function VehicleDetailFormContainer({ vehicle, refetch }) { const handleFinish = async (values) => { setLoading(true); + logImEXEvent("vehicle_update", {}); const result = await updateVehicle({ variables: { vehId: vehicle.id, vehicle: values } }); diff --git a/client/src/firebase/firebase.utils.js b/client/src/firebase/firebase.utils.js index 9d8d44b65..d3964eaac 100644 --- a/client/src/firebase/firebase.utils.js +++ b/client/src/firebase/firebase.utils.js @@ -76,9 +76,11 @@ export const logImEXEvent = (eventName, additionalParams, stateProp = null) => { try { const state = stateProp || store.getState(); + const eventParams = { shop: (state.user && state.user.bodyshop && state.user.bodyshop.shopname) || null, user: (state.user && state.user.currentUser && state.user.currentUser.email) || null, + partsManagementOnly: state?.user?.partsManagementOnly, ...additionalParams }; // axios.post("/ioevent", { @@ -89,12 +91,12 @@ export const logImEXEvent = (eventName, additionalParams, stateProp = null) => { // dbevent: false, // env: `master-AIO|${import.meta.env.VITE_APP_GIT_SHA_DATE}` // }); - // console.log( - // "%c[Analytics]", - // "background-color: green ;font-weight:bold;", - // eventName, - // eventParams - // ); + console.log( + "%c[Analytics]", + "background-color: green ;font-weight:bold;", + eventName, + eventParams + ); logEvent(analytics, eventName, eventParams); amplitude.track(eventName, eventParams); posthog.capture(eventName, eventParams); diff --git a/client/src/graphql/bills.queries.js b/client/src/graphql/bills.queries.js index 7a7fd2d8a..a9c3433fa 100644 --- a/client/src/graphql/bills.queries.js +++ b/client/src/graphql/bills.queries.js @@ -20,8 +20,8 @@ export const DELETE_BILL = gql` `; export const QUERY_ALL_BILLS_PAGINATED = gql` - query QUERY_ALL_BILLS_PAGINATED($offset: Int, $limit: Int, $order: [bills_order_by!]!) { - bills(offset: $offset, limit: $limit, order_by: $order) { + query QUERY_ALL_BILLS_PAGINATED($offset: Int, $limit: Int, $order: [bills_order_by!]!, $where: bills_bool_exp) { + bills(offset: $offset, limit: $limit, order_by: $order, where: $where) { id vendorid vendor { diff --git a/client/src/graphql/user.queries.js b/client/src/graphql/user.queries.js index c308453ee..682c0f541 100644 --- a/client/src/graphql/user.queries.js +++ b/client/src/graphql/user.queries.js @@ -5,6 +5,7 @@ export const QUERY_SHOP_ASSOCIATIONS = gql` associations(where: { shopid: { _eq: $shopid } }) { id authlevel + new_message_sound shopid user { email @@ -28,6 +29,26 @@ export const UPDATE_ASSOCIATION = gql` } `; +// Query to load the active association for a given user and get the new_message_sound flag +export const QUERY_ACTIVE_ASSOCIATION_SOUND = gql` + query QUERY_ACTIVE_ASSOCIATION_SOUND($email: String!) { + associations(where: { _and: { useremail: { _eq: $email }, active: { _eq: true } } }) { + id + new_message_sound + } + } +`; + +// Mutation to update just the new_message_sound field +export const UPDATE_NEW_MESSAGE_SOUND = gql` + mutation UPDATE_NEW_MESSAGE_SOUND($id: uuid!, $value: Boolean) { + update_associations_by_pk(pk_columns: { id: $id }, _set: { new_message_sound: $value }) { + id + new_message_sound + } + } +`; + export const INSERT_EULA_ACCEPTANCE = gql` mutation INSERT_EULA_ACCEPTANCE($eulaAcceptance: eula_acceptances_insert_input!) { insert_eula_acceptances_one(object: $eulaAcceptance) { @@ -77,6 +98,7 @@ export const QUERY_KANBAN_SETTINGS = gql` } } `; + export const UPDATE_KANBAN_SETTINGS = gql` mutation UPDATE_KANBAN_SETTINGS($id: uuid!, $ks: jsonb) { update_associations_by_pk(pk_columns: { id: $id }, _set: { kanban_settings: $ks }) { diff --git a/client/src/index.jsx b/client/src/index.jsx index 7132566a1..610aa8aad 100644 --- a/client/src/index.jsx +++ b/client/src/index.jsx @@ -26,7 +26,7 @@ registerSW({ immediate: true }); // Dinero.globalLocale = "en-CA"; Dinero.globalRoundingMode = "HALF_EVEN"; -amplitude.init("6228a598e57cd66875cfd41604f1f891", { +amplitude.init(import.meta.env.VITE_APP_AMP_KEY, { defaultTracking: true, serverUrl: import.meta.env.VITE_APP_AMP_URL // { diff --git a/client/src/pages/bills/bills.page.component.jsx b/client/src/pages/bills/bills.page.component.jsx index db9145984..e3d2f55b0 100644 --- a/client/src/pages/bills/bills.page.component.jsx +++ b/client/src/pages/bills/bills.page.component.jsx @@ -18,6 +18,7 @@ import { pageLimit } from "../../utils/config"; import { alphaSort, dateSort } from "../../utils/sorters"; import useLocalStorage from "../../utils/useLocalStorage"; import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries"; +import { logImEXEvent } from "../../firebase/firebase.utils"; const mapDispatchToProps = (dispatch) => ({ setBillEnterContext: (context) => dispatch(setModalContext({ context: context, modal: "billEnter" })) @@ -31,7 +32,7 @@ export function BillsListPage({ loading, data, refetch, total, setBillEnterConte const history = useNavigate(); const [state, setState] = useLocalStorage("bills_list_sort", { sortedInfo: {}, - filteredInfo: { text: "" } + filteredInfo: { vendorname: [] } }); const Templates = TemplateList("bill"); const { t } = useTranslation(); @@ -48,8 +49,7 @@ export function BillsListPage({ loading, data, refetch, total, setBillEnterConte vendor: { name: order === "descend" ? "desc" : "asc" } }), filters: (vendorsData?.vendors || []).map((v) => ({ text: v.name, value: v.id })), - filteredValue: state.filteredInfo.vendorname || null, - onFilter: (value, record) => record.vendorid === value, + filteredValue: search.vendorIds ? search.vendorIds.split(",") : null, sortOrder: state.sortedInfo.columnKey === "vendorname" && state.sortedInfo.order, render: (text, record) => {record.vendor.name} }, @@ -165,20 +165,37 @@ export function BillsListPage({ loading, data, refetch, total, setBillEnterConte ]; const handleTableChange = (pagination, filters, sorter) => { - // Persist filters (including vendorname) and sorting - setState({ ...state, filteredInfo: { ...state.filteredInfo, ...filters }, sortedInfo: sorter }); + setState({ + sortedInfo: sorter, + filteredInfo: { ...state.filteredInfo, vendorname: filters.vendorname || [] } + }); + search.page = pagination.current; + if (filters.vendorname && filters.vendorname.length) { + search.vendorIds = filters.vendorname.join(","); + } else { + delete search.vendorIds; + } if (sorter && sorter.column && sorter.column.sortObject) { search.searchObj = JSON.stringify(sorter.column.sortObject(sorter.order)); + delete search.sortcolumn; + delete search.sortorder; } else { delete search.searchObj; search.sortcolumn = sorter.order ? sorter.columnKey : null; search.sortorder = sorter.order; } - search.sort = JSON.stringify({ [sorter.columnKey]: sorter.order }); history({ search: queryString.stringify(search) }); + logImEXEvent("bills_list_sort_filter", { pagination, filters, sorter }); }; + useEffect(() => { + if (!search.vendorIds && state.filteredInfo.vendorname && state.filteredInfo.vendorname.length) { + search.vendorIds = state.filteredInfo.vendorname.join(","); + history({ search: queryString.stringify(search) }); + } + }, []); + useEffect(() => { if (search.search && search.search.trim() !== "") { searchBills(); @@ -192,6 +209,7 @@ export function BillsListPage({ loading, data, refetch, total, setBillEnterConte search: value || search.search, index: "bills" }); + logImEXEvent("bills_search", { search: value || search.search, results: searchData?.data?.hits?.hits?.length }); setOpenSearchResults(searchData.data.hits.hits.map((s) => s._source)); } catch (error) { console.log("Error while fetching search results", error); diff --git a/client/src/pages/bills/bills.page.container.jsx b/client/src/pages/bills/bills.page.container.jsx index 8ea150322..68c5d4c01 100644 --- a/client/src/pages/bills/bills.page.container.jsx +++ b/client/src/pages/bills/bills.page.container.jsx @@ -49,7 +49,8 @@ export function BillsPageContainer({ setBreadcrumbs, setSelectedHeader }) { : { [sortcolumn || "date"]: sortorder ? (sortorder === "descend" ? "desc" : "asc") : "desc" } - ] + ], + where: searchParams.vendorIds ? { vendorid: { _in: searchParams.vendorIds.split(",") } } : undefined } }); diff --git a/client/src/pages/contract-create/contract-create.page.container.jsx b/client/src/pages/contract-create/contract-create.page.container.jsx index e9b86ac4b..f1fc543ae 100644 --- a/client/src/pages/contract-create/contract-create.page.container.jsx +++ b/client/src/pages/contract-create/contract-create.page.container.jsx @@ -15,6 +15,7 @@ import InstanceRenderManager from "../../utils/instanceRenderMgr"; import ContractCreatePageComponent from "./contract-create.page.component"; import UpsellComponent, { upsellEnum } from "../../components/upsell/upsell.component"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +import { logImEXEvent } from "../../firebase/firebase.utils.js"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -57,6 +58,7 @@ export function ContractCreatePageContainer({ bodyshop, setBreadcrumbs, setSelec if (!result.errors) { //Update the courtesy car to have the damage. + logImEXEvent("courtesy_car_contract_created", {}); notification["success"]({ message: t("contracts.successes.saved") }); diff --git a/client/src/pages/jobs-create/jobs-create.container.jsx b/client/src/pages/jobs-create/jobs-create.container.jsx index 8c63b550d..85f2b3ffe 100644 --- a/client/src/pages/jobs-create/jobs-create.container.jsx +++ b/client/src/pages/jobs-create/jobs-create.container.jsx @@ -14,7 +14,7 @@ import InstanceRenderManager from "../../utils/instanceRenderMgr"; import JobsCreateComponent from "./jobs-create.component"; import JobCreateContext from "./jobs-create.context"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; - +import { logImEXEvent } from "../../firebase/firebase.utils"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop }); @@ -70,6 +70,7 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) { label: t("titles.bc.jobs-new") } ]); + logImEXEvent("manual_job_create_start", {}); }, [t, setBreadcrumbs, setSelectedHeader]); const runInsertJob = (job) => { @@ -81,6 +82,7 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) { error: null, newJobId: resp.data.insert_jobs.returning[0].id }); + logImEXEvent("manual_job_create_completed", {}); }) .catch((error) => { notification["error"]({ diff --git a/client/src/pages/manage/manage.page.component.jsx b/client/src/pages/manage/manage.page.component.jsx index 9685f469a..406371b32 100644 --- a/client/src/pages/manage/manage.page.component.jsx +++ b/client/src/pages/manage/manage.page.component.jsx @@ -20,7 +20,12 @@ import LoadingSpinner from "../../components/loading-spinner/loading-spinner.com import PartnerPingComponent from "../../components/partner-ping/partner-ping.component"; import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component"; import UpdateAlert from "../../components/update-alert/update-alert.component"; -import { selectBodyshop, selectInstanceConflict, selectPartsManagementOnly } from "../../redux/user/user.selectors"; +import { + selectBodyshop, + selectCurrentUser, + selectInstanceConflict, + selectPartsManagementOnly +} from "../../redux/user/user.selectors"; import InstanceRenderManager from "../../utils/instanceRenderMgr.js"; import useAlertsNotifications from "../../hooks/useAlertsNotifications.jsx"; import { selectDarkMode } from "../../redux/application/application.selectors.js"; @@ -109,10 +114,11 @@ const mapStateToProps = createStructuredSelector({ conflict: selectInstanceConflict, bodyshop: selectBodyshop, partsManagementOnly: selectPartsManagementOnly, - isDarkMode: selectDarkMode + isDarkMode: selectDarkMode, + currentUser: selectCurrentUser }); -export function Manage({ conflict, bodyshop, partsManagementOnly, isDarkMode }) { +export function Manage({ conflict, bodyshop, partsManagementOnly, isDarkMode, currentUser }) { const { t } = useTranslation(); const [chatVisible] = useState(false); const didMount = useRef(false); @@ -588,7 +594,7 @@ export function Manage({ conflict, bodyshop, partsManagementOnly, isDarkMode }) return ( <> - + diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 39e11fe12..aca3da3e2 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -144,6 +144,11 @@ "tasks_updated": "Task '{{title}}' updated by {{updatedBy}}" } }, + "audio": { + "manager": { + "description": "Click anywhere to enable the message ding." + } + }, "billlines": { "actions": { "newline": "New Line" @@ -3806,7 +3811,14 @@ "labels": { "actions": "Actions", "changepassword": "Change Password", - "profileinfo": "Profile Info" + "profileinfo": "Profile Info", + "user_settings": "User Settings", + "play_sound_for_new_messages": "Play a sound for new messages", + "notification_sound_on": "Sound is ON", + "notification_sound_off": "Sound is OFF", + "notification_sound_enabled": "Notification sound enabled", + "notification_sound_disabled": "Notification sound disabled", + "notification_sound_help": "Toggle the ding for incoming chat messages." }, "successess": { "passwordchanged": "Password changed successfully. " diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 84a6fcbf9..345712670 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -144,6 +144,11 @@ "tasks_updated": "" } }, + "audio": { + "manager": { + "description": "" + } + }, "billlines": { "actions": { "newline": "" @@ -3807,7 +3812,14 @@ "labels": { "actions": "", "changepassword": "", - "profileinfo": "" + "profileinfo": "", + "user_settings": "", + "play_sound_for_new_messages": "", + "notification_sound_on": "", + "notification_sound_off": "", + "notification_sound_enabled": "", + "notification_sound_disabled": "", + "notification_sound_help": "" }, "successess": { "passwordchanged": "" diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index 703afe3e2..c3f680059 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -144,6 +144,11 @@ "tasks_updated": "" } }, + "audio": { + "manager": { + "description": "" + } + }, "billlines": { "actions": { "newline": "" @@ -3807,7 +3812,14 @@ "labels": { "actions": "", "changepassword": "", - "profileinfo": "" + "profileinfo": "", + "user_settings": "", + "play_sound_for_new_messages": "", + "notification_sound_on": "", + "notification_sound_off": "", + "notification_sound_enabled": "", + "notification_sound_disabled": "", + "notification_sound_help": "" }, "successess": { "passwordchanged": "" diff --git a/client/src/utils/singleTabAudioLeader.js b/client/src/utils/singleTabAudioLeader.js new file mode 100644 index 000000000..fad3f496d --- /dev/null +++ b/client/src/utils/singleTabAudioLeader.js @@ -0,0 +1,164 @@ +// src/utils/singleTabAudioLeader.js +// Ensures only one tab ("leader") plays sounds per bodyshop. +// +// Storage key: localStorage["imex:sound:leader:"] = { id, ts } +// Channel: new BroadcastChannel("imex:sound:") + +const STORAGE_PREFIX = "imex:sound:leader:"; +const CHANNEL_PREFIX = "imex:sound:"; + +const TTL_MS = 60_000; // leader expires after 60s without heartbeat +const HEARTBEAT_MS = 20_000; // leader refresh interval +const WATCHDOG_MS = 10_000; // how often non-leaders check for stale leader + +const TAB_ID = + typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2); + +function channelSupported() { + try { + return "BroadcastChannel" in window; + } catch { + return false; + } +} + +function getChannel(bodyshopId) { + if (!channelSupported() || !bodyshopId) return null; + try { + return new BroadcastChannel(CHANNEL_PREFIX + String(bodyshopId)); + } catch { + return null; + } +} + +function lsKey(bodyshopId) { + return STORAGE_PREFIX + String(bodyshopId); +} + +function readLeader(bodyshopId) { + if (!bodyshopId) return null; + try { + const raw = localStorage.getItem(lsKey(bodyshopId)); + if (!raw) return null; + return JSON.parse(raw); + } catch { + return null; + } +} + +function writeLeader(record, bodyshopId) { + if (!bodyshopId) return; + try { + localStorage.setItem(lsKey(bodyshopId), JSON.stringify(record)); + const bc = getChannel(bodyshopId); + if (bc) { + bc.postMessage({ type: "leader-update", payload: { ...record, bodyshopId } }); + bc.close(); + } + } catch { + // ignore + } +} + +function removeLeader(bodyshopId) { + if (!bodyshopId) return; + try { + const cur = readLeader(bodyshopId); + if (cur?.id === TAB_ID) { + localStorage.removeItem(lsKey(bodyshopId)); + const bc = getChannel(bodyshopId); + if (bc) { + bc.postMessage({ type: "leader-removed", payload: { id: TAB_ID, bodyshopId } }); + bc.close(); + } + } + } catch { + // ignore + } +} + +function now() { + return Date.now(); +} + +function isStale(rec) { + return !rec || now() - rec.ts > TTL_MS; +} + +function claimLeadership(bodyshopId) { + const rec = { id: TAB_ID, ts: now() }; + writeLeader(rec, bodyshopId); + return rec; +} + +/** Is THIS tab currently the leader (and not stale)? */ +export function isLeaderTab(bodyshopId) { + const rec = readLeader(bodyshopId); + return !!rec && rec.id === TAB_ID && !isStale(rec); +} + +/** Force this tab to become the leader right now. */ +export function claimLeadershipNow(bodyshopId) { + return claimLeadership(bodyshopId); +} + +/** + * Initialize leader election/heartbeat for this tab (scoped by bodyshopId). + * Call once (e.g., in SoundWrapper). Returns a cleanup function. + */ +export function initSingleTabAudioLeader(bodyshopId) { + if (!bodyshopId) + return () => { + // + }; + + // If no leader or stale, try to claim after a tiny delay (reduce startup contention) + if (isStale(readLeader(bodyshopId))) { + setTimeout(() => claimLeadership(bodyshopId), 100); + } + + // If this tab becomes focused/visible, it can claim leadership + const onFocus = () => claimLeadership(bodyshopId); + const onVis = () => { + if (document.visibilityState === "visible") claimLeadership(bodyshopId); + }; + window.addEventListener("focus", onFocus); + document.addEventListener("visibilitychange", onVis); + + // Heartbeat from the leader to keep record fresh + const heartbeat = setInterval(() => { + if (!isLeaderTab(bodyshopId)) return; + writeLeader({ id: TAB_ID, ts: now() }, bodyshopId); + }, HEARTBEAT_MS); + + // Watchdog: if leader is stale, try to claim (even if we're not focused) + const watchdog = setInterval(() => { + const cur = readLeader(bodyshopId); + if (isStale(cur)) claimLeadership(bodyshopId); + }, WATCHDOG_MS); + + // If this tab was the leader, clean up on unload + const onUnload = () => removeLeader(bodyshopId); + window.addEventListener("beforeunload", onUnload); + + // Per-bodyshop BroadcastChannel listener (optional/no-op) + const bc = getChannel(bodyshopId); + const onBC = bc + ? () => { + // No state kept here; localStorage read is the source of truth. + } + : null; + if (bc && onBC) bc.addEventListener("message", onBC); + + return () => { + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onVis); + window.removeEventListener("beforeunload", onUnload); + clearInterval(heartbeat); + clearInterval(watchdog); + if (bc && onBC) { + bc.removeEventListener("message", onBC); + bc.close(); + } + }; +} diff --git a/client/src/utils/soundManager.js b/client/src/utils/soundManager.js new file mode 100644 index 000000000..bab9eec97 --- /dev/null +++ b/client/src/utils/soundManager.js @@ -0,0 +1,97 @@ +// src/utils/soundManager.js +// Handles audio init, autoplay unlock, and queued plays. +// When a tab successfully unlocks audio, it CLAIMS LEADERSHIP immediately for that bodyshop. + +import { claimLeadershipNow } from "./singleTabAudioLeader"; + +let baseAudio = null; +let unlocked = false; +let queuedPlays = 0; +let installingUnlockHandlers = false; + +/** + * Initialize the new-message sound. + * @param {string} url + * @param {number} volume + */ +export function initNewMessageSound(url, volume = 0.7) { + baseAudio = new Audio(url); + baseAudio.preload = "auto"; + baseAudio.volume = volume; +} + +/** Has this tab unlocked audio? (optional helper) */ +export function isAudioUnlocked() { + return unlocked; +} + +/** + * Unlocks audio if not already unlocked. + * On success, this tab immediately becomes the sound LEADER for the given bodyshop. + */ +export async function unlockAudio(bodyshopId) { + if (unlocked) return; + try { + // Chrome/Safari: playing any media (even muted) after a gesture unlocks audio. + const a = new Audio(); + a.muted = true; + await a.play().catch(() => { + // ignore + }); + unlocked = true; + + // Immediately become the leader because THIS tab can actually play sound. + claimLeadershipNow(bodyshopId); + + // Flush exactly one queued ding (avoid spamming if many queued while locked) + if (queuedPlays > 0 && baseAudio) { + queuedPlays = 0; + const b = baseAudio.cloneNode(true); + b.play().catch(() => { + // ignore + }); + } + } finally { + removeUnlockListeners(); + } +} + +/** Installs listeners to unlock audio on first gesture. */ +function addUnlockListeners(bodyshopId) { + if (installingUnlockHandlers) return; + installingUnlockHandlers = true; + const handler = () => unlockAudio(bodyshopId); + window.addEventListener("click", handler, { once: true, passive: true }); + window.addEventListener("touchstart", handler, { once: true, passive: true }); + window.addEventListener("keydown", handler, { once: true }); +} + +/** Removes listeners to unlock audio on first gesture. */ +function removeUnlockListeners() { + // With {once:true} they self-remove; we only reset the flag. + installingUnlockHandlers = false; +} + +/** + * Plays the new-message ding. If blocked, queue one and wait for first gesture. + */ +export async function playNewMessageSound(bodyshopId) { + if (!baseAudio) return; + try { + const a = baseAudio.cloneNode(true); + await a.play(); + } catch (err) { + // Most common: NotAllowedError due to missing prior gesture + if (err?.name === "NotAllowedError") { + queuedPlays = Math.min(queuedPlays + 1, 1); // cap at 1 + addUnlockListeners(bodyshopId); + + // Let the app know we need user interaction (optional UI prompt) + window.dispatchEvent(new CustomEvent("sound-needs-unlock")); + return; + } + // Other errors can be logged + + console.error("Audio play error:", err); + } +} diff --git a/client/vite.config.js b/client/vite.config.js index 4f3ef6df0..17a05b256 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -220,7 +220,7 @@ export default defineConfig({ // Strip console/debugger in prod to shrink bundles esbuild: { - drop: ["console", "debugger"] + //drop: ["console", "debugger"] }, optimizeDeps: { diff --git a/hasura/metadata/cron_triggers.yaml b/hasura/metadata/cron_triggers.yaml index cc1baa225..a1b4b7f4a 100644 --- a/hasura/metadata/cron_triggers.yaml +++ b/hasura/metadata/cron_triggers.yaml @@ -51,12 +51,13 @@ comment: "" - name: Rome Usage Report webhook: '{{HASURA_API_URL}}/data/usagereport' - schedule: 0 12 * * 5 + schedule: 0 12 * * 3,5 include_in_metadata: true payload: {} headers: - name: x-imex-auth value_from_env: DATAPUMP_AUTH + comment: "" - name: Task Reminders webhook: '{{HASURA_API_URL}}/tasks-remind-handler' schedule: '*/15 * * * *' diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index 65ed49949..74a00b23b 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -215,6 +215,7 @@ - default_prod_list_view - id - kanban_settings + - new_message_sound - notification_settings - notifications_autoadd - qbo_realmId @@ -232,6 +233,7 @@ - authlevel - default_prod_list_view - kanban_settings + - new_message_sound - notification_settings - notifications_autoadd - qbo_realmId @@ -942,6 +944,7 @@ - autohouseid - bill_allow_post_to_closed - bill_tax_rates + - carfax_exclude - cdk_configuration - cdk_dealerid - chatterid diff --git a/hasura/migrations/1758722733666_alter_table_public_associations_add_column_new_message_sound/down.sql b/hasura/migrations/1758722733666_alter_table_public_associations_add_column_new_message_sound/down.sql new file mode 100644 index 000000000..6091a1d6a --- /dev/null +++ b/hasura/migrations/1758722733666_alter_table_public_associations_add_column_new_message_sound/down.sql @@ -0,0 +1,4 @@ +-- Could not auto-generate a down migration. +-- Please write an appropriate down migration for the SQL below: +-- alter table "public"."associations" add column "new_message_sound" boolean +-- null; diff --git a/hasura/migrations/1758722733666_alter_table_public_associations_add_column_new_message_sound/up.sql b/hasura/migrations/1758722733666_alter_table_public_associations_add_column_new_message_sound/up.sql new file mode 100644 index 000000000..b5da28510 --- /dev/null +++ b/hasura/migrations/1758722733666_alter_table_public_associations_add_column_new_message_sound/up.sql @@ -0,0 +1,2 @@ +alter table "public"."associations" add column "new_message_sound" boolean + null; diff --git a/hasura/migrations/1758723347874_alter_table_public_associations_alter_column_new_message_sound/down.sql b/hasura/migrations/1758723347874_alter_table_public_associations_alter_column_new_message_sound/down.sql new file mode 100644 index 000000000..394c43f97 --- /dev/null +++ b/hasura/migrations/1758723347874_alter_table_public_associations_alter_column_new_message_sound/down.sql @@ -0,0 +1 @@ +ALTER TABLE "public"."associations" ALTER COLUMN "new_message_sound" drop default; diff --git a/hasura/migrations/1758723347874_alter_table_public_associations_alter_column_new_message_sound/up.sql b/hasura/migrations/1758723347874_alter_table_public_associations_alter_column_new_message_sound/up.sql new file mode 100644 index 000000000..3168f3b74 --- /dev/null +++ b/hasura/migrations/1758723347874_alter_table_public_associations_alter_column_new_message_sound/up.sql @@ -0,0 +1 @@ +alter table "public"."associations" alter column "new_message_sound" set default 'true'; diff --git a/hasura/migrations/1758833517953_alter_table_public_bodyshops_add_column_carfax_exclude/down.sql b/hasura/migrations/1758833517953_alter_table_public_bodyshops_add_column_carfax_exclude/down.sql new file mode 100644 index 000000000..1667505c0 --- /dev/null +++ b/hasura/migrations/1758833517953_alter_table_public_bodyshops_add_column_carfax_exclude/down.sql @@ -0,0 +1,4 @@ +-- Could not auto-generate a down migration. +-- Please write an appropriate down migration for the SQL below: +-- alter table "public"."bodyshops" add column "carfax_exclude" boolean +-- not null default 'false'; diff --git a/hasura/migrations/1758833517953_alter_table_public_bodyshops_add_column_carfax_exclude/up.sql b/hasura/migrations/1758833517953_alter_table_public_bodyshops_add_column_carfax_exclude/up.sql new file mode 100644 index 000000000..64df7a469 --- /dev/null +++ b/hasura/migrations/1758833517953_alter_table_public_bodyshops_add_column_carfax_exclude/up.sql @@ -0,0 +1,2 @@ +alter table "public"."bodyshops" add column "carfax_exclude" boolean + not null default 'false'; diff --git a/server/data/carfax.js b/server/data/carfax.js index b0792813d..34a145dda 100644 --- a/server/data/carfax.js +++ b/server/data/carfax.js @@ -99,7 +99,7 @@ exports.default = async (req, res) => { await processShopData(shopsToProcess, start, end, skipUpload, ignoreDateFilter, allXMLResults, allErrors); await sendServerEmail({ - subject: `CARFAX Report ${moment().format("MM-DD-YY")}`, + subject: `Project Mexico Report ${moment().format("MM-DD-YY")}`, text: `Errors:\n${JSON.stringify(allErrors, null, 2)}\n\nUploaded:\n${JSON.stringify( allXMLResults.map((x) => ({ imexshopid: x.imexshopid, @@ -164,35 +164,36 @@ async function processShopData(shopsToProcess, start, end, skipUpload, ignoreDat if (skipUpload) { fs.writeFileSync(`./logs/${jsonObj.filename}`, jsonObj.json); + uploadToS3(jsonObj); } else { await uploadViaSFTP(jsonObj); - } - await sendMexicoBillingEmail({ - subject: `${shopid.toUpperCase()}_Mexico${InstanceManager({ - imex: "IO", - rome: "RO" - })}_${moment().format("MMDDYYYY")} ROs ${jsonObj.count} Error ${errorCode(jsonObj)}`, - text: `Errors:\n${JSON.stringify( - erroredJobs.map((ej) => ({ - ro_number: ej.job?.ro_number, - jobid: ej.job?.id, - error: ej.error - })), - null, - 2 - )}\n\nUploaded:\n${JSON.stringify( - { - bodyshopid: bodyshop.id, - imexshopid: shopid, - count: jsonObj.count, - filename: jsonObj.filename, - result: jsonObj.result - }, - null, - 2 - )}` - }); + await sendMexicoBillingEmail({ + subject: `${shopid.replace(/_/g, "").toUpperCase()}_Mexico${InstanceManager({ + imex: "IO", + rome: "RO" + })}_${moment().format("MMDDYYYY")} ROs ${jsonObj.count} Error ${errorCode(jsonObj)}`, + text: `Errors:\n${JSON.stringify( + erroredJobs.map((ej) => ({ + ro_number: ej.job?.ro_number, + jobid: ej.job?.id, + error: ej.error + })), + null, + 2 + )}\n\nUploaded:\n${JSON.stringify( + { + bodyshopid: bodyshop.id, + imexshopid: shopid, + count: jsonObj.count, + filename: jsonObj.filename, + result: jsonObj.result + }, + null, + 2 + )}` + }); + } allXMLResults.push({ bodyshopid: bodyshop.id, @@ -292,17 +293,22 @@ const CreateRepairOrderTag = (job, errorCallback) => { v_make: job.v_make_desc || "", v_model: job.v_model_desc || "", - date_estimated: - (job.date_estimated && moment(job.date_estimated).tz(job.bodyshop.timezone).format(AHDateFormat)) || - (job.created_at && moment(job.created_at).tz(job.bodyshop.timezone).format(AHDateFormat)) || - "", - data_opened: - (job.date_open && moment(job.date_open).tz(job.bodyshop.timezone).format(AHDateFormat)) || - (job.created_at && moment(job.created_at).tz(job.bodyshop.timezone).format(AHDateFormat)) || - "", - date_invoiced: - (job.date_invoiced && moment(job.date_invoiced).tz(job.bodyshop.timezone).format(AHDateFormat)) || "", - loss_date: (job.loss_date && moment(job.loss_date).format(AHDateFormat)) || "", + date_estimated: [job.date_estimated, job.created_at].find((date) => date) + ? moment([job.date_open, job.created_at].find((date) => date)) + .tz(job.bodyshop.timezone) + .format(AHDateFormat) + : "", + data_opened: [job.date_open, job.created_at].find((date) => date) + ? moment([job.date_open, job.created_at].find((date) => date)) + .tz(job.bodyshop.timezone) + .format(AHDateFormat) + : "", + date_invoiced: [job.date_invoiced, job.actual_delivery, job.actual_completion].find((date) => date) + ? moment([job.date_invoiced, job.actual_delivery, job.actual_completion].find((date) => date)) + .tz(job.bodyshop.timezone) + .format(AHDateFormat) + : "", + loss_date: job.loss_date ? moment(job.loss_date).format(AHDateFormat) : "", ins_co_nm: job.ins_co_nm || "", loss_desc: job.loss_desc || "", @@ -329,7 +335,9 @@ const GenerateDetailLines = (line) => { line_desc: line.line_desc ? line.line_desc.replace(NON_ASCII_REGEX, "") : null, oem_partno: line.oem_partno ? line.oem_partno.replace(NON_ASCII_REGEX, "") : null, alt_partno: line.alt_partno ? line.alt_partno.replace(NON_ASCII_REGEX, "") : null, + op_code_desc: line.op_code_desc ? line.op_code_desc.replace(NON_ASCII_REGEX, "") : null, lbr_ty: generateLaborType(line.mod_lbr_ty), + lbr_hrs: line.mod_lb_hrs || 0, part_qty: line.part_qty || 0, part_type: generatePartType(line.part_type), act_price: line.act_price || 0 diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 26bbd70fd..6ccea6c3f 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -879,39 +879,43 @@ exports.CHATTER_QUERY = `query CHATTER_EXPORT($start: timestamptz, $bodyshopid: }`; exports.CARFAX_QUERY = `query CARFAX_EXPORT($start: timestamptz, $bodyshopid: uuid!, $end: timestamptz) { - bodyshops_by_pk(id: $bodyshopid){ + bodyshops_by_pk(id: $bodyshopid) { id shopname imexshopid timezone } - jobs(where: {_and: [{converted: {_eq: true}}, {v_vin: {_is_null: false}}, {date_invoiced: {_gt: $start}}, {date_invoiced: {_lte: $end}}, {shopid: {_eq: $bodyshopid}}]}) { - id + jobs(where: {_and: [{_or: [{date_invoiced: {_gt: $start, _lte: $end}}, {actual_delivery: {_gt: $start, _lte: $end}, date_invoiced: {_is_null: true}}, {actual_completion: {_gt: $start, _lte: $end}, actual_delivery: {_is_null: true}, date_invoiced: {_is_null: true}}]}, {_not: {_and: [{date_invoiced: {_is_null: true}}, {actual_delivery: {_is_null: true}}, {actual_completion: {_is_null: true}}]}}, {shopid: {_eq: $bodyshopid}}, {voided: {_neq: true}}, {v_vin: {_is_null: false}}]}) { + actual_completion + actual_delivery + area_of_damage created_at - ro_number - v_model_yr - v_model_desc - v_make_desc - v_vin date_estimated - date_open date_invoiced - loss_date + date_open + id ins_co_nm + job_totals + joblines(where: {removed: {_eq: false}}) { + act_price + alt_partno + line_desc + mod_lb_hrs + mod_lbr_ty + oem_partno + op_code_desc + part_type + part_qty + } + loss_date loss_desc + ro_number theft_ind tlos_ind - job_totals - area_of_damage - joblines(where: {removed: {_eq: false}}) { - line_desc - oem_partno - alt_partno - mod_lbr_ty - part_qty - part_type - act_price - } + v_make_desc + v_model_desc + v_model_yr + v_vin } }`; @@ -1854,7 +1858,7 @@ exports.GET_CHATTER_SHOPS = `query GET_CHATTER_SHOPS { }`; exports.GET_CARFAX_SHOPS = `query GET_CARFAX_SHOPS { - bodyshops(where: {external_shop_id: {_is_null: true}}){ + bodyshops(where: {external_shop_id: {_is_null: true}, carfax_exclude: {_neq: "true"}}){ id shopname imexshopid