diff --git a/client/src/components/dms-allocations-summary/rr-dms-allocations-summary.component.jsx b/client/src/components/dms-allocations-summary/rr-dms-allocations-summary.component.jsx new file mode 100644 index 000000000..bfc388678 --- /dev/null +++ b/client/src/components/dms-allocations-summary/rr-dms-allocations-summary.component.jsx @@ -0,0 +1,521 @@ +import { Alert, Button, Card, Table, Tabs, Typography } from "antd"; +import { SyncOutlined } from "@ant-design/icons"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import Dinero from "dinero.js"; + +import { selectBodyshop } from "../../redux/user/user.selectors"; + +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop +}); +const mapDispatchToProps = () => ({}); + +export default connect(mapStateToProps, mapDispatchToProps)(RrAllocationsSummary); + +function normalizeDineroJson(d) { + if (!d) return null; + + // If it's already a Dinero instance, leave it to the caller + if (typeof d.toUnit === "function") return d; + + // New server shape: { cents: 54144, currency?: "USD" } + if (typeof d.cents === "number") { + return { + amount: d.cents, + precision: 2, + currency: d.currency || "USD" + }; + } + + // Classic Dinero JSON: { amount, precision, currency } + if (typeof d.amount === "number") { + return { + amount: d.amount, + precision: d.precision ?? 2, + currency: d.currency || "USD" + }; + } + + return null; +} + +/** + * Convert a Dinero-like object into an "N2" string ("123.45"). + * Works with real Dinero instances or plain JSON objects + * that have { amount, precision }. + */ +function dineroToN2(dineroLike) { + if (!dineroLike) return "0.00"; + + // If it's an actual Dinero instance + if (typeof dineroLike.toUnit === "function") { + return dineroLike.toUnit().toFixed(2); + } + + const normalized = normalizeDineroJson(dineroLike); + if (!normalized) return "0.00"; + + const { amount, precision = 2 } = normalized; + const value = amount / Math.pow(10, precision); + return value.toFixed(2); +} + +/** + * Normalize job allocations into a flat list for display / preview building. + * @param ack + * @returns {{center: *, sale, partsSale, laborTaxableSale, laborNonTaxableSale, extrasSale, cost, profitCenter, costCenter}[]|*[]} + */ +function normalizeJobAllocations(ack) { + if (!ack || !Array.isArray(ack.jobAllocations)) return []; + + return ack.jobAllocations.map((row) => ({ + center: row.center, + + // legacy "sale" (total) if we ever want to show it again + sale: row.sale || row.totalSale || null, + + // bucketed sales used to build split ROGOG/ROLABOR + partsSale: row.partsSale || null, + laborTaxableSale: row.laborTaxableSale || null, + laborNonTaxableSale: row.laborNonTaxableSale || null, + extrasSale: row.extrasSale || null, + + cost: row.cost || null, + profitCenter: row.profitCenter || null, + costCenter: row.costCenter || null + })); +} + +/** + * Build a minimal ROGOG preview from job allocation rows. + * Mirrors the backend buildRogogFromAllocations logic: + * - parts+extras segment + * - taxable labor segment + * - non-taxable labor segment + * Each segment becomes its *own* JobNo with a single GOG line. + */ +function buildRogogPreviewFromJobRows(jobRows, opCode) { + if (!Array.isArray(jobRows) || !jobRows.length || !opCode) return null; + + const ops = []; + + const toDinero = (d) => { + if (!d) return Dinero(); + if (typeof d.toUnit === "function") return d; + + const normalized = normalizeDineroJson(d); + return normalized ? Dinero(normalized) : Dinero(); + }; + + const cents = (d) => toDinero(d).getAmount(); + + const segmentLabelMap = { + partsExtras: "Parts/Extras", + laborTaxable: "Taxable Labor", + laborNonTaxable: "Non-Taxable Labor" + }; + + for (const row of jobRows) { + const pc = row.profitCenter || {}; + const breakOut = pc.rr_gogcode; + const itemType = pc.rr_item_type; + + // Only centers configured for RR GOG should appear + if (!breakOut || !itemType) continue; + + // Bucketed sales (Dinero-like objects coming from the backend) + const partsSale = toDinero(row.partsSale); + const extrasSale = toDinero(row.extrasSale); + const laborTaxableSale = toDinero(row.laborTaxableSale); + const laborNonTaxableSale = toDinero(row.laborNonTaxableSale); + const costMoney = toDinero(row.cost); + + const partsExtrasSale = partsSale.add(extrasSale); + + const segments = []; + + // 1) Parts + extras segment (uses center's default tax flag) + if (!partsExtrasSale.isZero()) { + segments.push({ + kind: "partsExtras", + sale: partsExtrasSale, + txFlag: pc.rr_cust_txbl_flag || "T" + }); + } + + // 2) Taxable labor -> always "T" + if (!laborTaxableSale.isZero()) { + segments.push({ + kind: "laborTaxable", + sale: laborTaxableSale, + txFlag: "T" + }); + } + + // 3) Non-taxable labor -> always "N" + if (!laborNonTaxableSale.isZero()) { + segments.push({ + kind: "laborNonTaxable", + sale: laborNonTaxableSale, + txFlag: "N" + }); + } + + if (!segments.length) continue; + + // Proportionally split cost across segments (same logic as backend) + const totalCostCents = cents(costMoney); + const totalSaleCents = segments.reduce((sum, seg) => sum + cents(seg.sale), 0); + + let remainingCostCents = totalCostCents; + + segments.forEach((seg, idx) => { + let costCents = 0; + + if (totalCostCents > 0 && totalSaleCents > 0) { + if (idx === segments.length - 1) { + // Last segment gets the remainder to avoid rounding drift + costCents = remainingCostCents; + } else { + const segSaleCents = cents(seg.sale); + costCents = Math.round((segSaleCents / totalSaleCents) * totalCostCents); + remainingCostCents -= costCents; + } + } + + seg.costCents = costCents; + }); + + const itemDescBase = pc.accountdesc || pc.accountname || row.center || ""; + + // πŸ”‘ Each segment becomes its own JobNo with a single GOG line + segments.forEach((seg, segIndex) => { + const jobNo = String(ops.length + 1); // 1-based, global across all centers + const segmentCount = segments.length; + const segmentKind = seg.kind; + const segmentLabel = segmentLabelMap[segmentKind] || segmentKind; + + // If there is a split, annotate the description so it’s obvious which segment this is + const displayDesc = segmentCount > 1 ? `${itemDescBase} (${segmentLabel})` : itemDescBase; + + const saleN2 = dineroToN2(seg.sale); + const costN2 = dineroToN2({ + amount: seg.costCents || 0, + precision: 2 + }); + + ops.push({ + opCode, + jobNo, + segmentKind, + segmentIndex: segIndex, + segmentCount, + lines: [ + { + breakOut, + itemType, + itemDesc: displayDesc, + custQty: "1.0", + custPayTypeFlag: "C", + // canonical property name used on the server + custTxblNTxblFlag: seg.txFlag || "T", + // legacy alias used by the table + custTxblNtxblFlag: seg.txFlag || "T", + amount: { + payType: "Cust", + amtType: "Unit", + custPrice: saleN2, + dlrCost: costN2 + } + } + ] + }); + }); + } + + if (!ops.length) return null; + + return { + roNo: null, // preview only + ops + }; +} + +/** + * Build a minimal ROLABOR preview from a ROGOG preview. + * Mirrors server-side buildRolaborFromRogog. + */ +function buildRolaborPreviewFromRogog(rogg) { + if (!rogg || !Array.isArray(rogg.ops)) return null; + + const ops = rogg.ops.map((op) => { + const firstLine = op.lines?.[0] || {}; + + // Prefer the server-side property name, but fall back to legacy + const txFlag = firstLine.custTxblNTxblFlag ?? firstLine.custTxblNtxblFlag ?? "N"; + const payFlag = firstLine.custPayTypeFlag || "C"; + + return { + opCode: op.opCode, + jobNo: op.jobNo, + custPayTypeFlag: payFlag, + // this is what the table uses + custTxblNtxblFlag: txFlag, + bill: { + payType: "Cust", + jobTotalHrs: "0", + billTime: "0", + billRate: "0" + }, + amount: { + payType: "Cust", + amtType: "Job", + custPrice: "0", + totalAmt: "0" + } + }; + }); + + if (!ops.length) return null; + return { ops }; +} + +/** + * RR-specific DMS Allocations Summary + * Focused on what we actually send to RR: + * - ROGOG (split by taxable / non-taxable segments) + * - ROLABOR shell + */ +export function RrAllocationsSummary({ socket, bodyshop, jobId, title }) { + const { t } = useTranslation(); + const [jobRows, setJobRows] = useState([]); + const [error, setError] = useState(null); + + const fetchAllocations = useCallback(() => { + if (!socket || !jobId) return; + + try { + socket.emit("rr-calculate-allocations", jobId, (ack) => { + if (ack && ack.ok === false) { + setJobRows([]); + setError(ack.error || t("dms.labels.allocations_error")); + if (socket) { + socket.allocationsSummary = []; + socket.rrAllocationsRaw = ack; + } + return; + } + + const jobAllocRows = normalizeJobAllocations(ack); + + setJobRows(jobAllocRows); + setError(null); + + if (socket) { + socket.allocationsSummary = jobAllocRows; + socket.rrAllocationsRaw = ack; + } + }); + } catch { + setJobRows([]); + setError(t("dms.labels.allocations_error")); + if (socket) { + socket.allocationsSummary = []; + } + } + }, [socket, jobId, t]); + + useEffect(() => { + fetchAllocations(); + }, [fetchAllocations]); + + const opCode = bodyshop?.rr_configuration?.baseOpCode || "28TOZ"; + + const roggPreview = useMemo(() => buildRogogPreviewFromJobRows(jobRows, opCode), [jobRows, opCode]); + const rolaborPreview = useMemo(() => buildRolaborPreviewFromRogog(roggPreview), [roggPreview]); + + const roggRows = useMemo(() => { + if (!roggPreview || !Array.isArray(roggPreview.ops)) return []; + const rows = []; + roggPreview.ops.forEach((op) => { + (op.lines || []).forEach((line, idx) => { + rows.push({ + key: `${op.jobNo}-${idx}`, + opCode: op.opCode, + jobNo: op.jobNo, + breakOut: line.breakOut, + itemType: line.itemType, + itemDesc: line.itemDesc, + custQty: line.custQty, + custPayTypeFlag: line.custPayTypeFlag, + custTxblNtxblFlag: line.custTxblNtxblFlag, + custPrice: line.amount?.custPrice, + dlrCost: line.amount?.dlrCost, + // segment metadata for visual styling + segmentKind: op.segmentKind, + segmentCount: op.segmentCount + }); + }); + }); + return rows; + }, [roggPreview]); + + const rolaborRows = useMemo(() => { + if (!rolaborPreview || !Array.isArray(rolaborPreview.ops)) return []; + return rolaborPreview.ops.map((op, idx) => ({ + key: `${op.jobNo}-${idx}`, + opCode: op.opCode, + jobNo: op.jobNo, + custPayTypeFlag: op.custPayTypeFlag, + custTxblNtxblFlag: op.custTxblNtxblFlag, + payType: op.bill?.payType, + amtType: op.amount?.amtType, + custPrice: op.amount?.custPrice, + totalAmt: op.amount?.totalAmt + })); + }, [rolaborPreview]); + + // Totals for ROGOG (sum custPrice + dlrCost over all lines) + const roggTotals = useMemo(() => { + if (!roggPreview || !Array.isArray(roggPreview.ops)) { + return { totalCustPrice: "0.00", totalDlrCost: "0.00" }; + } + + let totalCustCents = 0; + let totalCostCents = 0; + + roggPreview.ops.forEach((op) => { + (op.lines || []).forEach((line) => { + const cp = parseFloat(line.amount?.custPrice || "0"); + if (!Number.isNaN(cp)) { + totalCustCents += Math.round(cp * 100); + } + + const dc = parseFloat(line.amount?.dlrCost || "0"); + if (!Number.isNaN(dc)) { + totalCostCents += Math.round(dc * 100); + } + }); + }); + + return { + totalCustPrice: (totalCustCents / 100).toFixed(2), + totalDlrCost: (totalCostCents / 100).toFixed(2) + }; + }, [roggPreview]); + + const roggColumns = [ + { title: "JobNo", dataIndex: "jobNo", key: "jobNo" }, + { title: "OpCode", dataIndex: "opCode", key: "opCode" }, + { title: "BreakOut", dataIndex: "breakOut", key: "breakOut" }, + { title: "ItemType", dataIndex: "itemType", key: "itemType" }, + { title: "ItemDesc", dataIndex: "itemDesc", key: "itemDesc" }, + { title: "CustQty", dataIndex: "custQty", key: "custQty" }, + { title: "CustTxblFlag", dataIndex: "custTxblNtxblFlag", key: "custTxblNtxblFlag" }, + { title: "CustPrice", dataIndex: "custPrice", key: "custPrice" }, + { title: "DlrCost", dataIndex: "dlrCost", key: "dlrCost" } + ]; + + const rolaborColumns = [ + { title: "JobNo", dataIndex: "jobNo", key: "jobNo" }, + { title: "OpCode", dataIndex: "opCode", key: "opCode" }, + { title: "CustPayType", dataIndex: "custPayTypeFlag", key: "custPayTypeFlag" }, + { title: "CustTxblFlag", dataIndex: "custTxblNtxblFlag", key: "custTxblNtxblFlag" }, + { title: "PayType", dataIndex: "payType", key: "payType" }, + { title: "AmtType", dataIndex: "amtType", key: "amtType" }, + { title: "CustPrice", dataIndex: "custPrice", key: "custPrice" }, + { title: "TotalAmt", dataIndex: "totalAmt", key: "totalAmt" } + ]; + + const tabItems = [ + { + key: "rogog", + label: "ROGOG Preview", + children: ( + <> + + OpCode: {opCode}. Only centers with RR GOG mapping (rr_gogcode & rr_item_type) are + included. Totals below reflect exactly what will be sent in ROGOG. + + { + if ( + record.segmentCount > 1 && + (record.segmentKind === "laborTaxable" || record.segmentKind === "laborNonTaxable") + ) { + return "rr-allocations-tax-split-row"; + } + if (record.segmentCount > 1) { + return "rr-allocations-split-row"; + } + return ""; + }} + summary={() => ( + + + {t("general.labels.totals")} + + + + + + + + {roggTotals.totalCustPrice} + {roggTotals.totalDlrCost} + + )} + /> + + ) + }, + { + key: "rolabor", + label: "ROLABOR Preview", + children: ( + <> + + This mirrors the shell that would be sent for ROLABOR when all financials are carried in GOG. + +
+ + ) + } + ]; + + return ( + + + + } + > + {bodyshop.pbs_configuration?.disablebillwip && ( + + )} + + {error && } + + + + ); +} 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 25f0981cf..d71f3633f 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 @@ -13,7 +13,14 @@ const mapDispatchToProps = () => ({}); export default connect(mapStateToProps, mapDispatchToProps)(DmsLogEvents); -export function DmsLogEvents({ logs, detailsOpen, detailsNonce, isDarkMode, colorizeJson = false }) { +export function DmsLogEvents({ + logs, + detailsOpen, + detailsNonce, + isDarkMode, + colorizeJson = false, + showDetails = true +}) { const [openSet, setOpenSet] = useState(() => new Set()); // Inject JSON highlight styles once (only when colorize is enabled) @@ -54,8 +61,10 @@ export function DmsLogEvents({ logs, detailsOpen, detailsNonce, isDarkMode, colo () => (logs || []).map((raw, idx) => { const { level, message, timestamp, meta } = normalizeLog(raw); - const hasMeta = !isEmpty(meta); - const isOpen = openSet.has(idx); + + // Only treat meta as "present" when we are allowed to show details + const hasMeta = !isEmpty(meta) && showDetails; + const isOpen = hasMeta && openSet.has(idx); return { key: idx, @@ -101,7 +110,7 @@ export function DmsLogEvents({ logs, detailsOpen, detailsNonce, isDarkMode, colo ) }; }), - [logs, openSet, colorizeJson] + [logs, openSet, colorizeJson, isDarkMode, showDetails] ); return ; diff --git a/client/src/pages/dms/dms.container.jsx b/client/src/pages/dms/dms.container.jsx index 313e29376..a5144c0f2 100644 --- a/client/src/pages/dms/dms.container.jsx +++ b/client/src/pages/dms/dms.container.jsx @@ -26,6 +26,7 @@ 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 @@ -82,6 +83,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse // Compute a single normalized mode and pick the proper socket const mode = getDmsMode(bodyshop, Fortellis.treatment); // "rr" | "fortellis" | "cdk" | "pbs" | "none" + const isRrMode = mode === DMS_MAP.reynolds; const { socket: wsssocket } = useSocket(); const activeSocket = useMemo(() => (isWssMode(mode) ? wsssocket : legacySocket), [mode, wsssocket]); @@ -134,7 +136,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse [mode] ); - const transportLabel = isWssMode(mode) ? "App Socket (WSS)" : "Legacy Socket (WS)"; + const transportLabel = isWssMode(mode) ? "(WSS)" : "(WS)"; const bannerMessage = `Posting to ${providerLabel} | ${transportLabel} | ${ isConnected ? "Connected" : "Disconnected" @@ -148,10 +150,10 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse errText || t("dms.errors.exportfailedgeneric", "We couldn't complete the export. Please try again."); - const vendorTitle = title || (mode === DMS_MAP.reynolds ? "Reynolds" : "DMS"); + const vendorTitle = title || (isRrMode ? "Reynolds" : "DMS"); const isRrOpenRoLimit = - mode === DMS_MAP.reynolds && + isRrMode && (vendorStatusCode === 507 || /MAX_OPEN_ROS/i.test(String(errorCode || "")) || /maximum number of open repair orders/i.test(String(msg || "").toLowerCase())); @@ -187,8 +189,8 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse }); setSelectedHeader("dms"); setBreadcrumbs([ - { link: "/manage/accounting/receivables", label: t("titles.bc.accounting-receivables") }, - { link: "/manage/dms", label: t("titles.bc.dms") } + { link: "/manage/accounting/receivables", label: t("titles.bc.accounting-receivables") } + // { link: "/manage/dms", label: t("titles.bc.dms") } ]); }, [t, setBreadcrumbs, setSelectedHeader]); @@ -218,7 +220,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse { timestamp: new Date(), level: "warn", - message: `Reconnected to ${mode === DMS_MAP.reynolds ? "RR" : mode === DMS_MAP.fortellis ? "Fortellis" : "DMS"} Export Service` + message: `Reconnected to ${isRrMode ? "RR" : mode === DMS_MAP.fortellis ? "Fortellis" : "DMS"} Export Service` } ]); }; @@ -235,22 +237,17 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse activeSocket.on("connect_error", onConnectError); // Logs - const onLog = - mode === DMS_MAP.reynolds - ? (payload = {}) => { - const normalized = { - timestamp: payload.timestamp - ? new Date(payload.timestamp) - : payload.ts - ? new Date(payload.ts) - : new Date(), - level: (payload.level || "INFO").toUpperCase(), - message: payload.message || payload.msg || "", - meta: payload.meta ?? payload.ctx ?? payload.details ?? null - }; - setLogs((prev) => [...prev, normalized]); - } - : (payload) => setLogs((prev) => [...prev, payload]); + const onLog = isRrMode + ? (payload = {}) => { + const normalized = { + timestamp: payload.timestamp ? new Date(payload.timestamp) : payload.ts ? new Date(payload.ts) : new Date(), + level: (payload.level || "INFO").toUpperCase(), + message: payload.message || payload.msg || "", + meta: payload.meta ?? payload.ctx ?? payload.details ?? null + }; + setLogs((prev) => [...prev, normalized]); + } + : (payload) => setLogs((prev) => [...prev, payload]); if (channels.log) activeSocket.on(channels.log, onLog); @@ -308,9 +305,8 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse ]); }; - if (mode === DMS_MAP.reynolds && channels.partialResult) activeSocket.on(channels.partialResult, onPartialResult); - if (mode === DMS_MAP.reynolds && channels.validationNeeded) - activeSocket.on(channels.validationNeeded, onValidationRequired); + if (isRrMode && channels.partialResult) activeSocket.on(channels.partialResult, onPartialResult); + if (isRrMode && channels.validationNeeded) activeSocket.on(channels.validationNeeded, onValidationRequired); return () => { activeSocket.off("connect", onConnect); @@ -322,10 +318,8 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse if (channels.exportSuccess) activeSocket.off(channels.exportSuccess, onExportSuccess); if (channels.exportFailed) activeSocket.off(channels.exportFailed, handleExportFailed); - if (mode === DMS_MAP.reynolds && channels.partialResult) - activeSocket.off(channels.partialResult, onPartialResult); - if (mode === DMS_MAP.reynolds && channels.validationNeeded) - activeSocket.off(channels.validationNeeded, onValidationRequired); + 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)) { @@ -359,19 +353,38 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse - - {`${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} - /> + {!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} + /> + )} @@ -397,13 +410,18 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse title={t("jobs.labels.dms.logs")} extra={ - - + {isRrMode && ( + <> + + + + )} +