From d7294ebba65e78519a1497d0042c3b422cad79ec Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 21 Apr 2026 10:51:13 -0400 Subject: [PATCH 01/17] feature/IO-3647-Reynolds-Integration-Phase-2-Optional - Add option to make 'Enhanced Early ROS' optional --- .../shop-info/shop-info.general.component.jsx | 46 +++++++- client/src/translations/en_us/common.json | 1 + client/src/translations/es/common.json | 1 + client/src/translations/fr/common.json | 1 + server/rr/rr-job-export.js | 23 ++-- server/rr/rr-job-export.test.js | 105 ++++++++++++++++++ server/rr/rr-utils.js | 9 ++ server/rr/rr-utils.test.js | 18 +++ 8 files changed, 191 insertions(+), 13 deletions(-) create mode 100644 server/rr/rr-job-export.test.js create mode 100644 server/rr/rr-utils.test.js diff --git a/client/src/components/shop-info/shop-info.general.component.jsx b/client/src/components/shop-info/shop-info.general.component.jsx index fa3a2c3af..cf97cd9ec 100644 --- a/client/src/components/shop-info/shop-info.general.component.jsx +++ b/client/src/components/shop-info/shop-info.general.component.jsx @@ -12,6 +12,8 @@ import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.c import { buildSectionActionButton, renderListOrEmpty } from "../layout-form-row/config-list-actions.utils.jsx"; import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx"; import LayoutFormRow from "../layout-form-row/layout-form-row.component"; +import { selectBodyshop } from "../../redux/user/user.selectors"; +import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils.js"; import { INLINE_TITLE_GROUP_STYLE, INLINE_TITLE_HANDLE_STYLE, @@ -25,16 +27,21 @@ import { const timeZonesList = Intl.supportedValuesOf("timeZone"); -const mapStateToProps = createStructuredSelector({}); +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop +}); const mapDispatchToProps = () => ({ //setUserLanguage: language => dispatch(setUserLanguage(language)) }); export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoGeneral); -export function ShopInfoGeneral({ form }) { +export function ShopInfoGeneral({ form, bodyshop }) { const { t } = useTranslation(); const insuranceCompanies = Form.useWatch(["md_ins_cos"], form) || []; const duplicateInsuranceCompanyIndexes = getDuplicateIndexSetByNormalizedName(insuranceCompanies, "name"); + const hasDMSKey = bodyshop ? bodyshopHasDmsKey(bodyshop) : false; + const dmsMode = bodyshop ? getDmsMode(bodyshop, "off") : "none"; + const isReynoldsMode = hasDMSKey && dmsMode === DMS_MAP.reynolds; return (
@@ -174,7 +181,9 @@ export function ShopInfoGeneral({ form }) { >
-
{t("bodyshop.fields.scoreboard_setup.ignore_blocked_days")}
+
+ {t("bodyshop.fields.scoreboard_setup.ignore_blocked_days")} +
@@ -311,7 +320,12 @@ export function ShopInfoGeneral({ form }) { - + @@ -478,7 +492,12 @@ export function ShopInfoGeneral({ form }) {
{t("bodyshop.fields.system_settings.job_costing.use_paint_scale_data")}
- +
@@ -558,7 +577,12 @@ export function ShopInfoGeneral({ form }) { - + + {isReynoldsMode && ( + + + + )}
diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 33f89f2fd..304423d4e 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -463,6 +463,7 @@ "md_email_cc": "Auto Email CC: $t(parts_orders.labels.{{template}})", "md_from_emails": "Additional From Emails", "md_functionality_toggles": { + "enhanced_early_ros": "Enable Enhance Early ROs", "parts_queue_toggle": "Auto Add Imported/Supplemented Jobs to Parts Queue" }, "md_hour_split": { diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 58c7a74b5..9fdfa21b6 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -457,6 +457,7 @@ "md_email_cc": "", "md_from_emails": "", "md_functionality_toggles": { + "enhanced_early_ros": "", "parts_queue_toggle": "" }, "md_hour_split": { diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index e5cccbe55..2f07205c9 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -457,6 +457,7 @@ "md_email_cc": "", "md_from_emails": "", "md_functionality_toggles": { + "enhanced_early_ros": "", "parts_queue_toggle": "" }, "md_hour_split": { diff --git a/server/rr/rr-job-export.js b/server/rr/rr-job-export.js index 15e2f2a34..c4d4d8328 100644 --- a/server/rr/rr-job-export.js +++ b/server/rr/rr-job-export.js @@ -4,7 +4,7 @@ const CreateRRLogEvent = require("./rr-logger-event"); const { withRRRequestXml } = require("./rr-log-xml"); const { extractRrResponsibilityCenters } = require("./rr-responsibility-centers"); const CdkCalculateAllocations = require("./rr-calculate-allocations").default; -const { resolveRROpCodeFromBodyshop } = require("./rr-utils"); +const { isEnhancedEarlyROEnabled, resolveRROpCodeFromBodyshop } = require("./rr-utils"); /** * Derive RR status information from response object. @@ -139,11 +139,14 @@ const createMinimalRRRepairOrder = async (args) => { resolvedMileageIn: mileageIn }); - const earlyRoOpCode = resolveRROpCode(bodyshop, txEnvelope); - const earlyRoLabor = buildMinimalRolaborFromJob(job, { - opCode: earlyRoOpCode, - payType: "Cust" - }); + const enhancedEarlyROEnabled = isEnhancedEarlyROEnabled(bodyshop); + const earlyRoOpCode = enhancedEarlyROEnabled ? resolveRROpCode(bodyshop, txEnvelope) : null; + const earlyRoLabor = enhancedEarlyROEnabled + ? buildMinimalRolaborFromJob(job, { + opCode: earlyRoOpCode, + payType: "Cust" + }) + : null; const payload = { customerNo: String(selected), @@ -176,13 +179,19 @@ const createMinimalRRRepairOrder = async (args) => { CreateRRLogEvent(socket, "INFO", "Creating minimal RR Repair Order (early creation)", { payload, + enhancedEarlyROEnabled, earlyRoOpCode, hasRolabor: !!earlyRoLabor }); const response = await client.createRepairOrder(payload, finalOpts); - CreateRRLogEvent(socket, "INFO", "RR minimal Repair Order created", withRRRequestXml(response, { payload, response })); + CreateRRLogEvent( + socket, + "INFO", + "RR minimal Repair Order created", + withRRRequestXml(response, { payload, response }) + ); const data = response?.data || null; const statusBlocks = response?.statusBlocks || {}; diff --git a/server/rr/rr-job-export.test.js b/server/rr/rr-job-export.test.js new file mode 100644 index 000000000..df8be1ff5 --- /dev/null +++ b/server/rr/rr-job-export.test.js @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const mock = require("mock-require"); + +const helpersModuleId = require.resolve("./rr-job-helpers"); +const lookupModuleId = require.resolve("./rr-lookup"); +const loggerEventModuleId = require.resolve("./rr-logger-event"); +const logXmlModuleId = require.resolve("./rr-log-xml"); +const responsibilityCentersModuleId = require.resolve("./rr-responsibility-centers"); +const allocationsModuleId = require.resolve("./rr-calculate-allocations"); +const jobExportModuleId = require.resolve("./rr-job-export"); + +const makeBodyshop = (mdFunctionalityToggles) => ({ + rr_configuration: { + defaults: { + prefix: "51", + base: "DOZ", + suffix: "" + } + }, + ...(mdFunctionalityToggles ? { md_functionality_toggles: mdFunctionalityToggles } : {}) +}); + +const makeJob = () => ({ + id: "job-1", + ro_number: "RO-123", + v_vin: "1HGBH41JXMN109186", + joblines: [{ mod_lbr_ty: "LAB", mod_lb_hrs: 2, lbr_amt: 200 }] +}); + +const loadJobExport = ({ + buildMinimalRolaborFromJob = vi.fn(() => ({ ops: [{ opCode: "51DOZ" }] })), + createRepairOrder = vi.fn(async () => ({ success: true, data: { dmsRoNo: "12345" } })) +} = {}) => { + mock.stopAll(); + mock(helpersModuleId, { + buildRRRepairOrderPayload: vi.fn(), + buildMinimalRolaborFromJob + }); + mock(lookupModuleId, { + buildClientAndOpts: () => ({ + client: { createRepairOrder }, + opts: { envelope: { sender: {} } } + }) + }); + mock(loggerEventModuleId, vi.fn()); + mock(logXmlModuleId, { + withRRRequestXml: (response, payload) => payload + }); + mock(responsibilityCentersModuleId, { + extractRrResponsibilityCenters: vi.fn(() => []) + }); + mock(allocationsModuleId, { + default: vi.fn() + }); + + delete require.cache[jobExportModuleId]; + return { + ...require(jobExportModuleId), + buildMinimalRolaborFromJob, + createRepairOrder + }; +}; + +afterEach(() => { + mock.stopAll(); + delete require.cache[jobExportModuleId]; +}); + +describe("server/rr/rr-job-export", () => { + it("sends early RO labor totals by default", async () => { + const { createMinimalRRRepairOrder, createRepairOrder, buildMinimalRolaborFromJob } = loadJobExport(); + + await createMinimalRRRepairOrder({ + bodyshop: makeBodyshop(), + job: makeJob(), + advisorNo: "70754", + selectedCustomer: { custNo: "1134485" }, + txEnvelope: {} + }); + + expect(buildMinimalRolaborFromJob).toHaveBeenCalledWith(makeJob(), { + opCode: "51DOZ", + payType: "Cust" + }); + expect(createRepairOrder.mock.calls[0][0].rolabor).toEqual({ ops: [{ opCode: "51DOZ" }] }); + }); + + it("omits early RO labor totals when the shop opts out", async () => { + const { createMinimalRRRepairOrder, createRepairOrder, buildMinimalRolaborFromJob } = loadJobExport(); + + await createMinimalRRRepairOrder({ + bodyshop: makeBodyshop({ enhanced_early_ros: false }), + job: makeJob(), + advisorNo: "70754", + selectedCustomer: { custNo: "1134485" }, + txEnvelope: {} + }); + + expect(buildMinimalRolaborFromJob).not.toHaveBeenCalled(); + expect(createRepairOrder.mock.calls[0][0].rolabor).toBeUndefined(); + }); +}); diff --git a/server/rr/rr-utils.js b/server/rr/rr-utils.js index 0a8cb49ab..5629e7fda 100644 --- a/server/rr/rr-utils.js +++ b/server/rr/rr-utils.js @@ -205,10 +205,19 @@ const resolveRROpCodeFromBodyshop = (bodyshop) => { return `${prefix}${base}${suffix}`; }; +/** + * Enhanced Early RO labor totals are enabled by default for backwards compatibility. + * Shops can explicitly set md_functionality_toggles.enhanced_early_ros to false to opt out. + * @param bodyshop + * @returns {boolean} + */ +const isEnhancedEarlyROEnabled = (bodyshop) => bodyshop?.md_functionality_toggles?.enhanced_early_ros !== false; + module.exports = { RRCacheEnums, defaultRRTTL, getTransactionType, + isEnhancedEarlyROEnabled, ownersFromVinBlocks, makeVehicleSearchPayloadFromJob, normalizeCustomerCandidates, diff --git a/server/rr/rr-utils.test.js b/server/rr/rr-utils.test.js new file mode 100644 index 000000000..3f6810fe7 --- /dev/null +++ b/server/rr/rr-utils.test.js @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { isEnhancedEarlyROEnabled } = require("./rr-utils"); + +describe("server/rr/rr-utils", () => { + it("keeps enhanced early ROs enabled when the shop setting is missing", () => { + expect(isEnhancedEarlyROEnabled()).toBe(true); + expect(isEnhancedEarlyROEnabled({})).toBe(true); + expect(isEnhancedEarlyROEnabled({ md_functionality_toggles: {} })).toBe(true); + }); + + it("only disables enhanced early ROs when the shop explicitly opts out", () => { + expect(isEnhancedEarlyROEnabled({ md_functionality_toggles: { enhanced_early_ros: false } })).toBe(false); + expect(isEnhancedEarlyROEnabled({ md_functionality_toggles: { enhanced_early_ros: true } })).toBe(true); + }); +}); From 92a3e57205d92a23582f6f269d2ca334ee7c5e4f Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Tue, 28 Apr 2026 15:46:40 -0700 Subject: [PATCH 02/17] IO-3667 Visual Production Title Color Signed-off-by: Allan Carr --- .../production-board-kanban-card.component.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/components/production-board-kanban/production-board-kanban-card.component.jsx b/client/src/components/production-board-kanban/production-board-kanban-card.component.jsx index dbb96a6ba..81ebb197c 100644 --- a/client/src/components/production-board-kanban/production-board-kanban-card.component.jsx +++ b/client/src/components/production-board-kanban/production-board-kanban-card.component.jsx @@ -431,6 +431,7 @@ export default function ProductionBoardCard({ technician, card, bodyshop, cardSe Date: Tue, 28 Apr 2026 16:44:23 -0700 Subject: [PATCH 03/17] IO-3650 Pagination Corrections Signed-off-by: Allan Carr --- .../owners-list/owners-list.component.jsx | 17 +++++++++------ .../owners-list/owners-list.container.jsx | 11 +++++++--- .../parts-queue.list.component.jsx | 18 +++++++++++----- .../vehicles-list/vehicles-list.component.jsx | 17 +++++++++------ .../vehicles-list/vehicles-list.container.jsx | 10 ++++++--- .../export-logs.page.component.jsx | 21 +++++++++++++------ 6 files changed, 65 insertions(+), 29 deletions(-) diff --git a/client/src/components/owners-list/owners-list.component.jsx b/client/src/components/owners-list/owners-list.component.jsx index eefec34ca..e6fe1efb9 100644 --- a/client/src/components/owners-list/owners-list.component.jsx +++ b/client/src/components/owners-list/owners-list.component.jsx @@ -11,12 +11,13 @@ import ResponsiveTable from "../responsive-table/responsive-table.component"; export default function OwnersListComponent({ loading, owners, total, refetch }) { const search = queryString.parse(useLocation().search); - const { - page - // sortcolumn, sortorder - } = search; + const { page, pageSize } = search; const history = useNavigate(); + const currentPage = Number.parseInt(page || "1", 10); + const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10); + const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize; + const [state, setState] = useState({ sortedInfo: {}, filteredInfo: { text: "" } @@ -71,10 +72,14 @@ export default function OwnersListComponent({ loading, owners, total, refetch }) ]; const handleTableChange = (pagination, filters, sorter) => { + const nextPageSize = pagination?.pageSize || currentPageSize; + const pageSizeChanged = nextPageSize !== currentPageSize; + setState({ ...state, filteredInfo: filters, sortedInfo: sorter }); const updatedSearch = { ...search, - page: pagination.current, + pageSize: nextPageSize, + page: pageSizeChanged ? 1 : pagination.current, sortcolumn: sorter.columnKey, sortorder: sorter.order }; @@ -119,7 +124,7 @@ export default function OwnersListComponent({ loading, owners, total, refetch }) > { - // searchParams.page = pagination.current; + const nextPageSize = pagination?.pageSize || currentPageSize; + const pageSizeChanged = nextPageSize !== currentPageSize; + + searchParams.pageSize = nextPageSize; + searchParams.page = pageSizeChanged ? 1 : pagination.current; searchParams.sortcolumn = sorter.columnKey; searchParams.sortorder = sorter.order; @@ -315,9 +322,10 @@ export function PartsQueueListComponent({ bodyshop }) { loading={loading} pagination={{ placement: "top", - pageSize: pageLimit - // current: parseInt(page || 1), - // total: data && data.jobs_aggregate.aggregate.count, + pageSize: currentPageSize, + current: currentPage, + showSizeChanger: true, + total: jobs.length }} columns={columns} mobileColumnKeys={["ro_number", "ownr_ln", "status", "vehicle", "partsstatus"]} diff --git a/client/src/components/vehicles-list/vehicles-list.component.jsx b/client/src/components/vehicles-list/vehicles-list.component.jsx index 41cc1d764..5d6b33fc8 100644 --- a/client/src/components/vehicles-list/vehicles-list.component.jsx +++ b/client/src/components/vehicles-list/vehicles-list.component.jsx @@ -11,12 +11,13 @@ import ResponsiveTable from "../responsive-table/responsive-table.component"; export default function VehiclesListComponent({ loading, vehicles, total, refetch, basePath = "/manage" }) { const search = queryString.parse(useLocation().search); - const { - page - //sortcolumn, sortorder, - } = search; + const { page, pageSize } = search; const history = useNavigate(); + const currentPage = Number.parseInt(page || "1", 10); + const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10); + const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize; + const [state, setState] = useState({ sortedInfo: {}, filteredInfo: { text: "" } @@ -62,10 +63,14 @@ export default function VehiclesListComponent({ loading, vehicles, total, refetc ]; const handleTableChange = (pagination, filters, sorter) => { + const nextPageSize = pagination?.pageSize || currentPageSize; + const pageSizeChanged = nextPageSize !== currentPageSize; + setState({ ...state, filteredInfo: filters, sortedInfo: sorter }); const updatedSearch = { ...search, - page: pagination.current, + pageSize: nextPageSize, + page: pageSizeChanged ? 1 : pagination.current, sortcolumn: sorter.columnKey, sortorder: sorter.order }; @@ -106,7 +111,7 @@ export default function VehiclesListComponent({ loading, vehicles, total, refetc > ; const handleTableChange = (pagination, filters, sorter) => { - searchParams.page = pagination.current; + const nextPageSize = pagination?.pageSize || currentPageSize; + const pageSizeChanged = nextPageSize !== currentPageSize; + + searchParams.pageSize = nextPageSize; + searchParams.page = pageSizeChanged ? 1 : pagination.current; searchParams.sortcolumn = sorter.columnKey; searchParams.sortorder = sorter.order; if (filters.status) { @@ -191,8 +199,9 @@ export function ExportLogsPageComponent() { loading={loading} pagination={{ placement: "top", - pageSize: pageLimit, - current: parseInt(page || 1, 10), + pageSize: currentPageSize, + current: currentPage, + showSizeChanger: true, total: data && data.search_exportlog_aggregate.aggregate.count }} columns={columns} From 614420d7d223301652535849082e6dac22343503 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Tue, 28 Apr 2026 18:06:11 -0700 Subject: [PATCH 04/17] IO-3562 Visual Production Board Statistics - Exclude Suspended Jobs Signed-off-by: Allan Carr --- .../production-board-kanban.statistics.jsx | 94 ++++++++++++------- .../settings/StatisticsSettings.jsx | 11 ++- .../settings/defaultKanbanSettings.js | 3 +- client/src/translations/en_us/common.json | 1 + client/src/translations/es/common.json | 1 + client/src/translations/fr/common.json | 1 + 6 files changed, 76 insertions(+), 35 deletions(-) diff --git a/client/src/components/production-board-kanban/production-board-kanban.statistics.jsx b/client/src/components/production-board-kanban/production-board-kanban.statistics.jsx index cc8c46332..b31d58b84 100644 --- a/client/src/components/production-board-kanban/production-board-kanban.statistics.jsx +++ b/client/src/components/production-board-kanban/production-board-kanban.statistics.jsx @@ -28,11 +28,14 @@ const ProductionStatistics = ({ data, cardSettings, reducerData }) => { const { t } = useTranslation(); const calculateTotal = (items, key, subKey) => { - return items.reduce((acc, item) => acc + (item[key]?.aggregate?.sum?.[subKey] || 0), 0); + return items.reduce((acc, item) => acc + (item?.[key]?.aggregate?.sum?.[subKey] ?? 0), 0); }; const calculateTotalAmount = (items, key) => { - return items.reduce((acc, item) => acc.add(Dinero(item[key]?.totals?.subtotal ?? Dinero())), Dinero({ amount: 0 })); + return items.reduce( + (acc, item) => acc.add(Dinero(item?.[key]?.totals?.subtotal ?? Dinero())), + Dinero({ amount: 0 }) + ); }; const calculateReducerTotalAmount = (lanes, key) => { @@ -67,58 +70,83 @@ const ProductionStatistics = ({ data, cardSettings, reducerData }) => { return value; }; + const filteredData = cardSettings.excludeSuspended === true ? data.filter((item) => item.suspended !== true) : data; + const filteredReducerData = + cardSettings.excludeSuspended === true + ? { + ...reducerData, + lanes: reducerData.lanes.map((lane) => ({ + ...lane, + cards: lane.cards.filter((card) => card.metadata.suspended !== true) + })) + } + : reducerData; + const totalHrs = cardSettings.totalHrs - ? parseFloat((calculateTotal(data, "labhrs", "mod_lb_hrs") + calculateTotal(data, "larhrs", "mod_lb_hrs")).toFixed(2)) + ? parseFloat( + ( + calculateTotal(filteredData, "labhrs", "mod_lb_hrs") + calculateTotal(filteredData, "larhrs", "mod_lb_hrs") + ).toFixed(2) + ) : null; const totalLAB = cardSettings.totalLAB - ? parseFloat(calculateTotal(data, "labhrs", "mod_lb_hrs").toFixed(2)) + ? parseFloat(calculateTotal(filteredData, "labhrs", "mod_lb_hrs").toFixed(2)) : null; const totalLAR = cardSettings.totalLAR - ? parseFloat(calculateTotal(data, "larhrs", "mod_lb_hrs").toFixed(2)) + ? parseFloat(calculateTotal(filteredData, "larhrs", "mod_lb_hrs").toFixed(2)) : null; - const jobsInProduction = cardSettings.jobsInProduction ? data.length : null; + const jobsInProduction = cardSettings.jobsInProduction ? filteredData.length : null; const totalAmountInProduction = cardSettings.totalAmountInProduction - ? calculateTotalAmount(data, "job_totals").toFormat("$0,0.00") + ? calculateTotalAmount(filteredData, "job_totals").toFormat("$0,0.00") : null; - const totalAmountOnBoard = reducerData && cardSettings.totalAmountOnBoard - ? calculateReducerTotalAmount(reducerData.lanes, "job_totals").toFormat("$0,0.00") - : null; + const totalAmountOnBoard = + filteredReducerData && cardSettings.totalAmountOnBoard + ? calculateReducerTotalAmount(filteredReducerData.lanes, "job_totals").toFormat("$0,0.00") + : null; - const totalHrsOnBoard = reducerData && cardSettings.totalHrsOnBoard - ? parseFloat(( - calculateReducerTotal(reducerData.lanes, "labhrs", "mod_lb_hrs") + - calculateReducerTotal(reducerData.lanes, "larhrs", "mod_lb_hrs") - ).toFixed(2)) - : null; + const totalHrsOnBoard = + filteredReducerData && cardSettings.totalHrsOnBoard + ? parseFloat( + ( + calculateReducerTotal(filteredReducerData.lanes, "labhrs", "mod_lb_hrs") + + calculateReducerTotal(filteredReducerData.lanes, "larhrs", "mod_lb_hrs") + ).toFixed(2) + ) + : null; - const totalLABOnBoard = reducerData && cardSettings.totalLABOnBoard - ? parseFloat(calculateReducerTotal(reducerData.lanes, "labhrs", "mod_lb_hrs").toFixed(2)) - : null; + const totalLABOnBoard = + filteredReducerData && cardSettings.totalLABOnBoard + ? parseFloat(calculateReducerTotal(filteredReducerData.lanes, "labhrs", "mod_lb_hrs").toFixed(2)) + : null; - const totalLAROnBoard = reducerData && cardSettings.totalLAROnBoard - ? parseFloat(calculateReducerTotal(reducerData.lanes, "larhrs", "mod_lb_hrs").toFixed(2)) - : null; + const totalLAROnBoard = + filteredReducerData && cardSettings.totalLAROnBoard + ? parseFloat(calculateReducerTotal(filteredReducerData.lanes, "larhrs", "mod_lb_hrs").toFixed(2)) + : null; - const jobsOnBoard = reducerData && cardSettings.jobsOnBoard - ? reducerData.lanes.reduce((acc, lane) => acc + lane.cards.length, 0) - : null; + const jobsOnBoard = + filteredReducerData && cardSettings.jobsOnBoard + ? filteredReducerData.lanes.reduce((acc, lane) => acc + lane.cards.length, 0) + : null; const tasksInProduction = cardSettings.tasksInProduction - ? data.reduce((acc, item) => acc + (item.tasks_aggregate?.aggregate?.count || 0), 0) + ? filteredData.reduce((acc, item) => acc + (item.tasks_aggregate?.aggregate?.count || 0), 0) : null; - const tasksOnBoard = reducerData && cardSettings.tasksOnBoard - ? reducerData.lanes.reduce((acc, lane) => { - return ( - acc + lane.cards.reduce((laneAcc, card) => laneAcc + (card.metadata.tasks_aggregate?.aggregate?.count || 0), 0) - ); - }, 0) - : null; + const tasksOnBoard = + filteredReducerData && cardSettings.tasksOnBoard + ? filteredReducerData.lanes.reduce((acc, lane) => { + return ( + acc + + lane.cards.reduce((laneAcc, card) => laneAcc + (card.metadata.tasks_aggregate?.aggregate?.count || 0), 0) + ); + }, 0) + : null; const statistics = mergeStatistics(statisticsItems, [ { id: 0, value: totalHrs, type: StatisticType.HOURS }, diff --git a/client/src/components/production-board-kanban/settings/StatisticsSettings.jsx b/client/src/components/production-board-kanban/settings/StatisticsSettings.jsx index 1645bc05d..d283f6122 100644 --- a/client/src/components/production-board-kanban/settings/StatisticsSettings.jsx +++ b/client/src/components/production-board-kanban/settings/StatisticsSettings.jsx @@ -14,7 +14,16 @@ const StatisticsSettings = ({ t, statisticsOrder, setStatisticsOrder, setHasChan }; return ( - + + + {t("production.settings.statistics.exclude_suspended")} + +
+ } + > {(provided) => ( diff --git a/client/src/components/production-board-kanban/settings/defaultKanbanSettings.js b/client/src/components/production-board-kanban/settings/defaultKanbanSettings.js index 1c7a264b7..93486a11d 100644 --- a/client/src/components/production-board-kanban/settings/defaultKanbanSettings.js +++ b/client/src/components/production-board-kanban/settings/defaultKanbanSettings.js @@ -91,7 +91,8 @@ const defaultKanbanSettings = { subtotal: false, statisticsOrder: statisticsItems.map((item) => item.id), selectedMdInsCos: [], - selectedEstimators: [] + selectedEstimators: [], + excludeSuspended: false }; const defaultFilters = { search: "", employeeId: null, alert: false }; diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 976222c46..9e41f90d8 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -3265,6 +3265,7 @@ "information": "Information", "layout": "Layout", "statistics": { + "exclude_suspended": "Exclude Suspended Jobs", "jobs_in_production": "Jobs in Production", "tasks_in_production": "Tasks in Production", "tasks_in_view": "Tasks in View", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 619b7f0b1..987149079 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -3259,6 +3259,7 @@ "information": "", "layout": "", "statistics": { + "exclude_suspended": "", "jobs_in_production": "", "tasks_in_production": "", "tasks_in_view": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index a0c5be98e..357d6aa4b 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -3259,6 +3259,7 @@ "information": "", "layout": "", "statistics": { + "exclude_suspended": "", "jobs_in_production": "", "tasks_in_production": "", "tasks_in_view": "", From 6242e0f30993405b265cafaca89963fc63858454 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Tue, 28 Apr 2026 18:10:49 -0700 Subject: [PATCH 05/17] IO-3667 - Correction for Styling Signed-off-by: Allan Carr --- .../production-board-kanban-card.component.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/production-board-kanban/production-board-kanban-card.component.jsx b/client/src/components/production-board-kanban/production-board-kanban-card.component.jsx index 81ebb197c..c12a7c6c9 100644 --- a/client/src/components/production-board-kanban/production-board-kanban-card.component.jsx +++ b/client/src/components/production-board-kanban/production-board-kanban-card.component.jsx @@ -431,7 +431,7 @@ export default function ProductionBoardCard({ technician, card, bodyshop, cardSe Date: Mon, 4 May 2026 16:11:54 -0400 Subject: [PATCH 06/17] feature/IO-3674-Fix-Save-And-New - Fix Save and New so state gets reset on form when starting from a new employee --- .../shop-employees-form.component.jsx | 8 ++++++-- .../shop-employees-form.component.test.jsx | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/client/src/components/shop-employees/shop-employees-form.component.jsx b/client/src/components/shop-employees/shop-employees-form.component.jsx index 8218597b9..7ff148791 100644 --- a/client/src/components/shop-employees/shop-employees-form.component.jsx +++ b/client/src/components/shop-employees/shop-employees-form.component.jsx @@ -220,12 +220,16 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi }); const savedEmployee = result?.data?.insert_employees?.returning?.[0]; - syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues); - if (submitAction === "saveAndNew") { + if (isNewEmployee) { + resetEmployeeFormToCurrentData(); + } navigateToEmployee("new"); } else if (savedEmployee?.id) { + syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues); navigateToEmployee(savedEmployee.id); + } else { + syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues); } notification.success({ diff --git a/client/src/components/shop-employees/shop-employees-form.component.test.jsx b/client/src/components/shop-employees/shop-employees-form.component.test.jsx index dc022a96a..6da7ef3a6 100644 --- a/client/src/components/shop-employees/shop-employees-form.component.test.jsx +++ b/client/src/components/shop-employees/shop-employees-form.component.test.jsx @@ -3,7 +3,12 @@ import { Form } from "antd"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { useEffect } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { DELETE_VACATION, INSERT_EMPLOYEES, QUERY_EMPLOYEE_BY_ID, UPDATE_EMPLOYEE } from "../../graphql/employees.queries"; +import { + DELETE_VACATION, + INSERT_EMPLOYEES, + QUERY_EMPLOYEE_BY_ID, + UPDATE_EMPLOYEE +} from "../../graphql/employees.queries"; import { ShopEmployeesFormComponent } from "./shop-employees-form.component.jsx"; const insertEmployeesMock = vi.fn(); @@ -335,6 +340,15 @@ describe("ShopEmployeesFormComponent", () => { expect(formInstance.isFieldsTouched()).toBe(false); }); + await waitFor(() => { + expect(screen.getByRole("textbox", { name: "First Name" })).toHaveValue(""); + expect(screen.getByRole("textbox", { name: "Last Name" })).toHaveValue(""); + expect(screen.getByRole("textbox", { name: "Employee Number" })).toHaveValue(""); + expect(screen.getByRole("textbox", { name: "PIN" })).toHaveValue(""); + expect(screen.getByRole("textbox", { name: "Hire Date" })).toHaveValue(""); + }); + + expect(screen.getByText("New Employee")).toBeInTheDocument(); expect(navigateMock).toHaveBeenCalledWith({ search: "employeeId=new" }); From 32e67b14b673ce85f3c79a9560b7477f7bbd22a8 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 4 May 2026 16:31:41 -0400 Subject: [PATCH 07/17] feature/IO-3672-Reynolds-Adjustments-V3 - Make sure there is never a scenario where a ROGOG does not have a ROLABOR --- server/rr/rr-job-export.js | 12 +-- server/rr/rr-job-helpers.js | 30 +++++-- server/rr/rr-job-helpers.test.js | 142 +++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 13 deletions(-) diff --git a/server/rr/rr-job-export.js b/server/rr/rr-job-export.js index 15e2f2a34..175780f6b 100644 --- a/server/rr/rr-job-export.js +++ b/server/rr/rr-job-export.js @@ -331,16 +331,12 @@ const updateRRRepairOrderWithFullData = async (args) => { payload.roNo = String(roNo); payload.outsdRoNo = job?.ro_number || job?.id || undefined; - // RR update rejects placeholder non-labor ROLABOR rows with zero labor prices. - // Keep only the actual labor jobs in ROLABOR and let ROGOG carry parts/extras. + // RR update needs a ROLABOR row for every ROGOG JobNo, but rejects zero-price placeholders. + // buildRolaborFromRogog mirrors the GOG price into each row, so keep the full 1:1 set. if (payload.rolabor?.ops?.length && payload.rogg?.ops?.length) { - const laborJobNos = new Set( - payload.rogg.ops - .filter((op) => op?.segmentKind === "laborTaxable" || op?.segmentKind === "laborNonTaxable") - .map((op) => String(op.jobNo)) - ); + const roggJobNos = new Set(payload.rogg.ops.map((op) => String(op.jobNo))); - payload.rolabor.ops = payload.rolabor.ops.filter((op) => laborJobNos.has(String(op?.jobNo))); + payload.rolabor.ops = payload.rolabor.ops.filter((op) => roggJobNos.has(String(op?.jobNo))); if (!payload.rolabor.ops.length) { delete payload.rolabor; diff --git a/server/rr/rr-job-helpers.js b/server/rr/rr-job-helpers.js index 23d003ccf..560e75039 100644 --- a/server/rr/rr-job-helpers.js +++ b/server/rr/rr-job-helpers.js @@ -120,6 +120,22 @@ const formatDecimal = (value, maxDecimals = 2) => { return rounded.toFixed(maxDecimals).replace(/\.?0+$/, "") || "0"; }; +const isLaborSideProfitCenter = (alloc = {}) => { + const pc = alloc?.profitCenter || {}; + + if ( + pc.rr_requires_rolabor || + pc.rr_force_rolabor || + pc.rr_labor_side || + pc.rr_is_labor || + pc.is_labor + ) { + return true; + } + + return [alloc.center, pc.name, pc.accountdesc, pc.accountname].some((value) => /\blabou?r\b/i.test(String(value || ""))); +}; + const buildRolaborBillFields = ({ amountUnits = 0, hours = 0, rate = 0 } = {}) => { const normalizedAmount = toFiniteNumber(amountUnits); @@ -335,6 +351,7 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo const pc = alloc?.profitCenter || {}; const breakOut = pc.rr_gogcode; const itemType = pc.rr_item_type; + const laborSideProfitCenter = isLaborSideProfitCenter(alloc); // Only centers configured for RR GOG are included if (!breakOut || !itemType) continue; @@ -434,6 +451,8 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo segments.forEach((seg, idx) => { const jobNo = String(ops.length + 1); // global, 1-based JobNo across all centers/segments const isLaborSegment = seg.kind === "laborTaxable" || seg.kind === "laborNonTaxable"; + const isPartsSegment = seg.kind === "partsTaxable" || seg.kind === "partsNonTaxable"; + const rolaborRequired = isLaborSegment || (isPartsSegment && laborSideProfitCenter); const segmentHours = isLaborSegment ? seg.kind === "laborTaxable" ? toFiniteNumber(alloc.laborTaxableHours) @@ -465,7 +484,8 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo segmentIndex: idx, segmentCount, segmentHours, - segmentBillRate + segmentBillRate, + rolaborRequired }); }); } @@ -484,9 +504,9 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo * * We still keep a 1:1 mapping with GOG ops: each op gets a corresponding * OpCodeLaborInfo entry using the same JobNo and the same tax flag as its - * GOG line. Labor sale amounts are mirrored into ROLABOR and, when hours - * are available from allocations, weighted bill hours/rates are also - * populated so the labor subsection is editable in Ignite. + * GOG line. Sale amounts are mirrored into ROLABOR so Reynolds has a + * non-zero job anchor for every ROGOG JobNo; when labor hours are available + * from allocations, weighted bill hours/rates are also populated. * * @param {Object} rogg - result of buildRogogFromAllocations * @param {Object} opts @@ -506,7 +526,7 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => { const linePayType = firstLine.custPayTypeFlag || "C"; const isLaborSegment = op.segmentKind === "laborTaxable" || op.segmentKind === "laborNonTaxable"; - const laborAmount = isLaborSegment ? String(firstLine?.amount?.custPrice ?? "0") : "0"; + const laborAmount = String(firstLine?.amount?.custPrice ?? "0"); const laborBill = isLaborSegment ? buildRolaborBillFields({ amountUnits: laborAmount, diff --git a/server/rr/rr-job-helpers.test.js b/server/rr/rr-job-helpers.test.js index c3a6ee5f3..483831ce2 100644 --- a/server/rr/rr-job-helpers.test.js +++ b/server/rr/rr-job-helpers.test.js @@ -115,4 +115,146 @@ describe("server/rr/rr-job-helpers", () => { ] }); }); + + it("mirrors parts assigned to a labor-side RR profit center into ROLABOR", () => { + const { buildRRRepairOrderPayload } = loadHelpers(); + + const payload = buildRRRepairOrderPayload({ + job: { + id: "job-2", + ro_number: "RO-456", + v_vin: "3GCUKHEL3TG292014" + }, + selectedCustomer: { customerNo: "411588" }, + advisorNo: "70754", + allocations: [ + { + center: "Customer Pay CV Labor", + partsSale: { amount: 15000, precision: 2 }, + partsTaxableSale: { amount: 0, precision: 2 }, + partsNonTaxableSale: { amount: 15000, precision: 2 }, + laborTaxableSale: { amount: 0, precision: 2 }, + laborNonTaxableSale: { amount: 0, precision: 2 }, + extrasSale: { amount: 0, precision: 2 }, + extrasTaxableSale: { amount: 0, precision: 2 }, + extrasNonTaxableSale: { amount: 0, precision: 2 }, + totalSale: { amount: 15000, precision: 2 }, + cost: { amount: 0, precision: 2 }, + profitCenter: { + rr_gogcode: "VL", + rr_item_type: "P", + accountdesc: "Customer Pay CV Labor" + } + } + ], + opCode: "30CVZBDY" + }); + + expect(payload.rogg.ops[0]).toMatchObject({ + opCode: "30CVZBDY", + jobNo: "1", + segmentKind: "partsNonTaxable", + rolaborRequired: true, + lines: [ + { + breakOut: "VL", + itemType: "P", + itemDesc: "Customer Pay CV Labor", + custTxblNtxblFlag: "N", + amount: { + custPrice: "150.00", + dlrCost: "0.00" + } + } + ] + }); + + expect(payload.rolabor).toEqual({ + ops: [ + { + opCode: "30CVZBDY", + jobNo: "1", + custPayTypeFlag: "C", + custTxblNtxblFlag: "N", + bill: { + payType: "Cust", + jobTotalHrs: "0", + billTime: "0", + billRate: "0" + }, + amount: { + payType: "Cust", + amtType: "Job", + custPrice: "150.00", + totalAmt: "150.00" + } + } + ] + }); + }); + + it("mirrors regular ROGOG parts into ROLABOR so Reynolds can find the JobNo", () => { + const { buildRRRepairOrderPayload } = loadHelpers(); + + const payload = buildRRRepairOrderPayload({ + job: { + id: "job-3", + ro_number: "CDK10131", + v_vin: "3TMLU4EN1AM044343" + }, + selectedCustomer: { customerNo: "69158" }, + advisorNo: "6224", + allocations: [ + { + center: "B/S PARTS", + partsSale: { amount: 15000, precision: 2 }, + partsTaxableSale: { amount: 15000, precision: 2 }, + partsNonTaxableSale: { amount: 0, precision: 2 }, + laborTaxableSale: { amount: 0, precision: 2 }, + laborNonTaxableSale: { amount: 0, precision: 2 }, + extrasSale: { amount: 0, precision: 2 }, + extrasTaxableSale: { amount: 0, precision: 2 }, + extrasNonTaxableSale: { amount: 0, precision: 2 }, + totalSale: { amount: 15000, precision: 2 }, + cost: { amount: 0, precision: 2 }, + profitCenter: { + rr_gogcode: "FR", + rr_item_type: "G", + accountdesc: "B/S PARTS" + } + } + ], + opCode: "60GMZ" + }); + + expect(payload.rogg.ops[0]).toMatchObject({ + opCode: "60GMZ", + jobNo: "1", + segmentKind: "partsTaxable", + rolaborRequired: false + }); + + expect(payload.rolabor).toEqual({ + ops: [ + { + opCode: "60GMZ", + jobNo: "1", + custPayTypeFlag: "C", + custTxblNtxblFlag: "T", + bill: { + payType: "Cust", + jobTotalHrs: "0", + billTime: "0", + billRate: "0" + }, + amount: { + payType: "Cust", + amtType: "Job", + custPrice: "150.00", + totalAmt: "150.00" + } + } + ] + }); + }); }); From c8262da44096f3356fea267c5f8388cc875c4de6 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 4 May 2026 16:58:06 -0400 Subject: [PATCH 08/17] feature/IO-3672-Reynolds-Adjustments-V3 - Hide DMS Posting sheet report in reynolds mode. --- .../print-center-jobs/print-center-jobs.component.jsx | 5 ++++- .../report-center-modal/report-center-modal.component.jsx | 7 +++++++ client/src/utils/TemplateConstants.js | 4 +++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/client/src/components/print-center-jobs/print-center-jobs.component.jsx b/client/src/components/print-center-jobs/print-center-jobs.component.jsx index a33a387e1..b0a412f40 100644 --- a/client/src/components/print-center-jobs/print-center-jobs.component.jsx +++ b/client/src/components/print-center-jobs/print-center-jobs.component.jsx @@ -12,7 +12,7 @@ import Jobd3RdPartyModal from "../job-3rd-party-modal/job-3rd-party-modal.compon import PrintCenterItem from "../print-center-item/print-center-item.component"; import PrintCenterJobsLabels from "../print-center-jobs-labels/print-center-jobs-labels.component"; import PrintCenterSpeedPrint from "../print-center-speed-print/print-center-speed-print.component"; -import { bodyshopHasDmsKey } from "../../utils/dmsUtils"; +import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils"; import { selectTechnician } from "../../redux/tech/tech.selectors"; const mapStateToProps = createStructuredSelector({ @@ -36,6 +36,8 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia splitKey: bodyshop.imexshopid }); const hasDMSKey = bodyshopHasDmsKey(bodyshop); + const dmsMode = getDmsMode(bodyshop, "off"); + const isReynoldsMode = dmsMode === DMS_MAP.reynolds; const Templates = !hasDMSKey ? Object.keys(tempList) @@ -60,6 +62,7 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia (temp.regions && temp.regions[bodyshop.region_config]) || (temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true) ) + .filter((temp) => !isReynoldsMode || !temp.excludedDmsModes?.includes(dmsMode)) .filter((temp) => !technician || temp.group !== "financial"); const JobsReportsList = diff --git a/client/src/components/report-center-modal/report-center-modal.component.jsx b/client/src/components/report-center-modal/report-center-modal.component.jsx index 7205939f2..1c934e144 100644 --- a/client/src/components/report-center-modal/report-center-modal.component.jsx +++ b/client/src/components/report-center-modal/report-center-modal.component.jsx @@ -12,6 +12,7 @@ import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries"; import { selectReportCenter } from "../../redux/modals/modals.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors"; import DatePickerRanges from "../../utils/DatePickerRanges"; +import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils"; import { GenerateDocument } from "../../utils/RenderTemplate"; import { TemplateList } from "../../utils/TemplateConstants"; import dayjs from "../../utils/day"; @@ -48,12 +49,18 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) { const [loading, setLoading] = useState(false); const { t } = useTranslation(); const Templates = TemplateList("report_center"); + const dmsMode = getDmsMode(bodyshop, "off"); + const isReynoldsMode = dmsMode === DMS_MAP.reynolds; const ReportsList = Object.keys(Templates) .map((key) => Templates[key]) .filter((temp) => { const enhancedPayrollOn = Enhanced_Payroll.treatment === "on"; const adpPayrollOn = ADPPayroll.treatment === "on"; + if (isReynoldsMode && temp.excludedDmsModes?.includes(dmsMode)) { + return false; + } + if (enhancedPayrollOn && adpPayrollOn) { return temp.enhanced_payroll !== false || temp.adp_payroll !== false; } diff --git a/client/src/utils/TemplateConstants.js b/client/src/utils/TemplateConstants.js index 4e27d4d6b..0031e36d0 100644 --- a/client/src/utils/TemplateConstants.js +++ b/client/src/utils/TemplateConstants.js @@ -1,5 +1,6 @@ import i18n from "i18next"; //import { store } from "../redux/store"; +import { DMS_MAP } from "./dmsUtils"; import InstanceRenderManager from "./instanceRenderMgr"; export const EmailSettings = { @@ -570,7 +571,8 @@ export const TemplateList = (type, context) => { key: "dms_posting_sheet", disabled: false, group: "financial", - dms: true + dms: true, + excludedDmsModes: [DMS_MAP.reynolds] }, worksheet_sorted_by_team: { title: i18n.t("printcenter.jobs.worksheet_sorted_by_team"), From e6178a613d31feb7dc846621fb4ce327b567fa63 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 5 May 2026 13:31:03 -0400 Subject: [PATCH 09/17] feature/IO-3672-Reynolds-Adjustments-V3 - Expand Export logs for Reynolds --- server/rr/rr-export-logs.test.js | 21 ++++++- server/rr/rr-job-export.js | 9 ++- server/rr/rr-preview-metadata.js | 85 ++++++++++++++++++++++++++ server/rr/rr-preview-metadata.test.js | 68 +++++++++++++++++++++ server/rr/rr-register-socket-events.js | 8 ++- 5 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 server/rr/rr-preview-metadata.js create mode 100644 server/rr/rr-preview-metadata.test.js diff --git a/server/rr/rr-export-logs.test.js b/server/rr/rr-export-logs.test.js index 5178595cc..c32f24eee 100644 --- a/server/rr/rr-export-logs.test.js +++ b/server/rr/rr-export-logs.test.js @@ -88,6 +88,15 @@ describe("server/rr/rr-export-logs", () => { statusCode: "0", message: "Finalized" } + }, + metaExtra: { + rrPreview: { + provider: "rr", + previewFormat: "rr-rogog-preview.v1", + rogg: { + rows: [{ jobNo: "1", opCode: "BODY", custPrice: "125.00", dlrCost: "50.00" }] + } + } } }); @@ -106,7 +115,17 @@ describe("server/rr/rr-export-logs", () => { bodyshopid: "bodyshop-1", jobid: "job-1", successful: true, - useremail: "tech@example.com" + useremail: "tech@example.com", + metadata: { + provider: "rr", + rrPreview: { + provider: "rr", + previewFormat: "rr-rogog-preview.v1", + rogg: { + rows: [{ jobNo: "1", opCode: "BODY", custPrice: "125.00", dlrCost: "50.00" }] + } + } + } }, bill: { exported: true, diff --git a/server/rr/rr-job-export.js b/server/rr/rr-job-export.js index 175780f6b..057858ba7 100644 --- a/server/rr/rr-job-export.js +++ b/server/rr/rr-job-export.js @@ -2,6 +2,7 @@ const { buildRRRepairOrderPayload, buildMinimalRolaborFromJob } = require("./rr- const { buildClientAndOpts } = require("./rr-lookup"); const CreateRRLogEvent = require("./rr-logger-event"); const { withRRRequestXml } = require("./rr-log-xml"); +const { buildRRPreviewMetadata } = require("./rr-preview-metadata"); const { extractRrResponsibilityCenters } = require("./rr-responsibility-centers"); const CdkCalculateAllocations = require("./rr-calculate-allocations").default; const { resolveRROpCodeFromBodyshop } = require("./rr-utils"); @@ -383,7 +384,7 @@ const updateRRRepairOrderWithFullData = async (args) => { success = String(roStatus.status).toUpperCase() === "SUCCESS"; } - return { + const exportResult = { success, data, roStatus, @@ -393,6 +394,8 @@ const updateRRRepairOrderWithFullData = async (args) => { roNo: String(roNo), xml: response?.xml }; + exportResult.rrPreview = buildRRPreviewMetadata({ payload, result: exportResult }); + return exportResult; }; /** @@ -524,7 +527,7 @@ const exportJobToRR = async (args) => { // Extract canonical roNo you'll need for finalize step const roNo = data?.dmsRoNo ?? data?.outsdRoNo ?? roStatus?.dmsRoNo ?? null; - return { + const exportResult = { success, data, roStatus, @@ -534,6 +537,8 @@ const exportJobToRR = async (args) => { roNo, xml: response?.xml // expose XML for logging/diagnostics }; + exportResult.rrPreview = buildRRPreviewMetadata({ payload, result: exportResult }); + return exportResult; }; /** diff --git a/server/rr/rr-preview-metadata.js b/server/rr/rr-preview-metadata.js new file mode 100644 index 000000000..4b4b2ec49 --- /dev/null +++ b/server/rr/rr-preview-metadata.js @@ -0,0 +1,85 @@ +const segmentLabelMap = { + partsTaxable: "Parts Taxable", + partsNonTaxable: "Parts Non-Taxable", + extrasTaxable: "Extras Taxable", + extrasNonTaxable: "Extras Non-Taxable", + laborTaxable: "Labor Taxable", + laborNonTaxable: "Labor Non-Taxable" +}; + +const toCentsFromAmountString = (value) => { + const parsed = Number.parseFloat(value || "0"); + return Number.isNaN(parsed) ? 0 : Math.round(parsed * 100); +}; + +const buildRoggRows = (rogg) => { + if (!rogg || !Array.isArray(rogg.ops)) return []; + + const rows = []; + + rogg.ops.forEach((op) => { + (op.lines || []).forEach((line, idx) => { + const segmentKind = op.segmentKind; + const segmentCount = op.segmentCount || 0; + const segmentLabel = segmentLabelMap[segmentKind] || segmentKind; + const itemDesc = + segmentCount > 1 && segmentLabel ? `${line.itemDesc} (${segmentLabel})` : line.itemDesc; + + rows.push({ + key: `${op.jobNo}-${idx}`, + opCode: op.opCode, + jobNo: op.jobNo, + breakOut: line.breakOut, + itemType: line.itemType, + itemDesc, + custQty: line.custQty, + custPayTypeFlag: line.custPayTypeFlag, + custTxblNtxblFlag: line.custTxblNtxblFlag, + custPrice: line.amount?.custPrice, + dlrCost: line.amount?.dlrCost, + segmentKind, + segmentCount + }); + }); + }); + + return rows; +}; + +const buildRoggTotals = (roggRows) => { + const totals = roggRows.reduce( + (acc, row) => { + acc.totalCustPriceCents += toCentsFromAmountString(row.custPrice); + acc.totalDlrCostCents += toCentsFromAmountString(row.dlrCost); + return acc; + }, + { totalCustPriceCents: 0, totalDlrCostCents: 0 } + ); + + return { + ...totals, + totalCustPrice: (totals.totalCustPriceCents / 100).toFixed(2), + totalDlrCost: (totals.totalDlrCostCents / 100).toFixed(2) + }; +}; + +const buildRRPreviewMetadata = ({ payload, result } = {}) => { + const rogg = payload?.rogg || null; + const roggRows = buildRoggRows(rogg); + + return { + provider: "rr", + previewFormat: "rr-rogog-preview.v1", + roNo: result?.roNo || payload?.roNo || null, + outsdRoNo: payload?.outsdRoNo || null, + rogg: { + raw: rogg, + rows: roggRows, + totals: buildRoggTotals(roggRows) + } + }; +}; + +module.exports = { + buildRRPreviewMetadata +}; diff --git a/server/rr/rr-preview-metadata.test.js b/server/rr/rr-preview-metadata.test.js new file mode 100644 index 000000000..a3f205573 --- /dev/null +++ b/server/rr/rr-preview-metadata.test.js @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { buildRRPreviewMetadata } = require("./rr-preview-metadata"); + +describe("server/rr/rr-preview-metadata", () => { + it("captures ROGOG preview rows and totals", () => { + const metadata = buildRRPreviewMetadata({ + payload: { + outsdRoNo: "RO-100", + rogg: { + ops: [ + { + opCode: "BODY", + jobNo: "1", + segmentKind: "laborTaxable", + segmentCount: 2, + lines: [ + { + breakOut: "B", + itemType: "LAB", + itemDesc: "Body Labor", + custQty: "1.0", + custPayTypeFlag: "C", + custTxblNtxblFlag: "T", + amount: { + custPrice: "125.00", + dlrCost: "50.00" + } + } + ] + } + ] + } + }, + result: { roNo: "12345" } + }); + + expect(metadata).toMatchObject({ + provider: "rr", + previewFormat: "rr-rogog-preview.v1", + roNo: "12345", + outsdRoNo: "RO-100", + rogg: { + rows: [ + { + opCode: "BODY", + jobNo: "1", + breakOut: "B", + itemType: "LAB", + itemDesc: "Body Labor (Labor Taxable)", + custTxblNtxblFlag: "T", + custPrice: "125.00", + dlrCost: "50.00" + } + ], + totals: { + totalCustPriceCents: 12500, + totalDlrCostCents: 5000, + totalCustPrice: "125.00", + totalDlrCost: "50.00" + } + } + }); + expect(metadata).not.toHaveProperty("rolabor"); + }); +}); diff --git a/server/rr/rr-register-socket-events.js b/server/rr/rr-register-socket-events.js index 378469322..d64a80404 100644 --- a/server/rr/rr-register-socket-events.js +++ b/server/rr/rr-register-socket-events.js @@ -1497,7 +1497,8 @@ const registerRREvents = ({ socket, redisHelpers }) => { dmsRoNo, customerNo: String(effectiveCustNo), advisorNo: String(advisorNo), - vin: job?.v_vin || null + vin: job?.v_vin || null, + rrPreview: result?.rrPreview || null }, defaultRRTTL ); @@ -1705,7 +1706,10 @@ const registerRREvents = ({ socket, redisHelpers }) => { jobId: rid, job, bodyshop, - result: finalizeResult + result: finalizeResult, + metaExtra: { + rrPreview: pending?.rrPreview || null + } }); // Clean pending key From c6af2b34b272186f9a24fa6884e8cc76d795c820 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 5 May 2026 13:41:50 -0400 Subject: [PATCH 10/17] feature/IO-3676-Order-As-In-House-Quantity - Fix --- .../job-detail-lines/job-lines.component.jsx | 12 ++----- .../job-lines.in-house-bill-lines.utils.js | 11 +++++++ ...ob-lines.in-house-bill-lines.utils.test.js | 33 +++++++++++++++++++ 3 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 client/src/components/job-detail-lines/job-lines.in-house-bill-lines.utils.js create mode 100644 client/src/components/job-detail-lines/job-lines.in-house-bill-lines.utils.test.js 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 16a3d6c87..7e3ce8b85 100644 --- a/client/src/components/job-detail-lines/job-lines.component.jsx +++ b/client/src/components/job-detail-lines/job-lines.component.jsx @@ -44,6 +44,7 @@ import JoblineTeamAssignment from "../job-line-team-assignment/job-line-team-ass import JobSendPartPriceChangeComponent from "../job-send-parts-price-change/job-send-parts-price-change.component"; import PartsOrderDrawer from "../parts-order-list-table/parts-order-list-table-drawer.component"; import PartsOrderModalContainer from "../parts-order-modal/parts-order-modal.container"; +import { buildInHouseBillLines } from "./job-lines.in-house-bill-lines.utils"; import JobLinesExpander from "./job-lines-expander.component"; import JobLinesPartPriceChange from "./job-lines-part-price-change.component"; import JobLinesExpanderSimple from "./jobs-lines-expander-simple.component"; @@ -595,16 +596,7 @@ export function JobLinesComponent({ isinhouse: true, date: dayjs(), total: 0, - billlines: selectedLines.map((p) => ({ - joblineid: p.id, - actual_price: p.act_price, - actual_cost: 0, - line_desc: p.line_desc, - line_remarks: p.line_remarks, - part_type: p.part_type, - quantity: p.quantity || 1, - applicable_taxes: { local: false, state: false, federal: false } - })) + billlines: buildInHouseBillLines(selectedLines) } } }); diff --git a/client/src/components/job-detail-lines/job-lines.in-house-bill-lines.utils.js b/client/src/components/job-detail-lines/job-lines.in-house-bill-lines.utils.js new file mode 100644 index 000000000..8a75d1755 --- /dev/null +++ b/client/src/components/job-detail-lines/job-lines.in-house-bill-lines.utils.js @@ -0,0 +1,11 @@ +export const buildInHouseBillLines = (lines) => + lines.map((line) => ({ + joblineid: line.id, + actual_price: line.act_price, + actual_cost: 0, + line_desc: line.line_desc, + line_remarks: line.line_remarks, + part_type: line.part_type, + quantity: line.part_qty ?? line.quantity ?? 1, + applicable_taxes: { local: false, state: false, federal: false } + })); diff --git a/client/src/components/job-detail-lines/job-lines.in-house-bill-lines.utils.test.js b/client/src/components/job-detail-lines/job-lines.in-house-bill-lines.utils.test.js new file mode 100644 index 000000000..27f5f731b --- /dev/null +++ b/client/src/components/job-detail-lines/job-lines.in-house-bill-lines.utils.test.js @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { buildInHouseBillLines } from "./job-lines.in-house-bill-lines.utils"; + +describe("buildInHouseBillLines", () => { + it("carries job line part quantity into the in-house bill line", () => { + const billLines = buildInHouseBillLines([ + { + id: "job-line-1", + act_price: 125, + line_desc: "Door shell", + line_remarks: "Left", + part_type: "PAA", + part_qty: 3 + } + ]); + + expect(billLines[0]).toMatchObject({ + joblineid: "job-line-1", + actual_price: 125, + actual_cost: 0, + line_desc: "Door shell", + line_remarks: "Left", + part_type: "PAA", + quantity: 3, + applicable_taxes: { local: false, state: false, federal: false } + }); + }); + + it("falls back to legacy quantity and then one when part quantity is absent", () => { + expect(buildInHouseBillLines([{ id: "legacy", quantity: 2 }])[0].quantity).toBe(2); + expect(buildInHouseBillLines([{ id: "missing" }])[0].quantity).toBe(1); + }); +}); From 88ae1fb1cc4d7293647b0eec92a58e38b48030c3 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 5 May 2026 13:56:16 -0400 Subject: [PATCH 11/17] feature/IO-3673-Order-Parts-Receive-Bill-Bug - Fix --- .../parts-order-modal.component.jsx | 3 ++ .../parts-order-modal.container.jsx | 50 ++++++++----------- .../parts-order-modal.utils.js | 23 +++++++++ .../parts-order-modal.utils.test.js | 32 ++++++++++++ 4 files changed, 78 insertions(+), 30 deletions(-) create mode 100644 client/src/components/parts-order-modal/parts-order-modal.utils.js create mode 100644 client/src/components/parts-order-modal/parts-order-modal.utils.test.js diff --git a/client/src/components/parts-order-modal/parts-order-modal.component.jsx b/client/src/components/parts-order-modal/parts-order-modal.component.jsx index 3766b2e7b..f04b5f7bc 100644 --- a/client/src/components/parts-order-modal/parts-order-modal.component.jsx +++ b/client/src/components/parts-order-modal/parts-order-modal.component.jsx @@ -176,6 +176,9 @@ export function PartsOrderModalComponent({ > + { - const originalLine = linesToOrder?.[index]; - const jobLineId = isReturn ? originalLine?.joblineid : originalLine?.id; - - return { - ...p, - job_line_id: jobLineId, - ...(isReturn && { cm_received: false }) - }; + const forcedLines = buildSubmittedPartsOrderLines({ + submittedLines, + linesToOrder, + isReturn }); let insertResult; @@ -147,10 +143,7 @@ export function PartsOrderModalContainer({ type: isReturn ? "jobspartsreturn" : "jobspartsorder" }); - // Use linesToOrder from context instead of form values to preserve job line ids - const jobLineIds = (linesToOrder ?? []) - .filter((line) => (isReturn ? line.joblineid : line.id)) - .map((line) => (isReturn ? line.joblineid : line.id)); + const jobLineIds = getSubmittedPartsOrderJobLineIds(forcedLines); try { const jobLinesResult = await updateJobLines({ @@ -206,23 +199,20 @@ export function PartsOrderModalContainer({ isinhouse: true, date: dayjs(), total: 0, - billlines: forcedLines.map((p, index) => { - const originalLine = linesToOrder?.[index]; - return { - joblineid: isReturn ? originalLine?.joblineid : originalLine?.id, - actual_price: p.act_price, - actual_cost: 0, // p.act_price, - line_desc: p.line_desc, - line_remarks: p.line_remarks, - part_type: p.part_type, - quantity: p.quantity || 1, - applicable_taxes: { - local: false, - state: false, - federal: false - } - }; - }) + billlines: forcedLines.map((p) => ({ + joblineid: p.job_line_id, + actual_price: p.act_price, + actual_cost: 0, // p.act_price, + line_desc: p.line_desc, + line_remarks: p.line_remarks, + part_type: p.part_type, + quantity: p.quantity || 1, + applicable_taxes: { + local: false, + state: false, + federal: false + } + })) } } }); diff --git a/client/src/components/parts-order-modal/parts-order-modal.utils.js b/client/src/components/parts-order-modal/parts-order-modal.utils.js new file mode 100644 index 000000000..1517704ce --- /dev/null +++ b/client/src/components/parts-order-modal/parts-order-modal.utils.js @@ -0,0 +1,23 @@ +export const getPartsOrderJobLineId = ({ line, originalLine, isReturn }) => { + return line?.job_line_id || (isReturn ? originalLine?.joblineid : originalLine?.id); +}; + +export const buildSubmittedPartsOrderLines = ({ submittedLines = [], linesToOrder = [], isReturn }) => { + return submittedLines.map((line, index) => { + const jobLineId = getPartsOrderJobLineId({ + line, + originalLine: linesToOrder?.[index], + isReturn + }); + + return { + ...line, + job_line_id: jobLineId, + ...(isReturn && { cm_received: false }) + }; + }); +}; + +export const getSubmittedPartsOrderJobLineIds = (partsOrderLines = []) => { + return partsOrderLines.map((line) => line.job_line_id).filter(Boolean); +}; diff --git a/client/src/components/parts-order-modal/parts-order-modal.utils.test.js b/client/src/components/parts-order-modal/parts-order-modal.utils.test.js new file mode 100644 index 000000000..1c7309680 --- /dev/null +++ b/client/src/components/parts-order-modal/parts-order-modal.utils.test.js @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { buildSubmittedPartsOrderLines, getSubmittedPartsOrderJobLineIds } from "./parts-order-modal.utils.js"; + +describe("parts order modal utilities", () => { + it("preserves submitted job line ids after a row is removed", () => { + const submittedLines = [ + { line_desc: "second line", job_line_id: "job-line-2" }, + { line_desc: "third line", job_line_id: "job-line-3" } + ]; + const linesToOrder = [{ id: "job-line-1" }, { id: "job-line-2" }, { id: "job-line-3" }]; + + const result = buildSubmittedPartsOrderLines({ submittedLines, linesToOrder, isReturn: false }); + + expect(result.map((line) => line.job_line_id)).toEqual(["job-line-2", "job-line-3"]); + expect(getSubmittedPartsOrderJobLineIds(result)).toEqual(["job-line-2", "job-line-3"]); + }); + + it("falls back to original return line ids when the form omits hidden metadata", () => { + const submittedLines = [{ line_desc: "return line" }]; + const linesToOrder = [{ joblineid: "return-job-line-1" }]; + + const result = buildSubmittedPartsOrderLines({ submittedLines, linesToOrder, isReturn: true }); + + expect(result).toEqual([ + { + line_desc: "return line", + job_line_id: "return-job-line-1", + cm_received: false + } + ]); + }); +}); From f294eafde7d829f81ff040748bfb9481f51f4d37 Mon Sep 17 00:00:00 2001 From: Patrick Fic Date: Wed, 6 May 2026 11:57:53 -0700 Subject: [PATCH 12/17] IO-3686 River city enhancements for AR customers and Contact Code --- bodyshop_translations.babel | 105 ++++++++++++++++++ .../pbs-customer-selector.jsx | 10 +- client/src/translations/en_us/common.json | 3 +- client/src/translations/es/common.json | 3 +- client/src/translations/fr/common.json | 3 +- server/accounting/pbs/pbs-job-export.js | 24 +++- 6 files changed, 138 insertions(+), 10 deletions(-) diff --git a/bodyshop_translations.babel b/bodyshop_translations.babel index 2449aeb52..e31ec91f4 100644 --- a/bodyshop_translations.babel +++ b/bodyshop_translations.babel @@ -27699,6 +27699,27 @@ + + addpayer + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + addtopartsqueue false @@ -31118,6 +31139,27 @@ dms + + IsARCustomer + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + address false @@ -45333,6 +45375,69 @@ + + esign-document-completed + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + esign-document-opened + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + esign-document-upload-failed + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + intake-delivery-checklist-completed false diff --git a/client/src/components/dms-customer-selector/pbs-customer-selector.jsx b/client/src/components/dms-customer-selector/pbs-customer-selector.jsx index fcded201e..7389e2f2d 100644 --- a/client/src/components/dms-customer-selector/pbs-customer-selector.jsx +++ b/client/src/components/dms-customer-selector/pbs-customer-selector.jsx @@ -1,4 +1,4 @@ -import { Button, Col } from "antd"; +import { Button, Checkbox, Col } from "antd"; import ResponsiveTable from "../responsive-table/responsive-table.component"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -49,7 +49,13 @@ export default function PBSCustomerSelector({ bodyshop, socket }) { if (!open) return null; const columns = [ - { title: t("jobs.fields.dms.id"), dataIndex: "ContactId", key: "ContactId" }, + { title: t("jobs.fields.dms.id"), dataIndex: "Code", key: "ContactId" }, + { + title: t("jobs.fields.dms.IsARCustomer"), + dataIndex: "IsARCustomer", + key: "IsARCustomer", + render: (text, record) => + }, { title: t("jobs.fields.dms.name1"), key: "name1", diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 976222c46..5199922ee 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -1785,9 +1785,9 @@ }, "jobs": { "actions": { - "addpayer": "Add Payer", "addDocuments": "Add Job Documents", "addNote": "Add Note", + "addpayer": "Add Payer", "addtopartsqueue": "Add to Parts Queue", "addtoproduction": "Add to Production", "addtoscoreboard": "Add to Scoreboard", @@ -1964,6 +1964,7 @@ "ded_status": "Deductible Status", "depreciation_taxes": "Betterment/Depreciation/Taxes", "dms": { + "IsARCustomer": "AR Customer?", "address": "Customer Address", "advisor": "Advisor #", "amount": "Amount", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 619b7f0b1..bd8e7740d 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -1779,9 +1779,9 @@ }, "jobs": { "actions": { - "addpayer": "", "addDocuments": "Agregar documentos de trabajo", "addNote": "Añadir la nota", + "addpayer": "", "addtopartsqueue": "", "addtoproduction": "", "addtoscoreboard": "", @@ -1958,6 +1958,7 @@ "ded_status": "Estado deducible", "depreciation_taxes": "Depreciación / Impuestos", "dms": { + "IsARCustomer": "", "address": "", "advisor": "", "amount": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index a0c5be98e..0b6245761 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -1779,9 +1779,9 @@ }, "jobs": { "actions": { - "addpayer": "", "addDocuments": "Ajouter des documents de travail", "addNote": "Ajouter une note", + "addpayer": "", "addtopartsqueue": "", "addtoproduction": "", "addtoscoreboard": "", @@ -1958,6 +1958,7 @@ "ded_status": "Statut de franchise", "depreciation_taxes": "Amortissement / taxes", "dms": { + "IsARCustomer": "", "address": "", "advisor": "", "amount": "", diff --git a/server/accounting/pbs/pbs-job-export.js b/server/accounting/pbs/pbs-job-export.js index 7e0499023..1eddd759e 100644 --- a/server/accounting/pbs/pbs-job-export.js +++ b/server/accounting/pbs/pbs-job-export.js @@ -98,12 +98,26 @@ exports.PbsSelectedCustomer = async function PbsSelectedCustomer(socket, selecte socket.JobData.ownr_fn || "" } ${socket.JobData.ownr_ln || ""} ${socket.JobData.ownr_co_nm || ""}` ); - const ownerRef = await UpsertContactData(socket, selectedCustomerId); - socket.ownerRef = ownerRef; - WsLogger.createLogEvent(socket, "INFO", `Upserting vehicle information to DMS for ${socket.JobData.v_vin}`); - const vehicleRef = await UpsertVehicleData(socket, ownerRef.ReferenceId); - socket.vehicleRef = vehicleRef; + //If this is an AR customer, don't do anything. + + const selectedCustomer = [...(socket.DMSVehCustomer ? [{ ...socket.DMSVehCustomer, vinOwner: true }] : []), + ...socket.DMSCustList]?.find((cust) => cust.ContactId === selectedCustomerId); + + if (selectedCustomer?.IsARCustomer) { + + WsLogger.createLogEvent(socket, "INFO", `Skipping contact and vehicle update becuase it is marked as an AR contact in PBS.`); + + } + else { + + const ownerRef = await UpsertContactData(socket, selectedCustomerId); + socket.ownerRef = ownerRef; + WsLogger.createLogEvent(socket, "INFO", `Upserting vehicle information to DMS for ${socket.JobData.v_vin}`); + const vehicleRef = await UpsertVehicleData(socket, ownerRef.ReferenceId); + socket.vehicleRef = vehicleRef; + } + } else { WsLogger.createLogEvent( socket, From fcba77fe20288a9135c12fe374a88b3ed9d6f31c Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 6 May 2026 16:45:00 -0400 Subject: [PATCH 13/17] feature/IO-3687-Grey-Scale-Invisible-text - implement --- .../document-editor-local.component.jsx | 42 +++++- .../document-editor.component.jsx | 43 +++++- .../document-editor.utility.js | 123 ++++++++++++++++++ ...s-documents-imgproxy-gallery.component.jsx | 116 ++++++++++++++--- client/src/translations/en_us/common.json | 1 + client/src/translations/es/common.json | 1 + client/src/translations/fr/common.json | 1 + 7 files changed, 302 insertions(+), 25 deletions(-) create mode 100644 client/src/components/document-editor/document-editor.utility.js diff --git a/client/src/components/document-editor/document-editor-local.component.jsx b/client/src/components/document-editor/document-editor-local.component.jsx index 18bf0ead8..724caea34 100644 --- a/client/src/components/document-editor/document-editor-local.component.jsx +++ b/client/src/components/document-editor/document-editor-local.component.jsx @@ -1,5 +1,5 @@ import axios from "axios"; -import { Result } from "antd"; +import { Result, theme } from "antd"; import * as markerjs2 from "markerjs2"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -9,6 +9,12 @@ import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selecto import { handleUpload } from "../documents-local-upload/documents-local-upload.utility"; import LoadingSpinner from "../loading-spinner/loading-spinner.component"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +import { + addGreyscaleButtonToMarkerArea, + addImageHistoryUndoToMarkerArea, + applyGreyscaleToMarkerAreaImage, + setMarkerAreaImageSource +} from "./document-editor.utility"; const mapStateToProps = createStructuredSelector({ currentUser: selectCurrentUser, @@ -24,7 +30,9 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { const [imageLoaded, setImageLoaded] = useState(false); const [imageLoading, setImageLoading] = useState(true); const markerArea = useRef(null); + const imageHistory = useRef([]); const { t } = useTranslation(); + const { token } = theme.useToken(); const notification = useNotification(); const [uploading, setUploading] = useState(false); @@ -32,6 +40,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { async (dataUrl) => { if (uploading) return; setUploading(true); + setLoading(true); const blob = await b64toBlob(dataUrl); const nameWithoutExt = filename.split(".").slice(0, -1).join(".").trim(); const parts = nameWithoutExt.split("-"); @@ -70,6 +79,23 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { [filename, jobid, notification, uploading] ); + const handleGreyscale = useCallback(() => { + if (!imgRef.current || loading || uploaded || imageLoading || !imageLoaded) return; + + imageHistory.current.push(imgRef.current.src); + applyGreyscaleToMarkerAreaImage(markerArea.current, imgRef.current); + }, [imageLoaded, imageLoading, loading, uploaded]); + + const undoImageEdit = useCallback(() => { + if (!imgRef.current) return; + + const previousSrc = imageHistory.current.pop(); + + if (previousSrc) { + setMarkerAreaImageSource(markerArea.current, imgRef.current, previousSrc); + } + }, []); + useEffect(() => { if (imgRef.current !== null && imageLoaded && !markerArea.current) { // create a marker.js MarkerArea @@ -93,8 +119,10 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { markerArea.current.renderImageQuality = 1; //markerArea.current.settings.displayMode = "inline"; markerArea.current.show(); + addGreyscaleButtonToMarkerArea(markerArea.current, handleGreyscale, t("documents.labels.greyscale")); + addImageHistoryUndoToMarkerArea(markerArea.current, () => imageHistory.current.length > 0, undoImageEdit); } - }, [triggerUpload, imageLoaded]); + }, [handleGreyscale, imageLoaded, t, triggerUpload, undoImageEdit]); useEffect(() => { if (!imageUrl) return; @@ -106,6 +134,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { try { const response = await axios.get(imageUrl, { responseType: "blob", signal: controller.signal }); const blobUrl = URL.createObjectURL(response.data); + imageHistory.current = []; setLoadedImageUrl((prevUrl) => { if (prevUrl) URL.revokeObjectURL(prevUrl); return blobUrl; @@ -142,7 +171,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { } return ( -
+
{!loading && !uploaded && loadedImageUrl && ( )} - {uploaded && } + {uploaded && ( + {t("documents.successes.edituploaded")}} + /> + )}
); } diff --git a/client/src/components/document-editor/document-editor.component.jsx b/client/src/components/document-editor/document-editor.component.jsx index 1bdf0f9e7..d9b7f8758 100644 --- a/client/src/components/document-editor/document-editor.component.jsx +++ b/client/src/components/document-editor/document-editor.component.jsx @@ -1,15 +1,21 @@ //import "tui-image-editor/dist/tui-image-editor.css"; import axios from "axios"; -import { Result } from "antd"; +import { Result, theme } from "antd"; import * as markerjs2 from "markerjs2"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; +import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { handleUpload } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility.js"; import LoadingSpinner from "../loading-spinner/loading-spinner.component"; -import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +import { + addGreyscaleButtonToMarkerArea, + addImageHistoryUndoToMarkerArea, + applyGreyscaleToMarkerAreaImage, + setMarkerAreaImageSource +} from "./document-editor.utility"; const mapStateToProps = createStructuredSelector({ currentUser: selectCurrentUser, @@ -27,7 +33,9 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { const [imageLoaded, setImageLoaded] = useState(false); const [imageLoading, setImageLoading] = useState(true); const markerArea = useRef(null); + const imageHistory = useRef([]); const { t } = useTranslation(); + const { token } = theme.useToken(); const notification = useNotification(); const triggerUpload = useCallback( @@ -57,6 +65,23 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { [bodyshop, currentUser, document, notification] ); + const handleGreyscale = useCallback(() => { + if (!imgRef.current || loading || uploaded || imageLoading || !imageLoaded) return; + + imageHistory.current.push(imgRef.current.src); + applyGreyscaleToMarkerAreaImage(markerArea.current, imgRef.current); + }, [imageLoaded, imageLoading, loading, uploaded]); + + const undoImageEdit = useCallback(() => { + if (!imgRef.current) return; + + const previousSrc = imageHistory.current.pop(); + + if (previousSrc) { + setMarkerAreaImageSource(markerArea.current, imgRef.current, previousSrc); + } + }, []); + useEffect(() => { if (imgRef.current !== null && imageLoaded && !markerArea.current) { // create a marker.js MarkerArea @@ -80,8 +105,10 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { markerArea.current.renderImageQuality = 1; //markerArea.current.settings.displayMode = "inline"; markerArea.current.show(); + addGreyscaleButtonToMarkerArea(markerArea.current, handleGreyscale, t("documents.labels.greyscale")); + addImageHistoryUndoToMarkerArea(markerArea.current, () => imageHistory.current.length > 0, undoImageEdit); } - }, [triggerUpload, imageLoaded]); + }, [handleGreyscale, imageLoaded, t, triggerUpload, undoImageEdit]); useEffect(() => { if (!document?.id) return; @@ -100,6 +127,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { } ); const blobUrl = URL.createObjectURL(response.data); + imageHistory.current = []; setImageUrl((prevUrl) => { if (prevUrl) URL.revokeObjectURL(prevUrl); return blobUrl; @@ -134,7 +162,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { } return ( -
+
{!loading && !uploaded && imageUrl && ( )} - {uploaded && } + {uploaded && ( + {t("documents.successes.edituploaded")}} + /> + )}
); } diff --git a/client/src/components/document-editor/document-editor.utility.js b/client/src/components/document-editor/document-editor.utility.js new file mode 100644 index 000000000..9efceec62 --- /dev/null +++ b/client/src/components/document-editor/document-editor.utility.js @@ -0,0 +1,123 @@ +/** + * Converts an image element to a greyscale data URL. + * @param imageElement + * @returns {string} + */ +export function convertImageElementToGreyscaleDataUrl(imageElement) { + if (!imageElement?.naturalWidth || !imageElement?.naturalHeight) { + throw new Error("Image must be loaded before it can be converted to greyscale."); + } + + const canvas = document.createElement("canvas"); + canvas.width = imageElement.naturalWidth; + canvas.height = imageElement.naturalHeight; + + const context = canvas.getContext("2d"); + context.drawImage(imageElement, 0, 0); + + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + const pixels = imageData.data; + + for (let i = 0; i < pixels.length; i += 4) { + const luminance = Math.round(pixels[i] * 0.299 + pixels[i + 1] * 0.587 + pixels[i + 2] * 0.114); + pixels[i] = luminance; + pixels[i + 1] = luminance; + pixels[i + 2] = luminance; + } + + context.putImageData(imageData, 0, 0); + + return canvas.toDataURL("image/jpeg", 1); +} + +/** + * Adds a greyscale button to the marker area controls if it doesn't already exist. + * @param markerArea + * @param onGreyscale + * @param title + */ +export function addGreyscaleButtonToMarkerArea(markerArea, onGreyscale, title) { + requestAnimationFrame(() => { + const renderButton = markerArea?.coverDiv?.querySelector?.('[data-action="render"]'); + + if (!renderButton || markerArea.coverDiv.querySelector('[data-action="greyscale"]')) return; + + const greyscaleButton = document.createElement("div"); + greyscaleButton.className = renderButton.className; + greyscaleButton.innerHTML = + ''; + greyscaleButton.setAttribute("role", "button"); + greyscaleButton.setAttribute("data-action", "greyscale"); + greyscaleButton.setAttribute("aria-label", title); + greyscaleButton.title = title; + greyscaleButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + onGreyscale(); + }); + + renderButton.parentElement.insertBefore(greyscaleButton, renderButton); + }); +} + +/** + * Applies a greyscale filter to the image in the marker area and updates the image source. + * @param markerArea + * @param imageElement + * @returns {string} + */ +export function applyGreyscaleToMarkerAreaImage(markerArea, imageElement) { + const dataUrl = convertImageElementToGreyscaleDataUrl(imageElement); + + setMarkerAreaImageSource(markerArea, imageElement, dataUrl); + + return dataUrl; +} + +/** + * Sets the image source for the marker area and updates the editing target if it's an image element. + * @param markerArea + * @param imageElement + * @param src + */ +export function setMarkerAreaImageSource(markerArea, imageElement, src) { + imageElement.src = src; + + if (markerArea?.editingTarget instanceof HTMLImageElement) { + markerArea.editingTarget.src = src; + } +} + +/** + * Adds undo functionality for image edits to the marker area by tracking the state before and after undo actions. + * @param markerArea + * @param canUndoImage + * @param undoImage + */ +export function addImageHistoryUndoToMarkerArea(markerArea, canUndoImage, undoImage) { + requestAnimationFrame(() => { + const undoButton = markerArea?.coverDiv?.querySelector?.('[data-action="undo"]'); + + if (!undoButton || undoButton.dataset.imageHistoryUndo === "true") return; + + let markerStateBeforeUndo = null; + + undoButton.dataset.imageHistoryUndo = "true"; + undoButton.addEventListener( + "click", + () => { + markerStateBeforeUndo = JSON.stringify(markerArea.getState(true)); + }, + true + ); + undoButton.addEventListener("click", () => { + const markerStateAfterUndo = JSON.stringify(markerArea.getState(true)); + + if (markerStateBeforeUndo === markerStateAfterUndo && canUndoImage()) { + undoImage(); + } + + markerStateBeforeUndo = null; + }); + }); +} diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx index 8f78cf675..d34e0645e 100644 --- a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx +++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx @@ -3,7 +3,7 @@ import { Button, Card, Col, Row, Space } from "antd"; import axios from "axios"; import i18n from "i18next"; import { isFunction } from "lodash"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import Lightbox from "react-image-lightbox"; import "react-image-lightbox/style.css"; @@ -12,12 +12,12 @@ import { createStructuredSelector } from "reselect"; import { selectBodyshop } from "../../redux/user/user.selectors"; import DocumentsUploadImgproxyComponent from "../documents-upload-imgproxy/documents-upload-imgproxy.component"; import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; +import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component"; import UpsellComponent, { upsellEnum } from "../upsell/upsell.component"; import JobsDocumentsDownloadButton from "./jobs-document-imgproxy-gallery.download.component"; import JobsDocumentsGalleryReassign from "./jobs-document-imgproxy-gallery.reassign.component"; import JobsDocumentsDeleteButton from "./jobs-documents-imgproxy-gallery.delete.component"; import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-imgproxy-gallery.selectall.component"; -import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -38,6 +38,9 @@ function JobsDocumentsImgproxyComponent({ const [galleryImages, setGalleryImages] = useState({ images: [], other: [] }); const { t } = useTranslation(); const [modalState, setModalState] = useState({ open: false, index: 0 }); + const [previewUrls, setPreviewUrls] = useState({}); + const [previewError, setPreviewError] = useState(null); + const previewUrlsRef = useRef({}); const fetchThumbnails = useCallback(() => { fetchImgproxyThumbnails({ setStateCallback: setGalleryImages, jobId, billId }); @@ -49,8 +52,86 @@ function JobsDocumentsImgproxyComponent({ } }, [data, fetchThumbnails]); + useEffect(() => { + return () => { + Object.values(previewUrlsRef.current).forEach(URL.revokeObjectURL); + }; + }, []); + + const selectedImage = modalState.open ? galleryImages.images[modalState.index] : null; + + useEffect(() => { + if (!modalState.open || !selectedImage?.id) return; + + if (previewUrlsRef.current[selectedImage.id]) { + setPreviewError(null); + return; + } + + const controller = new AbortController(); + + async function loadPreviewImage() { + setPreviewError(null); + + try { + const response = await axios.post( + "/media/imgproxy/original", + { documentId: selectedImage.id }, + { + responseType: "blob", + signal: controller.signal + } + ); + const blobUrl = URL.createObjectURL(response.data); + + previewUrlsRef.current = { + ...previewUrlsRef.current, + [selectedImage.id]: blobUrl + }; + setPreviewUrls(previewUrlsRef.current); + } catch (error) { + if (axios.isCancel?.(error) || error.name === "CanceledError") return; + + console.error("Failed to fetch original image blob", error); + setPreviewError(error); + } + } + + loadPreviewImage(); + + return () => { + controller.abort(); + }; + }, [modalState.open, selectedImage?.id]); + + useEffect(() => { + if (modalState.open && !selectedImage) { + setModalState({ open: false, index: 0 }); + } + }, [modalState.open, selectedImage]); + + const openEditorForImage = useCallback((image) => { + if (!image?.id) return; + + const newWindow = window.open( + `${window.location.protocol}//${window.location.host}/edit?documentId=${image.id}`, + "_blank", + "noopener,noreferrer" + ); + if (newWindow) newWindow.opener = null; + }, []); + const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" }); const hasMobileAccess = HasFeatureAccess({ bodyshop, featureName: "mobile" }); + const previewSrc = selectedImage ? previewUrls[selectedImage.id] : null; + const getLightboxImageSrc = useCallback( + (index) => { + const image = galleryImages.images[index]; + return image ? previewUrls[image.id] || image.src : undefined; + }, + [galleryImages.images, previewUrls] + ); + return (
@@ -147,30 +228,33 @@ function JobsDocumentsImgproxyComponent({ /> - {modalState.open && ( + {modalState.open && selectedImage && ( { - const newWindow = window.open( - `${window.location.protocol}//${window.location.host}/edit?documentId=${ - galleryImages.images[modalState.index].id - }`, - "_blank", - "noopener,noreferrer" - ); - if (newWindow) newWindow.opener = null; + openEditorForImage(selectedImage); }} /> ]} - mainSrc={galleryImages.images[modalState.index].fullsize} - nextSrc={galleryImages.images[(modalState.index + 1) % galleryImages.images.length].fullsize} - prevSrc={ + imageLoadErrorMessage={previewError ? t("general.errors.notfound") : undefined} + mainSrc={previewSrc || selectedImage.src} + mainSrcThumbnail={selectedImage.src} + nextSrc={getLightboxImageSrc((modalState.index + 1) % galleryImages.images.length)} + nextSrcThumbnail={galleryImages.images[(modalState.index + 1) % galleryImages.images.length]?.src} + prevSrc={getLightboxImageSrc( + (modalState.index + galleryImages.images.length - 1) % galleryImages.images.length + )} + prevSrcThumbnail={ galleryImages.images[(modalState.index + galleryImages.images.length - 1) % galleryImages.images.length] - .fullsize + ?.src } - onCloseRequest={() => setModalState({ open: false, index: 0 })} + reactModalProps={{ ariaHideApp: false }} + onCloseRequest={() => { + setModalState({ open: false, index: 0 }); + setPreviewError(null); + }} onMovePrevRequest={() => setModalState({ ...modalState, diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 7252d162e..09a365c0c 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -1222,6 +1222,7 @@ "confirmdelete": "Are you sure you want to delete these documents. This CANNOT be undone.", "doctype": "Document Type", "dragtoupload": "Click or drag files to this area to upload", + "greyscale": "Greyscale", "newjobid": "Assign to Job", "openinexplorer": "Open in Explorer", "optimizedimage": "The below image is optimized. Click on the picture below to open in a new window and view it full size, or open it in explorer.", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 1ade10f9f..99bc1a064 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -1216,6 +1216,7 @@ "confirmdelete": "", "doctype": "", "dragtoupload": "", + "greyscale": "Escala de grises", "newjobid": "", "openinexplorer": "", "optimizedimage": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index f1681e201..042a3c5a5 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -1216,6 +1216,7 @@ "confirmdelete": "", "doctype": "", "dragtoupload": "", + "greyscale": "Niveaux de gris", "newjobid": "", "openinexplorer": "", "optimizedimage": "", From f849ea9d0a75f52789b5da44d071a0eabacb6c62 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 7 May 2026 10:41:19 -0400 Subject: [PATCH 14/17] feature/IO-3679-Tech-Console-Null-Error - fix --- .../tech-assigned-prod-jobs.component.jsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/client/src/pages/tech-assigned-prod-jobs/tech-assigned-prod-jobs.component.jsx b/client/src/pages/tech-assigned-prod-jobs/tech-assigned-prod-jobs.component.jsx index 322880710..cb4934c7f 100644 --- a/client/src/pages/tech-assigned-prod-jobs/tech-assigned-prod-jobs.component.jsx +++ b/client/src/pages/tech-assigned-prod-jobs/tech-assigned-prod-jobs.component.jsx @@ -27,12 +27,19 @@ const mapDispatchToProps = (dispatch) => ({ }); export function TechAssignedProdJobs({ setTimeTicketTaskContext, technician, bodyshop }) { + const technicianId = technician?.id; + const teamIds = (bodyshop?.employee_teams || []) + .filter((employeeTeam) => + employeeTeam?.employee_team_members?.some((teamMember) => teamMember?.employeeid === technicianId) + ) + .map((employeeTeam) => employeeTeam.id) + .filter(Boolean); + const hasAssignedTeams = Boolean(technicianId) && teamIds.length > 0; const { loading, error, data, refetch } = useQuery(QUERY_JOBS_TECH_ASIGNED_TO_BY_TEAM, { variables: { - teamIds: bodyshop.employee_teams - .filter((et) => et.employee_team_members.find((etm) => etm.employeeid === technician.id)) - .map((et) => et.id) - } + teamIds + }, + skip: !technicianId || !hasAssignedTeams }); const searchParams = queryString.parse(useLocation().search); @@ -177,7 +184,7 @@ export function TechAssignedProdJobs({ setTimeTicketTaskContext, technician, bod - From 80697a52595a3dc77b5a4efb4960352dd16f2f07 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 8 May 2026 11:42:19 -0400 Subject: [PATCH 16/17] feature/IO-3688-Searchable-Referral-Source - Implement (convert button) --- .../jobs-convert-button.component.jsx | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx b/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx index 78ebecf27..8d83ff6e8 100644 --- a/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx +++ b/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx @@ -224,14 +224,10 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr )} - + + - (option?.label ?? "").toLowerCase().includes(input.toLowerCase()) + optionFilterProp: "label", + filterOption: (input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase()) }} style={{ width: 200 }} - options={csrOptions} /> )} {bodyshop.enforce_conversion_category && ( - +