diff --git a/client/src/App/App.styles.scss b/client/src/App/App.styles.scss index 87e33c2db..aedfbd15c 100644 --- a/client/src/App/App.styles.scss +++ b/client/src/App/App.styles.scss @@ -443,6 +443,30 @@ flex-direction: column; } +/* DMS top panels: prevent card/table overflow into adjacent column at desktop+zoom */ +.dms-top-panel-col { + min-width: 0; +} + +.dms-top-panel-col > .ant-card { + width: 100%; + min-width: 0; + max-width: 100%; +} + +.dms-top-panel-col > .ant-card .ant-card-body { + min-width: 0; + max-width: 100%; +} + +.dms-top-panel-col .ant-table-wrapper, +.dms-top-panel-col .ant-tabs, +.dms-top-panel-col .ant-tabs-content, +.dms-top-panel-col .ant-tabs-tabpane { + min-width: 0; + max-width: 100%; +} + //.rbc-time-header-gutter { // padding: 0; //} diff --git a/client/src/components/dms-log-events/dms-log-events.component.jsx b/client/src/components/dms-log-events/dms-log-events.component.jsx index d90623df6..916d577d1 100644 --- a/client/src/components/dms-log-events/dms-log-events.component.jsx +++ b/client/src/components/dms-log-events/dms-log-events.component.jsx @@ -4,6 +4,7 @@ import dayjs from "../../utils/day"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { selectDarkMode } from "../../redux/application/application.selectors.js"; +import { useTranslation } from "react-i18next"; const mapStateToProps = createStructuredSelector({ isDarkMode: selectDarkMode @@ -21,23 +22,32 @@ export function DmsLogEvents({ colorizeJson = false, showDetails = true }) { + const { t } = useTranslation(); const [openSet, setOpenSet] = useState(() => new Set()); + const [copiedKey, setCopiedKey] = useState(null); // Inject JSON highlight styles once (only when colorize is enabled) useEffect(() => { if (!colorizeJson) return; if (typeof document === "undefined") return; - if (document.getElementById("json-highlight-styles")) return; - const style = document.createElement("style"); - style.id = "json-highlight-styles"; + let style = document.getElementById("json-highlight-styles"); + if (!style) { + style = document.createElement("style"); + style.id = "json-highlight-styles"; + document.head.appendChild(style); + } style.textContent = ` .json-key { color: #fa8c16; } .json-string { color: #52c41a; } .json-number { color: #722ed1; } .json-boolean { color: #1890ff; } .json-null { color: #faad14; } + .xml-tag { color: #1677ff; } + .xml-attr { color: #d46b08; } + .xml-value { color: #389e0d; } + .xml-decl { color: #7c3aed; } + .xml-comment { color: #8c8c8c; } `; - document.head.appendChild(style); }, [colorizeJson]); // Trim openSet if logs shrink @@ -65,6 +75,13 @@ export function DmsLogEvents({ // Only treat meta as "present" when we are allowed to show details const hasMeta = !isEmpty(meta) && showDetails; const isOpen = hasMeta && openSet.has(idx); + const xml = hasMeta ? extractXmlFromMeta(meta) : { request: null, response: null }; + const hasRequestXml = !!xml.request; + const hasResponseXml = !!xml.response; + const copyPayload = hasMeta ? getCopyPayload(meta) : null; + const copyPayloadKey = `copy-${idx}`; + const copyReqKey = `copy-req-${idx}`; + const copyResKey = `copy-res-${idx}`; return { key: idx, @@ -92,10 +109,42 @@ export function DmsLogEvents({ return next; }) } - style={{ cursor: "pointer", userSelect: "none" }} + style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }} > - {isOpen ? "Hide details" : "Details"} + {isOpen ? t("dms.labels.hide_details") : t("dms.labels.details")} + + handleCopyAction(copyPayloadKey, copyPayload, setCopiedKey)} + style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }} + > + {copiedKey === copyPayloadKey ? t("dms.labels.copied") : t("dms.labels.copy")} + + {hasRequestXml && ( + <> + + handleCopyAction(copyReqKey, xml.request, setCopiedKey)} + style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }} + > + {copiedKey === copyReqKey ? t("dms.labels.copied") : t("dms.labels.copy_request")} + + + )} + {hasResponseXml && ( + <> + + handleCopyAction(copyResKey, xml.response, setCopiedKey)} + style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }} + > + {copiedKey === copyResKey ? t("dms.labels.copied") : t("dms.labels.copy_response")} + + + )} )} @@ -103,14 +152,30 @@ export function DmsLogEvents({ {/* Row 2: details body (only when open) */} {hasMeta && isOpen && (
- + + {hasRequestXml && ( + + )} + {hasResponseXml && ( + + )}
)} ) }; }), - [logs, openSet, colorizeJson, isDarkMode, showDetails] + [logs, openSet, colorizeJson, copiedKey, isDarkMode, showDetails, t] ); return ; @@ -179,6 +244,121 @@ const safeStringify = (obj, spaces = 2) => { } }; +/** + * Get request/response XML from various Reynolds log meta shapes. + * @param meta + * @returns {{request: string|null, response: string|null}} + */ +const extractXmlFromMeta = (meta) => { + const request = + firstString(meta?.requestXml) || + firstString(meta?.xml?.request) || + firstString(meta?.response?.xml?.request) || + firstString(meta?.response?.requestXml); + + const response = + firstString(meta?.responseXml) || firstString(meta?.xml?.response) || firstString(meta?.response?.xml?.response); + + return { request, response }; +}; + +/** + * Return the value to copy when clicking the "Copy" action. + * @param meta + * @returns {*} + */ +const getCopyPayload = (meta) => { + if (meta?.payload != null) return meta.payload; + return meta; +}; + +/** + * Remove bulky XML fields from object shown in JSON block (XML is rendered separately). + * @param meta + * @returns {*} + */ +const removeXmlFromMeta = (meta) => { + if (meta == null || typeof meta !== "object") return meta; + const cloned = safeClone(meta); + if (cloned == null || typeof cloned !== "object") return meta; + + if (typeof cloned.requestXml === "string") delete cloned.requestXml; + if (typeof cloned.responseXml === "string") delete cloned.responseXml; + + if (cloned.xml && typeof cloned.xml === "object") { + if (typeof cloned.xml.request === "string") delete cloned.xml.request; + if (typeof cloned.xml.response === "string") delete cloned.xml.response; + if (isEmpty(cloned.xml)) delete cloned.xml; + } + + if (cloned.response?.xml && typeof cloned.response.xml === "object") { + if (typeof cloned.response.xml.request === "string") delete cloned.response.xml.request; + if (typeof cloned.response.xml.response === "string") delete cloned.response.xml.response; + if (isEmpty(cloned.response.xml)) delete cloned.response.xml; + } + + return cloned; +}; + +/** + * Safe deep clone for plain JSON structures. + * @param value + * @returns {*} + */ +const safeClone = (value) => { + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return value; + } +}; + +/** + * First non-empty string helper. + * @param value + * @returns {string|null} + */ +const firstString = (value) => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +}; + +/** + * Copy arbitrary text/object to clipboard. + * @param key + * @param value + * @param setCopied + * @returns {Promise} + */ +const handleCopyAction = async (key, value, setCopied) => { + const text = typeof value === "string" ? value : safeStringify(value, 2); + if (!text) return; + const copied = await copyTextToClipboard(text); + if (!copied) return; + setCopied(key); + setTimeout(() => { + setCopied((prev) => (prev === key ? null : prev)); + }, 1200); +}; + +/** + * Clipboard helper (modern async Clipboard API). + * @param text + * @returns {Promise} + */ +const copyTextToClipboard = async (text) => { + if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { + return false; + } + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +}; + /** * JSON display block with optional syntax highlighting. * @param data @@ -210,6 +390,105 @@ const JsonBlock = ({ data, colorize, isDarkMode }) => { return
{jsonText}
; }; +/** + * XML display block with normalized indentation. + * @param title + * @param xmlText + * @param isDarkMode + * @returns {JSX.Element} + * @constructor + */ +const XmlBlock = ({ title, xmlText, isDarkMode, colorize = false }) => { + const base = { + margin: "8px 0 0", + maxWidth: 720, + overflowX: "auto", + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + fontSize: 12, + lineHeight: 1.45, + padding: 8, + borderRadius: 6, + background: isDarkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.04)", + border: isDarkMode ? "1px solid rgba(255,255,255,0.12)" : "1px solid rgba(0,0,0,0.08)", + color: isDarkMode ? "var(--card-text-fallback)" : "#141414", + whiteSpace: "pre" + }; + + return ( +
+
{title}
+ {colorize ? ( +
+      ) : (
+        
{formatXml(xmlText)}
+ )} +
+ ); +}; + +/** + * Basic XML pretty-printer. + * @param xml + * @returns {string} + */ +const formatXml = (xml) => { + if (typeof xml !== "string") return ""; + const normalized = xml.replace(/\r\n/g, "\n").replace(/>\s*\n<").trim(); + const lines = normalized.split("\n"); + let indent = 0; + const out = []; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) continue; + + if (/^<\/[^>]+>/.test(line)) indent = Math.max(indent - 1, 0); + out.push(`${" ".repeat(indent)}${line}`); + + const opens = (line.match(/<[^/!?][^>]*>/g) || []).length; + const closes = (line.match(/<\/[^>]+>/g) || []).length; + const selfClosing = (line.match(/<[^>]+\/>/g) || []).length; + const declaration = /^<\?xml/.test(line) ? 1 : 0; + + indent += opens - closes - selfClosing - declaration; + if (indent < 0) indent = 0; + } + + return out.join("\n"); +}; + +/** + * Syntax highlight pretty-printed XML text for HTML display. + * @param xmlText + * @returns {string} + */ +const highlightXml = (xmlText) => { + const esc = String(xmlText || "") + .replace(/&/g, "&") + .replace(//g, ">"); + const lines = esc.split("\n"); + + return lines + .map((line) => { + let out = line; + + out = out.replace(/(<!--[\s\S]*?-->)/g, '$1'); + out = out.replace(/(<\?xml[\s\S]*?\?>)/g, '$1'); + + out = out.replace(/(<\/?)([A-Za-z_][\w:.-]*)([\s\S]*?)(\/?>)/g, (_m, open, tag, attrs, close) => { + const coloredAttrs = attrs.replace( + /([A-Za-z_][\w:.-]*)(=)("[^"]*"|'[^']*'|"[\s\S]*?"|'[\s\S]*?')/g, + '$1$2$3' + ); + return `${open}${tag}${coloredAttrs}${close}`; + }); + + return out; + }) + .join("\n"); +}; + /** * Syntax highlight JSON text for HTML display. * @param jsonText diff --git a/client/src/pages/dms/dms.container.jsx b/client/src/pages/dms/dms.container.jsx index 80edc5eeb..89e44d1a2 100644 --- a/client/src/pages/dms/dms.container.jsx +++ b/client/src/pages/dms/dms.container.jsx @@ -164,19 +164,21 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse const providerLabel = useMemo( () => ({ - [DMS_MAP.reynolds]: "Reynolds", - [DMS_MAP.fortellis]: "Fortellis", - [DMS_MAP.cdk]: "CDK", - [DMS_MAP.pbs]: "PBS" - })[mode] || "DMS", - [mode] + [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) ? "(WSS)" : "(WS)"; + const transportLabel = isWssMode(mode) ? t("dms.labels.transport_wss") : t("dms.labels.transport_ws"); - const bannerMessage = `Posting to ${providerLabel} | ${transportLabel} | ${ - isConnected ? "Connected" : "Disconnected" - }`; + 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]); @@ -246,7 +248,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse errText || t("dms.errors.exportfailedgeneric", "We couldn't complete the export. Please try again."); - const vendorTitle = title || (isRrMode ? "Reynolds" : "DMS"); + const vendorTitle = title || (isRrMode ? t("dms.labels.provider_reynolds") : t("dms.labels.provider_dms")); const isRrOpenRoLimit = isRrMode && @@ -321,7 +323,9 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse { timestamp: new Date(), level: "warn", - message: `Reconnected to ${isRrMode ? "RR" : mode === DMS_MAP.fortellis ? "Fortellis" : "DMS"} Export Service` + message: t("dms.labels.reconnected_export_service", { + provider: isRrMode ? t("dms.labels.provider_reynolds") : providerLabel + }) } ]); }; @@ -380,14 +384,12 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse { timestamp: new Date(), level: "INFO", - message: - "Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize." + message: t("dms.labels.rr_validation_message") } ]); notification.info({ - title: "Reynolds RO created", - description: - "Complete validation in Reynolds, then click Finished/Close to finalize and mark this export complete.", + title: t("dms.labels.rr_validation_notice_title"), + description: t("dms.labels.rr_validation_notice_description"), duration: 8 }); }; @@ -399,8 +401,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse { timestamp: new Date(), level: "INFO", - message: - "Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize.", + message: t("dms.labels.rr_validation_message"), meta: { payload } } ]); @@ -428,7 +429,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse activeSocket.disconnect(); } }; - }, [mode, activeSocket, channels, logLevel, notification, t, insertAuditTrail, history]); + }, [mode, activeSocket, channels, logLevel, notification, t, insertAuditTrail, history, isRrMode, providerLabel]); // RR finalize callback (unchanged public behavior) const handleRrValidationFinished = () => { @@ -471,7 +472,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse - + {!isRrMode ? ( - + - + )}