import { useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { connect } from "react-redux"; import { useTranslation } from "react-i18next"; import { createStructuredSelector } from "reselect"; import queryString from "query-string"; import { useQuery } from "@apollo/client/react"; import { Button, Card, Col, Result, Row, Select, Space, Switch } from "antd"; import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement"; import { useSocket } from "../../contexts/SocketIO/useSocket.js"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { QUERY_JOB_EXPORT_DMS } from "../../graphql/jobs.queries"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; import InstanceRenderManager from "../../utils/instanceRenderMgr"; import AuditTrailMapping from "../../utils/AuditTrailMappings"; import { bodyshopHasDmsKey, DMS_MAP, getDmsMode, isWssMode } from "../../utils/dmsUtils.js"; import legacySocket from "../../utils/legacySocket"; import { OwnerNameDisplayFunction } from "../../components/owner-name-display/owner-name-display.component"; import AlertComponent from "../../components/alert/alert.component"; import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component"; import DmsPostForm from "../../components/dms-post-form/dms-post-form.component"; import DmsLogEvents from "../../components/dms-log-events/dms-log-events.component"; import DmsCustomerSelector from "../../components/dms-customer-selector/dms-customer-selector.component"; import DmsAllocationsSummary from "../../components/dms-allocations-summary/dms-allocations-summary.component"; import RrAllocationsSummary from "../../components/dms-allocations-summary/rr-dms-allocations-summary.component"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, currentUser: selectCurrentUser }); const mapDispatchToProps = (dispatch) => ({ setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)), setSelectedHeader: (key) => dispatch(setSelectedHeader(key)), insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type })) }); export default connect(mapStateToProps, mapDispatchToProps)(DmsContainer); const DMS_SOCKET_EVENTS = { [DMS_MAP.reynolds]: { log: "rr-log-event", partialResult: "rr-export-job:result", validationNeeded: "rr-validation-required", exportSuccess: "export-success", exportFailed: "export-failed" }, [DMS_MAP.fortellis]: { log: "fortellis-log-event", exportSuccess: "export-success", exportFailed: "export-failed" }, [DMS_MAP.cdk]: { log: "log-event", exportSuccess: "export-success", exportFailed: "export-failed" }, [DMS_MAP.pbs]: { log: "log-event", exportSuccess: "export-success", exportFailed: "export-failed" } }; const stripRrXmlFromPayload = (input) => { if (input == null || typeof input !== "object") return input; let target = null; try { target = JSON.parse(JSON.stringify(input)); } catch { // Fallback to in-place scrub if cloning fails. target = input; } const scrub = (node) => { if (node == null || typeof node !== "object") return; if (Array.isArray(node)) { node.forEach(scrub); return; } delete node.requestXml; delete node.responseXml; if (node.xml && typeof node.xml === "object") { delete node.xml.request; delete node.xml.response; if (Object.keys(node.xml).length === 0) delete node.xml; } Object.values(node).forEach(scrub); }; scrub(target); return target; }; export function DmsContainer({ bodyshop, currentUser, setBreadcrumbs, setSelectedHeader, insertAuditTrail }) { const { treatments: { Fortellis } } = useTreatmentsWithConfig({ attributes: {}, names: ["Fortellis"], splitKey: bodyshop.imexshopid }); const { t } = useTranslation(); const [resetAfterReconnect, setResetAfterReconnect] = useState(false); const [allocationsSummary, setAllocationsSummary] = useState(null); const [reconnectNonce, setReconnectNonce] = useState(0); const isDevEnv = import.meta.env.DEV; const isProdEnv = import.meta.env.PROD; const userEmail = (currentUser?.email || "").toLowerCase(); const devEmails = ["imex.dev", "rome.dev"]; const prodEmails = ["imex.prod", "rome.prod", "imex.test", "rome.test"]; const hasValidEmail = (emails) => emails.some((email) => userEmail.endsWith(email)); const canViewSensitiveRrXml = (isDevEnv && hasValidEmail(devEmails)) || (isProdEnv && hasValidEmail(prodEmails)); // Compute a single normalized mode and pick the proper socket const mode = getDmsMode(bodyshop, Fortellis.treatment); // "rr" | "fortellis" | "cdk" | "pbs" | "none" // RR-only: derive default OpCode parts from bodyshop RR configuration const isRrMode = mode === DMS_MAP.reynolds; const deriveDefaultRrOpCodeParts = () => { if (!isRrMode) return null; const cfg = bodyshop?.rr_configuration || {}; // Adjust these paths to match your real schema. const defaults = cfg.opCodeDefault || cfg.op_code_default || cfg.op_codes?.default || cfg.defaults?.opCode || cfg.defaults || cfg.default || {}; const prefix = defaults.prefix ?? defaults.opCodePrefix ?? ""; const base = defaults.base ?? defaults.opCodeBase ?? ""; const suffix = defaults.suffix ?? defaults.opCodeSuffix ?? ""; return { prefix, base, suffix }; }; const [rrOpCodeParts, setRrOpCodeParts] = useState(() => deriveDefaultRrOpCodeParts()); const history = useNavigate(); const search = queryString.parse(useLocation().search); const { jobId } = search; const notification = useNotification(); const { socket: wsssocket, reconnectSocket } = useSocket(); const activeSocket = useMemo(() => (isWssMode(mode) ? wsssocket : legacySocket), [mode, wsssocket]); const [isConnected, setIsConnected] = useState(!!activeSocket?.connected); // One place to set log level const [logLevel, setLogLevel] = useState(mode === DMS_MAP.pbs ? "INFO" : "DEBUG"); const rrOpCodeCombined = useMemo(() => { if (!rrOpCodeParts || !rrOpCodeParts.base) return ""; const { prefix, base, suffix } = rrOpCodeParts; return `${prefix || ""}${base}${suffix || ""}`; }, [rrOpCodeParts]); const setActiveLogLevel = (level) => { if (!activeSocket) return; activeSocket.emit("set-log-level", level); }; const [logs, setLogs] = useState([]); const [detailsOpen, setDetailsOpen] = useState(false); const [detailsNonce, setDetailsNonce] = useState(0); const [colorizeJson, setColorizeJson] = useState(false); const [rrOpenRoLimit, setRrOpenRoLimit] = useState(false); const clearRrOpenRoLimit = () => setRrOpenRoLimit(false); const [rrValidationPending, setrrValidationPending] = useState(false); const { loading, error, data } = useQuery(QUERY_JOB_EXPORT_DMS, { variables: { id: jobId }, skip: !jobId, fetchPolicy: "network-only", nextFetchPolicy: "network-only" }); const logsRef = useRef(null); const toggleDetailsAll = () => { setDetailsOpen((v) => !v); setDetailsNonce((n) => n + 1); }; // Channel names per mode to avoid branching everywhere const channels = useMemo(() => DMS_SOCKET_EVENTS[mode] || {}, [mode]); const providerLabel = useMemo( () => ({ [DMS_MAP.reynolds]: t("dms.labels.provider_reynolds"), [DMS_MAP.fortellis]: t("dms.labels.provider_fortellis"), [DMS_MAP.cdk]: t("dms.labels.provider_cdk"), [DMS_MAP.pbs]: t("dms.labels.provider_pbs") })[mode] || t("dms.labels.provider_dms"), [mode, t] ); const transportLabel = isWssMode(mode) ? t("dms.labels.transport_wss") : t("dms.labels.transport_ws"); const bannerMessage = t("dms.labels.banner_message", { provider: providerLabel, transport: transportLabel, status: isConnected ? t("dms.labels.banner_status_connected") : t("dms.labels.banner_status_disconnected") }); const resetKey = useMemo(() => `${mode || "none"}-${jobId || "none"}`, [mode, jobId]); const customerSelectorKey = useMemo(() => `${resetKey}-${reconnectNonce}`, [resetKey, reconnectNonce]); const handleReconnectClick = async () => { setResetAfterReconnect(true); setReconnectNonce((n) => n + 1); if (!activeSocket) return; if (isWssMode(mode)) { setActiveLogLevel(logLevel); const didReconnect = await reconnectSocket?.({ forceRefreshToken: true }); if (!didReconnect) { activeSocket.disconnect(); setTimeout(() => activeSocket.connect(), 100); } return; } activeSocket.disconnect(); setTimeout(() => activeSocket.connect(), 100); }; // 🔄 Hard reset of local + server-side DMS context when the page/job loads useEffect(() => { // Clear any local ephemeral state that might be stale setLogs([]); setRrOpenRoLimit(false); setrrValidationPending(false); setAllocationsSummary(null); // RR OpCode parts: reset to config defaults when job/mode changes setRrOpCodeParts(deriveDefaultRrOpCodeParts()); if (!activeSocket) return; const emitReset = () => { // Generic reset; server can branch on `mode` if needed activeSocket.emit("dms-reset-context", { jobId, mode }); }; if (activeSocket.connected) { // WSS usually lands here emitReset(); return; } // Legacy WS: wait for the connect before emitting reset const handleConnectOnce = () => { emitReset(); activeSocket.off("connect", handleConnectOnce); }; activeSocket.on("connect", handleConnectOnce); return () => { activeSocket.off("connect", handleConnectOnce); }; }, [jobId, mode, activeSocket]); const handleExportFailed = (payload = {}) => { const safePayload = canViewSensitiveRrXml ? payload : stripRrXmlFromPayload(payload); const { title, friendlyMessage, error: errText, severity, errorCode, vendorStatusCode } = payload; const msg = friendlyMessage || errText || t("dms.errors.exportfailedgeneric", "We couldn't complete the export. Please try again."); const vendorTitle = title || (isRrMode ? t("dms.labels.provider_reynolds") : t("dms.labels.provider_dms")); const isRrOpenRoLimit = isRrMode && (vendorStatusCode === 507 || /MAX_OPEN_ROS/i.test(String(errorCode || "")) || /maximum number of open repair orders/i.test(String(msg || "").toLowerCase())); const sev = severity || (isRrOpenRoLimit ? "warning" : "error"); if (!isRrOpenRoLimit) { const type = sev === "warning" && typeof notification.warning === "function" ? "warning" : "error"; notification.open({ type, tile: vendorTitle, description: msg, duration: 10 }); } else { setRrOpenRoLimit(true); } setLogs((prev) => [ ...prev, { timestamp: new Date(), level: (sev || "error").toUpperCase(), message: `${vendorTitle}: ${msg}`, meta: { errorCode, vendorStatusCode, raw: safePayload, blockedByOpenRoLimit: !!isRrOpenRoLimit } } ]); }; // keep this in sync if mode/socket flips useEffect(() => { setIsConnected(!!activeSocket?.connected); }, [activeSocket]); useEffect(() => { document.title = t("titles.dms", { app: InstanceRenderManager({ imex: "$t(titles.imexonline)", rome: "$t(titles.romeonline)" }) }); setSelectedHeader("dms"); setBreadcrumbs([ { link: "/manage/accounting/receivables", label: t("titles.bc.accounting-receivables") } // { link: "/manage/dms", label: t("titles.bc.dms") } ]); }, [t, setBreadcrumbs, setSelectedHeader]); // Socket wiring (mode-aware) useEffect(() => { if (!activeSocket) return; // Connect legacy socket if needed if (!isWssMode(mode)) { if (activeSocket.disconnected) activeSocket.connect(); } // Set log level now and on connect/reconnect setActiveLogLevel(logLevel); const onConnect = () => { setIsConnected(true); setActiveLogLevel(logLevel); if (resetAfterReconnect) { activeSocket.emit("dms-reset-context", { jobId, mode }); setResetAfterReconnect(false); } }; const onDisconnect = () => setIsConnected(false); const onReconnect = () => { setIsConnected(true); setLogs((prev) => [ ...prev, { timestamp: new Date(), level: "warn", message: t("dms.labels.reconnected_export_service", { provider: isRrMode ? t("dms.labels.provider_reynolds") : providerLabel }) } ]); }; const onConnectError = (err) => { // Legacy and WSS both emit this console.log(`connect_error due to ${err}`, err); notification.error({ title: err.message }); }; activeSocket.on("disconnect", onDisconnect); activeSocket.on("connect", onConnect); activeSocket.on("reconnect", onReconnect); activeSocket.on("connect_error", onConnectError); // Logs const onLog = isRrMode ? (payload = {}) => { const safePayload = canViewSensitiveRrXml ? payload : stripRrXmlFromPayload(payload); const normalized = { timestamp: safePayload.timestamp ? new Date(safePayload.timestamp) : safePayload.ts ? new Date(safePayload.ts) : new Date(), level: (safePayload.level || "INFO").toUpperCase(), message: safePayload.message || safePayload.msg || "", meta: safePayload.meta ?? safePayload.ctx ?? safePayload.details ?? null }; setLogs((prev) => [...prev, normalized]); } : (payload) => setLogs((prev) => [...prev, payload]); if (channels.log) activeSocket.on(channels.log, onLog); // Success / Failed const onExportSuccess = (payload) => { const jobIdResolved = payload?.jobId ?? payload; notification.success({ title: t("jobs.successes.exported") }); // Clear RR Validation flag if any setrrValidationPending(false); insertAuditTrail({ jobid: jobIdResolved, operation: AuditTrailMapping.jobexported(), type: "jobexported" }); history("/manage/accounting/receivables"); }; if (channels.exportSuccess) activeSocket.on(channels.exportSuccess, onExportSuccess); if (channels.exportFailed) activeSocket.on(channels.exportFailed, handleExportFailed); // RR-only extras const onPartialResult = () => { setrrValidationPending(true); setLogs((prev) => [ ...prev, { timestamp: new Date(), level: "INFO", message: t("dms.labels.rr_validation_message") } ]); notification.info({ title: t("dms.labels.rr_validation_notice_title"), description: t("dms.labels.rr_validation_notice_description"), duration: 8 }); }; const onValidationRequired = (payload) => { setrrValidationPending(true); setLogs((prev) => [ ...prev, { timestamp: new Date(), level: "INFO", message: t("dms.labels.rr_validation_message"), meta: { payload } } ]); }; if (isRrMode && channels.partialResult) activeSocket.on(channels.partialResult, onPartialResult); if (isRrMode && channels.validationNeeded) activeSocket.on(channels.validationNeeded, onValidationRequired); return () => { activeSocket.off("connect", onConnect); activeSocket.off("reconnect", onReconnect); activeSocket.off("connect_error", onConnectError); activeSocket.off("disconnect", onDisconnect); if (channels.log) activeSocket.off(channels.log, onLog); if (channels.exportSuccess) activeSocket.off(channels.exportSuccess, onExportSuccess); if (channels.exportFailed) activeSocket.off(channels.exportFailed, handleExportFailed); if (isRrMode && channels.partialResult) activeSocket.off(channels.partialResult, onPartialResult); if (isRrMode && channels.validationNeeded) activeSocket.off(channels.validationNeeded, onValidationRequired); // Only tear down legacy socket listeners; don't disconnect WSS from here if (!isWssMode(mode)) { activeSocket.removeAllListeners(); activeSocket.disconnect(); } }; }, [ mode, activeSocket, channels, logLevel, notification, t, insertAuditTrail, history, isRrMode, providerLabel, canViewSensitiveRrXml ]); // RR finalize callback (unchanged public behavior) const handleRrValidationFinished = () => { if (!jobId) return; if (!isWssMode(mode)) return; // RR is WSS-only activeSocket.emit("rr-finalize-repair-order", { jobId }, (ack) => { if (ack?.ok) return; if (ack?.error) notification.error({ title: ack.error }); }); }; if (loading) return ; if (error) return ; if (!jobId || !bodyshopHasDmsKey(bodyshop) || !data?.jobs_by_pk) return ; if (data.jobs_by_pk?.date_exported) return ; // Check if Reynolds mode requires early RO const hasEarlyRO = !!(data.jobs_by_pk?.dms_id && data.jobs_by_pk?.dms_customer_id && data.jobs_by_pk?.dms_advisor_id); if (isRrMode && !hasEarlyRO) { return ( } /> ); } return (
{!isRrMode ? ( {`${data?.jobs_by_pk && data.jobs_by_pk.ro_number}`} {` | ${OwnerNameDisplayFunction(data.jobs_by_pk)} | ${data.jobs_by_pk.v_model_yr || ""} ${ data.jobs_by_pk.v_make_desc || "" } ${data.jobs_by_pk.v_model_desc || ""}`} } socket={activeSocket} jobId={jobId} mode={mode} /> ) : ( {data?.jobs_by_pk && data.jobs_by_pk.ro_number} {` | ${OwnerNameDisplayFunction(data.jobs_by_pk)} | ${ data.jobs_by_pk.v_model_yr || "" } ${data.jobs_by_pk.v_make_desc || ""} ${data.jobs_by_pk.v_model_desc || ""}`} } socket={activeSocket} jobId={jobId} opCode={rrOpCodeCombined} /> )}
{isRrMode && ( <> )}