From 52c9b9a2909fe20bdce175ae898dce2210e47ea2 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Tue, 27 Jan 2026 19:20:12 -0800 Subject: [PATCH 01/11] IO-3510 Autohouse Datapump Enhancements Signed-off-by: Allan Carr --- server/data/autohouse.js | 76 +++++++++++++++++++++++++++++++- server/graphql-client/queries.js | 10 ++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/server/data/autohouse.js b/server/data/autohouse.js index 19138c5c0..819c1d292 100644 --- a/server/data/autohouse.js +++ b/server/data/autohouse.js @@ -221,6 +221,8 @@ const CreateRepairOrderTag = (job, errorCallback) => { const repairCosts = CreateCosts(job); + const LaborDetailLines = generateLaborLines(job.timetickets); + //Calculate detail only lines. const detailAdjustments = job.joblines .filter((jl) => jl.ah_detail_line && jl.mod_lbr_ty) @@ -606,12 +608,14 @@ const CreateRepairOrderTag = (job, errorCallback) => { // CSIID: null, InsGroupCode: null }, - DetailLines: { DetailLine: job.joblines.length > 0 ? job.joblines.map((jl) => GenerateDetailLines(job, jl, job.bodyshop.md_order_statuses)) : [generateNullDetailLine()] + }, + LaborDetailLines: { + LaborDetailLine: LaborDetailLines } }; return ret; @@ -787,6 +791,76 @@ const CreateCosts = (job) => { }; }; +const generateLaborLines = (timetickets) => { + if (!timetickets || timetickets.length === 0) return []; + + const codeToProps = { + LAB: { actual: "LaborBodyActualHours", flag: "LaborBodyFlagHours", cost: "LaborBodyCost" }, + LAM: { actual: "LaborMechanicalActualHours", flag: "LaborMechanicalFlagHours", cost: "LaborMechanicalCost" }, + LAG: { actual: "LaborGlassActualHours", flag: "LaborGlassFlagHours", cost: "LaborGlassCost" }, + LAS: { actual: "LaborStructuralActualHours", flag: "LaborStructuralFlagHours", cost: "LaborStructuralCost" }, + LAE: { actual: "LaborElectricalActualHours", flag: "LaborElectricalFlagHours", cost: "LaborElectricalCost" }, + LAA: { actual: "LaborAluminumActualHours", flag: "LaborAluminumFlagHours", cost: "LaborAluminumCost" }, + LAR: { actual: "LaborRefinishActualHours", flag: "LaborRefinishFlagHours", cost: "LaborRefinishCost" }, + LAU: { actual: "LaborDetailActualHours", flag: "LaborDetailFlagHours", cost: "LaborDetailCost" }, + LA1: { actual: "LaborOtherActualHours", flag: "LaborOtherFlagHours", cost: "LaborOtherCost" }, + LA2: { actual: "LaborOtherActualHours", flag: "LaborOtherFlagHours", cost: "LaborOtherCost" }, + LA3: { actual: "LaborOtherActualHours", flag: "LaborOtherFlagHours", cost: "LaborOtherCost" }, + LA4: { actual: "LaborOtherActualHours", flag: "LaborOtherFlagHours", cost: "LaborOtherCost" } + }; + + return timetickets.map((ticket, idx) => { + const { ciecacode, employee, actualhrs = 0, productivehrs = 0, rate = 0 } = ticket; + const isFlatRate = employee?.flat_rate; + const hours = isFlatRate ? productivehrs : actualhrs; + const cost = rate * hours; + + const laborDetail = { + LaborDetailLineNumber: idx + 1, + TechnicianNameFirst: employee?.first_name || "", + TechnicianNameLast: employee?.last_name || "", + LaborBodyActualHours: 0, + LaborMechanicalActualHours: 0, + LaborGlassActualHours: 0, + LaborStructuralActualHours: 0, + LaborElectricalActualHours: 0, + LaborAluminumActualHours: 0, + LaborRefinishActualHours: 0, + LaborDetailActualHours: 0, + LaborOtherActualHours: 0, + LaborBodyFlagHours: 0, + LaborMechanicalFlagHours: 0, + LaborGlassFlagHours: 0, + LaborStructuralFlagHours: 0, + LaborElectricalFlagHours: 0, + LaborAluminumFlagHours: 0, + LaborRefinishFlagHours: 0, + LaborDetailFlagHours: 0, + LaborOtherFlagHours: 0, + LaborBodyCost: 0, + LaborMechanicalCost: 0, + LaborGlassCost: 0, + LaborStructuralCost: 0, + LaborElectricalCost: 0, + LaborAluminumCost: 0, + LaborRefinishCost: 0, + LaborDetailCost: 0, + LaborOtherCost: 0 + }; + + const effectiveCiecacode = ciecacode || "LA4"; + + if (codeToProps[effectiveCiecacode]) { + const { actual, flag, cost: costProp } = codeToProps[effectiveCiecacode]; + laborDetail[actual] = actualhrs; + laborDetail[flag] = productivehrs; + laborDetail[costProp] = cost; + } + + return laborDetail; + }); +}; + const StatusMapping = (status, md_ro_statuses) => { //Possible return statuses EST, SCH, ARR, IPR, RDY, DEL, CLO, CAN, UNDEFINED. const { diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index a0cd0bc91..8add6e694 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -827,13 +827,21 @@ exports.AUTOHOUSE_QUERY = `query AUTOHOUSE_EXPORT($start: timestamptz, $bodyshop quantity } } - timetickets { + timetickets(where: {cost_center: {_neq: "timetickets.labels.shift"}}) { id rate + ciecacode cost_center actualhrs productivehrs flat_rate + employeeid + employee { + employee_number + flat_rate + first_name + last_name + } } area_of_damage employee_prep_rel { From 22aae0a7f13d144a4c85d8b82fdc238089fe03af Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 3 Feb 2026 13:21:32 -0500 Subject: [PATCH 02/11] feature/IO-3545-Production-Board-List-DND - Checkpoint --- client/package-lock.json | 106 ++++--- client/package.json | 5 +- .../production-list-table.component.jsx | 290 ++++++++++++++---- ...uction-list-table.resizeable.component.jsx | 15 +- 4 files changed, 308 insertions(+), 108 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 568cc6058..225d2ba06 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -12,6 +12,10 @@ "@amplitude/analytics-browser": "^2.34.0", "@ant-design/pro-layout": "^7.22.6", "@apollo/client": "^4.1.3", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/is-prop-valid": "^1.4.0", "@fingerprintjs/fingerprintjs": "^5.0.1", "@firebase/analytics": "^0.10.19", @@ -60,7 +64,6 @@ "react-color": "^2.19.3", "react-cookie": "^8.0.1", "react-dom": "^19.2.4", - "react-drag-listview": "^2.0.0", "react-grid-gallery": "^1.0.1", "react-grid-layout": "^2.2.2", "react-i18next": "^16.5.4", @@ -2494,6 +2497,73 @@ "node": ">=10" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz", @@ -8396,16 +8466,6 @@ "@babel/types": "^7.26.0" } }, - "node_modules/babel-runtime": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", - "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==", - "license": "MIT", - "dependencies": { - "core-js": "^2.4.0", - "regenerator-runtime": "^0.11.0" - } - }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -9192,14 +9252,6 @@ "node": ">=18" } }, - "node_modules/core-js": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", - "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", - "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", - "hasInstallScript": true, - "license": "MIT" - }, "node_modules/core-js-compat": { "version": "3.47.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz", @@ -15376,16 +15428,6 @@ "react": "^19.2.4" } }, - "node_modules/react-drag-listview": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/react-drag-listview/-/react-drag-listview-2.0.0.tgz", - "integrity": "sha512-7Apx/1Xt4qu+JHHP0rH6aLgZgS7c2MX8ocHVGCi03KfeIWEu0t14MhT3boQKM33l5eJrE/IWfExFTvoYq22fsg==", - "license": "MIT", - "dependencies": { - "babel-runtime": "^6.26.0", - "prop-types": "^15.5.8" - } - }, "node_modules/react-draggable": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz", @@ -15972,12 +16014,6 @@ "node": ">=4" } }, - "node_modules/regenerator-runtime": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", - "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", - "license": "MIT" - }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", diff --git a/client/package.json b/client/package.json index 8b88d8e43..b0cbdf6b8 100644 --- a/client/package.json +++ b/client/package.json @@ -11,6 +11,10 @@ "@amplitude/analytics-browser": "^2.34.0", "@ant-design/pro-layout": "^7.22.6", "@apollo/client": "^4.1.3", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/is-prop-valid": "^1.4.0", "@fingerprintjs/fingerprintjs": "^5.0.1", "@firebase/analytics": "^0.10.19", @@ -59,7 +63,6 @@ "react-color": "^2.19.3", "react-cookie": "^8.0.1", "react-dom": "^19.2.4", - "react-drag-listview": "^2.0.0", "react-grid-gallery": "^1.0.1", "react-grid-layout": "^2.2.2", "react-i18next": "^16.5.4", diff --git a/client/src/components/production-list-table/production-list-table.component.jsx b/client/src/components/production-list-table/production-list-table.component.jsx index 584aac402..e35200a74 100644 --- a/client/src/components/production-list-table/production-list-table.component.jsx +++ b/client/src/components/production-list-table/production-list-table.component.jsx @@ -1,15 +1,19 @@ -import { SyncOutlined } from "@ant-design/icons"; +import { HolderOutlined, SyncOutlined } from "@ant-design/icons"; import { PageHeader } from "@ant-design/pro-layout"; import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { Button, Dropdown, Input, Space, Statistic, Table } from "antd"; import _ from "lodash"; import { useEffect, useMemo, useRef, useState } from "react"; -import ReactDragListView from "react-drag-listview"; +import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, closestCenter } from "@dnd-kit/core"; +import { arrayMove, horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { restrictToHorizontalAxis } from "@dnd-kit/modifiers"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { selectTechnician } from "../../redux/tech/tech.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; +import { selectDarkMode } from "../../redux/application/application.selectors.js"; import Prompt from "../../utils/prompt.js"; import AlertComponent from "../alert/alert.component.jsx"; import ProductionListColumnsAdd from "../production-list-columns/production-list-columns.add.component"; @@ -23,12 +27,108 @@ import { logImEXEvent } from "../../firebase/firebase.utils.js"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, technician: selectTechnician, - currentUser: selectCurrentUser + currentUser: selectCurrentUser, + isDarkMode: selectDarkMode }); -export function ProductionListTable({ loading, data, refetch, bodyshop, technician, currentUser }) { +// Draggable header cell component - combines drag and resize +function DraggableHeaderCell(props) { + const { children, columnKey, onResize, width, ...restProps } = props; + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: columnKey, + disabled: !columnKey + }); + + const style = { + ...restProps.style, + transform: CSS.Transform.toString(transform), + transition, + cursor: "move", + opacity: isDragging ? 0.3 : 1 + }; + + // If no columnKey, render as regular header + if (!columnKey) { + return ; + } + + // Combine drag functionality with resize + return ( + + {children} + + ); +} + +// Draggable column title component +// function DraggableColumnTitle({ col, onRemove, t }) { +// const { attributes, listeners, setNodeRef, isDragging } = useSortable({ +// id: col.key +// }); +// +// const menu = { +// onClick: onRemove, +// items: [ +// { +// key: col.key, +// label: t("production.actions.removecolumn") +// } +// ] +// }; +// +// const style = { +// display: "flex", +// alignItems: "center", +// width: "100%", +// opacity: isDragging ? 0.4 : 1 +// }; +// +// return ( +//
+// +// +// +// +// {col.title} +// +//
+// ); +// } + +export function ProductionListTable({ loading, data, refetch, bodyshop, technician, currentUser, isDarkMode }) { const [searchText, setSearchText] = useState(""); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [activeId, setActiveId] = useState(null); + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 1 + } + }) + ); const { treatments: { Production_List_Status_Colors, Enhanced_Payroll } } = useTreatmentsWithConfig({ @@ -118,17 +218,30 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici logImEXEvent("production_list_sort_filter", { pagination, filters, sorter }); }; - const onDragEnd = (fromIndex, toIndex) => { - if (fromIndex === toIndex) return; - const columnsCopy = [...columns]; - const [movedItem] = columnsCopy.splice(fromIndex, 1); - columnsCopy.splice(toIndex, 0, movedItem); - if (!_.isEqual(columnsCopy, columns)) { - setColumns(columnsCopy); - setHasUnsavedChanges(true); + const onDragStart = ({ active }) => { + setActiveId(active.id); + }; + + const onDragEnd = ({ active, over }) => { + setActiveId(null); + if (!over || active.id === over.id) return; + + const oldIndex = columns.findIndex((col) => col.key === active.id); + const newIndex = columns.findIndex((col) => col.key === over.id); + + if (oldIndex !== -1 && newIndex !== -1) { + const newColumns = arrayMove(columns, oldIndex, newIndex); + if (!_.isEqual(newColumns, columns)) { + setColumns(newColumns); + setHasUnsavedChanges(true); + } } }; + const onDragCancel = () => { + setActiveId(null); + }; + const removeColumn = (e) => { const { key } = e; const newColumns = columns.filter((i) => i.key !== key); @@ -172,10 +285,14 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici } ] }; + return ( - - {col.title} - +
+ + + {col.title} + +
); }; @@ -286,60 +403,99 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici } /> - - { - if (!bodyshop.md_ro_statuses.production_colors) return null; - const color = bodyshop.md_ro_statuses.production_colors.find((x) => x.status === record.status); - if (!color) { - if (index % 2 === 0) - return { - style: { - backgroundColor: "var(--table-row-even-bg)" - } - }; - return null; - } - return { - className: "rowWithColor", - style: { - "--bgColor": color.color - ? `rgba(${color.color.r},${color.color.g},${color.color.b},${color.color.a || 1})` - : "var(--status-row-bg-fallback)" + + col.key)} strategy={horizontalListSortingStrategy}> +
{ + if (!bodyshop.md_ro_statuses.production_colors) return null; + const color = bodyshop.md_ro_statuses.production_colors.find((x) => x.status === record.status); + if (!color) { + if (index % 2 === 0) + return { + style: { + backgroundColor: "var(--table-row-even-bg)" + } + }; + return null; } + return { + className: "rowWithColor", + style: { + "--bgColor": color.color + ? `rgba(${color.color.r},${color.color.g},${color.color.b},${color.color.a || 1})` + : "var(--status-row-bg-fallback)" + } + }; + } + })} + components={{ + header: { + cell: DraggableHeaderCell + } + }} + columns={columns.map((c, index) => { + return { + ...c, + filteredValue: state.filteredInfo[c.key] || null, + sortOrder: state.sortedInfo.columnKey === c.key && state.sortedInfo.order, + title: headerItem(c), + ellipsis: true, + width: c.width ?? 100, + onHeaderCell: (column) => ({ + columnKey: column.key, + width: column.width, + onResize: handleResize(index) + }) }; - } - })} - components={{ - header: { - cell: ResizeableTitle - } - }} - columns={columns.map((c, index) => { - return { - ...c, - filteredValue: state.filteredInfo[c.key] || null, - sortOrder: state.sortedInfo.columnKey === c.key && state.sortedInfo.order, - title: headerItem(c), - ellipsis: true, - width: c.width ?? 100, - onHeaderCell: (column) => ({ - width: column.width, - onResize: handleResize(index) - }) - }; - })} - rowKey="id" - loading={loading} - dataSource={dataSource} - scroll={{ x: 1000 }} - onChange={handleTableChange} - /> - + })} + rowKey="id" + loading={loading} + dataSource={dataSource} + scroll={{ x: 1000 }} + onChange={handleTableChange} + /> + + + {activeId ? ( +
+ + + {(() => { + const col = columns.find((c) => c.key === activeId); + const title = typeof col?.title === 'string' ? col.title : + (col?.dataIndex || col?.key || "Column"); + return title; + })()} + +
+ ) : null} +
+ ); } diff --git a/client/src/components/production-list-table/production-list-table.resizeable.component.jsx b/client/src/components/production-list-table/production-list-table.resizeable.component.jsx index bfb634228..023a4ce9e 100644 --- a/client/src/components/production-list-table/production-list-table.resizeable.component.jsx +++ b/client/src/components/production-list-table/production-list-table.resizeable.component.jsx @@ -1,10 +1,11 @@ +import { forwardRef } from "react"; import { Resizable } from "react-resizable"; -export default function ResizableComponent(props) { - const { onResize, width, ...restProps } = props; +const ResizableComponent = forwardRef((props, ref) => { + const { onResize, width, dragAttributes, dragListeners, ...restProps } = props; if (!width) { - return + options={employeeOptions} + /> diff --git a/client/src/components/production-list-table/production-list-table.component.jsx b/client/src/components/production-list-table/production-list-table.component.jsx index 760ee0f0b..60a545534 100644 --- a/client/src/components/production-list-table/production-list-table.component.jsx +++ b/client/src/components/production-list-table/production-list-table.component.jsx @@ -43,7 +43,9 @@ function DraggableHeaderCell(props) { ...restProps.style, transform: CSS.Transform.toString(transform), transition, - opacity: isDragging ? 0.3 : 1 + opacity: isDragging ? 0.3 : 1, + userSelect: "none", + textAlign: "left" }; // If no columnKey, render as regular header @@ -237,35 +239,51 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici }; const headerItem = (col) => { - const menu = { - onClick: removeColumn, - items: [ - { - key: col.key, - label: t("production.actions.removecolumn") - } - ] - }; + const menu = { onClick: removeColumn, items: [{ key: col.key, label: t("production.actions.removecolumn") }] }; return ( -
+
+ - {col.title} + + {col.title} +
); @@ -389,6 +407,7 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici col.key)} strategy={horizontalListSortingStrategy}>
; + return ; } return ( @@ -22,7 +23,11 @@ export default function ResizableComponent(props) { /> } > - + ); -} +}); + +ResizableComponent.displayName = "ResizableComponent"; + +export default ResizableComponent; From 1b6fe4d18ec88d69acc71391ddc2697e96350697 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 3 Feb 2026 13:26:17 -0500 Subject: [PATCH 03/11] feature/IO-3545-Production-Board-List-DND - Checkpoint --- .../production-list-table.component.jsx | 98 +++++++------------ 1 file changed, 36 insertions(+), 62 deletions(-) diff --git a/client/src/components/production-list-table/production-list-table.component.jsx b/client/src/components/production-list-table/production-list-table.component.jsx index e35200a74..760ee0f0b 100644 --- a/client/src/components/production-list-table/production-list-table.component.jsx +++ b/client/src/components/production-list-table/production-list-table.component.jsx @@ -4,7 +4,7 @@ import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { Button, Dropdown, Input, Space, Statistic, Table } from "antd"; import _ from "lodash"; import { useEffect, useMemo, useRef, useState } from "react"; -import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, closestCenter } from "@dnd-kit/core"; +import { closestCenter, DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { arrayMove, horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { restrictToHorizontalAxis } from "@dnd-kit/modifiers"; @@ -43,7 +43,6 @@ function DraggableHeaderCell(props) { ...restProps.style, transform: CSS.Transform.toString(transform), transition, - cursor: "move", opacity: isDragging ? 0.3 : 1 }; @@ -52,6 +51,18 @@ function DraggableHeaderCell(props) { return ; } + // Only apply drag listeners to elements with data-drag-handle attribute + const filteredListeners = listeners + ? { + onPointerDown: (e) => { + // Only trigger drag if clicking on the drag handle + if (e.target.closest('[data-drag-handle="true"]')) { + listeners.onPointerDown?.(e); + } + } + } + : {}; + // Combine drag functionality with resize return ( {children} ); } -// Draggable column title component -// function DraggableColumnTitle({ col, onRemove, t }) { -// const { attributes, listeners, setNodeRef, isDragging } = useSortable({ -// id: col.key -// }); -// -// const menu = { -// onClick: onRemove, -// items: [ -// { -// key: col.key, -// label: t("production.actions.removecolumn") -// } -// ] -// }; -// -// const style = { -// display: "flex", -// alignItems: "center", -// width: "100%", -// opacity: isDragging ? 0.4 : 1 -// }; -// -// return ( -//
-// -// -// -// -// {col.title} -// -//
-// ); -// } - export function ProductionListTable({ loading, data, refetch, bodyshop, technician, currentUser, isDarkMode }) { const [searchText, setSearchText] = useState(""); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); @@ -285,12 +246,26 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici } ] }; - + return (
- + + + - {col.title} + {col.title}
); @@ -403,10 +378,10 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici } /> - {(() => { const col = columns.find((c) => c.key === activeId); - const title = typeof col?.title === 'string' ? col.title : - (col?.dataIndex || col?.key || "Column"); + const title = typeof col?.title === "string" ? col.title : col?.dataIndex || col?.key || "Column"; return title; })()} From 2cc9fa961e3873e9ca7625cdf10665681be2e8f9 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 3 Feb 2026 14:34:42 -0500 Subject: [PATCH 04/11] feature/IO-3545-Production-Board-List-DND - Checkpoint --- ...n-list-columns.empassignment.component.jsx | 25 +++++----- .../production-list-table.component.jsx | 49 +++++++++++++------ ...uction-list-table.resizeable.component.jsx | 9 +--- 3 files changed, 48 insertions(+), 35 deletions(-) diff --git a/client/src/components/production-list-columns/production-list-columns.empassignment.component.jsx b/client/src/components/production-list-columns/production-list-columns.empassignment.component.jsx index d96d6f56d..c02f46fa6 100644 --- a/client/src/components/production-list-columns/production-list-columns.empassignment.component.jsx +++ b/client/src/components/production-list-columns/production-list-columns.empassignment.component.jsx @@ -94,29 +94,30 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record const [visibility, setVisibility] = useState(false); const onChange = (e, option) => { - setAssignment({ ...assignment, employeeid: e, name: option.name }); + setAssignment({ ...assignment, employeeid: e, name: option.label }); }; + const employeeOptions = bodyshop.employees + .filter((emp) => emp.active) + .map((emp) => ({ + value: emp.id, + label: `${emp.first_name} ${emp.last_name}`, + name: `${emp.first_name} ${emp.last_name}` + })); + const popContent = (
{ height={0} onResize={onResize} draggableOpts={{ enableUserSelectHack: false }} - handle={ - { - e.stopPropagation(); - }} - /> - } + handle={} >
From da0462f14c68031d1a8e1cf374d0ddd2f1608238 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 3 Feb 2026 14:56:04 -0500 Subject: [PATCH 05/11] feature/IO-3545-Production-Board-List-DND - Checkpoint --- .../production-list-table.component.jsx | 152 +++++++++++++----- ...uction-list-table.resizeable.component.jsx | 12 +- 2 files changed, 119 insertions(+), 45 deletions(-) diff --git a/client/src/components/production-list-table/production-list-table.component.jsx b/client/src/components/production-list-table/production-list-table.component.jsx index 60a545534..bf5b05d39 100644 --- a/client/src/components/production-list-table/production-list-table.component.jsx +++ b/client/src/components/production-list-table/production-list-table.component.jsx @@ -3,7 +3,7 @@ import { PageHeader } from "@ant-design/pro-layout"; import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { Button, Dropdown, Input, Space, Statistic, Table } from "antd"; import _ from "lodash"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { closestCenter, DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { arrayMove, horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -84,7 +84,14 @@ function DraggableHeaderCell(props) { export function ProductionListTable({ loading, data, refetch, bodyshop, technician, currentUser, isDarkMode }) { const [searchText, setSearchText] = useState(""); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + // NEW: smoother resize + const [isResizing, setIsResizing] = useState(false); + const resizeRafRef = useRef(null); + const pendingResizeRef = useRef(null); + const [activeId, setActiveId] = useState(null); + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { @@ -92,6 +99,7 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici } }) ); + const { treatments: { Production_List_Status_Colors, Enhanced_Payroll } } = useTreatmentsWithConfig({ @@ -99,8 +107,10 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici names: ["Production_List_Status_Colors", "Enhanced_Payroll"], splitKey: bodyshop.imexshopid }); + const assoc = bodyshop.associations.find((a) => a.useremail === currentUser.email); const defaultView = assoc?.default_prod_list_view; + const initialStateRef = useRef( (bodyshop.production_config && bodyshop.production_config.find((p) => p.name === defaultView)?.columns.tableState) || @@ -109,6 +119,7 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici filteredInfo: { text: "" } } ); + const initialColumnsRef = useRef( (initialStateRef.current && bodyshop?.production_config @@ -129,14 +140,30 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici })) || [] ); + const [state, setState] = useState(initialStateRef.current); const [columns, setColumns] = useState(initialColumnsRef.current); + const { t } = useTranslation(); + const matchingColumnConfig = useMemo(() => { return bodyshop?.production_config?.find((p) => p.name === defaultView); }, [bodyshop.production_config, defaultView]); + // NEW: cleanup RAF on unmount useEffect(() => { + return () => { + if (resizeRafRef.current) cancelAnimationFrame(resizeRafRef.current); + }; + }, []); + + useEffect(() => { + // NEW: while resizing, don’t regenerate columns + if (isResizing) return; + + // NEW: bail early BEFORE expensive ProductionListColumns(...) call + if (!_.isEqual(initialColumnsRef.current, columns)) return; + const newColumns = matchingColumnConfig?.columns.columnKeys.map((k) => { return { @@ -152,10 +179,8 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici width: k.width ?? 100 }; }) || []; - // Only update columns if they haven't been manually changed by the user - if (_.isEqual(initialColumnsRef.current, columns)) { - setColumns(newColumns); - } + + setColumns(newColumns); }, [ matchingColumnConfig, bodyshop, @@ -165,7 +190,8 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici Production_List_Status_Colors, refetch, state, - columns + columns, + isResizing ]); const handleTableChange = (pagination, filters, sorter) => { @@ -215,19 +241,54 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici logImEXEvent("production_list_remove_column", { key }); }; - const handleResize = - (index) => - (e, { size }) => { - const nextColumns = [...columns]; - nextColumns[index] = { - ...nextColumns[index], - width: size.width - }; - if (!_.isEqual(nextColumns, columns)) { - setColumns(nextColumns); + // NEW: commit widths via rAF (less jank) + const applyColumnWidth = useCallback((columnKey, width) => { + setColumns((prev) => { + const idx = prev.findIndex((c) => c.key === columnKey); + if (idx === -1) return prev; + + const currentWidth = prev[idx].width ?? 100; + if (currentWidth === width) return prev; + + const next = prev.slice(); + next[idx] = { ...next[idx], width }; + return next; + }); + }, []); + + const handleResize = useCallback( + (columnKey) => + (e, { size }) => { + pendingResizeRef.current = { columnKey, width: size.width }; + + if (resizeRafRef.current) return; + resizeRafRef.current = requestAnimationFrame(() => { + resizeRafRef.current = null; + const pending = pendingResizeRef.current; + if (!pending) return; + applyColumnWidth(pending.columnKey, pending.width); + }); + }, + [applyColumnWidth] + ); + + const handleResizeStart = useCallback(() => { + setIsResizing(true); + }, []); + + const handleResizeStop = useCallback( + (columnKey) => + (e, { size }) => { + setIsResizing(false); + + // Ensure final width is committed + applyColumnWidth(columnKey, size.width); + setHasUnsavedChanges(true); - } - }; + logImEXEvent("production_list_resize_column", { key: columnKey, width: size.width }); + }, + [applyColumnWidth] + ); const addColumn = (newColumn) => { const updatedColumns = [...columns, newColumn]; @@ -384,6 +445,9 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici onSave={() => { setHasUnsavedChanges(false); initialStateRef.current = state; + + // NEW: after saving, treat current columns as the baseline + initialColumnsRef.current = columns; }} /> { - if (!bodyshop.md_ro_statuses.production_colors) return null; - const color = bodyshop.md_ro_statuses.production_colors.find((x) => x.status === record.status); - if (!color) { - if (index % 2 === 0) - return { - style: { - backgroundColor: "var(--table-row-even-bg)" - } - }; - return null; - } - return { - className: "rowWithColor", - style: { - "--bgColor": color.color - ? `rgba(${color.color.r},${color.color.g},${color.color.b},${color.color.a || 1})` - : "var(--status-row-bg-fallback)" + {...(Production_List_Status_Colors.treatment === "on" && + !isResizing && { + onRow: (record, index) => { + if (!bodyshop.md_ro_statuses.production_colors) return null; + const color = bodyshop.md_ro_statuses.production_colors.find((x) => x.status === record.status); + if (!color) { + if (index % 2 === 0) + return { + style: { + backgroundColor: "var(--table-row-even-bg)" + } + }; + return null; } - }; - } - })} + return { + className: "rowWithColor", + style: { + "--bgColor": color.color + ? `rgba(${color.color.r},${color.color.g},${color.color.b},${color.color.a || 1})` + : "var(--status-row-bg-fallback)" + } + }; + } + })} components={{ header: { cell: DraggableHeaderCell } }} - columns={columns.map((c, index) => { + columns={columns.map((c) => { return { ...c, filteredValue: state.filteredInfo[c.key] || null, @@ -449,7 +514,9 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici onHeaderCell: (column) => ({ columnKey: column.key, width: column.width, - onResize: handleResize(index) + onResize: handleResize(column.key), + onResizeStart: handleResizeStart, + onResizeStop: handleResizeStop(column.key) }) }; })} @@ -460,6 +527,7 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici onChange={handleTableChange} /> + {activeId ? (
{ - const { onResize, width, dragAttributes, dragListeners, ...restProps } = props; + const { onResize, onResizeStart, onResizeStop, width, dragAttributes, dragListeners, ...restProps } = props; if (!width) { return
; @@ -10,11 +11,16 @@ const ResizableComponent = forwardRef((props, ref) => { return ( } + resizeHandles={["e"]} + handle={(axis, handleRef) => ( + + )} > From 3acec55c0ecd9ed6c0d1afac8302187283176cc8 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 3 Feb 2026 15:01:10 -0500 Subject: [PATCH 06/11] feature/IO-3545-Production-Board-List-DND - Checkpoint --- .../production-list-table.resizeable.component.jsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/src/components/production-list-table/production-list-table.resizeable.component.jsx b/client/src/components/production-list-table/production-list-table.resizeable.component.jsx index 65e4d790e..568fc1396 100644 --- a/client/src/components/production-list-table/production-list-table.resizeable.component.jsx +++ b/client/src/components/production-list-table/production-list-table.resizeable.component.jsx @@ -19,7 +19,12 @@ const ResizableComponent = forwardRef((props, ref) => { draggableOpts={{ enableUserSelectHack: false }} resizeHandles={["e"]} handle={(axis, handleRef) => ( - + e.stopPropagation()} + style={{ width: '10px', right: '-5px', cursor: 'col-resize' }} + /> )} > From 332ade96e5a1d02fd93729d98ae56862a07d3227 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 3 Feb 2026 15:17:20 -0500 Subject: [PATCH 07/11] feature/IO-3545-Production-Board-List-DND - Checkpoint --- client/src/App/App.styles.scss | 29 +++++++++++++++++++ .../production-list-table.component.jsx | 1 + ...uction-list-table.resizeable.component.jsx | 6 ++-- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/client/src/App/App.styles.scss b/client/src/App/App.styles.scss index 09745eac9..657520e49 100644 --- a/client/src/App/App.styles.scss +++ b/client/src/App/App.styles.scss @@ -446,3 +446,32 @@ //.rbc-time-header-gutter { // padding: 0; //} + +/* globally allow shrink inside table cells */ +.prod-list-table .ant-table-cell, +.prod-list-table .ant-table-cell > * { + min-width: 0; +} + +/* common AntD offenders */ +.prod-list-table > .ant-table-cell .ant-space, +.ant-table-cell .ant-space-item { + min-width: 0; +} + +/* Keep your custom header content on the left, push AntD sorter to the far right */ +.prod-list-table .ant-table-column-sorters { + display: flex !important; + align-items: center; + width: 100%; +} + +.prod-list-table .ant-table-column-title { + flex: 1 1 auto; + min-width: 0; /* allows ellipsis to work */ +} + +.prod-list-table .ant-table-column-sorter { + margin-left: auto; + flex: 0 0 auto; +} diff --git a/client/src/components/production-list-table/production-list-table.component.jsx b/client/src/components/production-list-table/production-list-table.component.jsx index bf5b05d39..64e07d1f5 100644 --- a/client/src/components/production-list-table/production-list-table.component.jsx +++ b/client/src/components/production-list-table/production-list-table.component.jsx @@ -472,6 +472,7 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici { onResizeStop={onResizeStop} draggableOpts={{ enableUserSelectHack: false }} resizeHandles={["e"]} + axis="x" handle={(axis, handleRef) => ( - e.stopPropagation()} - style={{ width: '10px', right: '-5px', cursor: 'col-resize' }} /> )} > From 05ae0801e55889072ea2af016d04b8419fb237e8 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 3 Feb 2026 15:29:03 -0500 Subject: [PATCH 08/11] feature/IO-3545-Production-Board-List-DND - EMP assignment selector fix --- ...n-list-columns.empassignment.component.jsx | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/client/src/components/production-list-columns/production-list-columns.empassignment.component.jsx b/client/src/components/production-list-columns/production-list-columns.empassignment.component.jsx index c02f46fa6..704d48432 100644 --- a/client/src/components/production-list-columns/production-list-columns.empassignment.component.jsx +++ b/client/src/components/production-list-columns/production-list-columns.empassignment.component.jsx @@ -35,8 +35,6 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record const result = await updateJob({ variables: { jobId: record.id, job: { [empAssignment]: employeeid } } - - // awaitRefetchQueries: true, }); insertAuditTrail({ @@ -55,6 +53,7 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record await refetch(); + setAssignment({ operation: null, employeeid: null }); setLoading(false); }; const handleRemove = async (operation) => { @@ -84,6 +83,7 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record await refetch(); + setAssignment({ operation: null, employeeid: null }); setLoading(false); }; @@ -115,6 +115,7 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record filterOption: (input, option) => option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0 }} style={{ width: 200 }} + value={assignment.employeeid} onChange={onChange} options={employeeOptions} /> @@ -142,25 +143,25 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record if (record[type]) theEmployee = bodyshop.employees.find((e) => e.id === record[type]); return ( - - - {record[type] ? ( -
- {`${theEmployee?.first_name || ""} ${theEmployee?.last_name || ""}`} - handleRemove(type)} /> -
- ) : ( + + {record[type] ? ( +
+ {`${theEmployee?.first_name || ""} ${theEmployee?.last_name || ""}`} + handleRemove(type)} /> +
+ ) : ( + { - setAssignment({ operation: type }); + setAssignment({ operation: type, employeeid: null }); setVisibility(true); }} /> - )} -
-
+ + )} + ); } From ad520ab23e95a3871c2969ce86f1bbd39e488e1c Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 3 Feb 2026 15:42:10 -0500 Subject: [PATCH 09/11] feature/IO-3548-Bill-Modal-TabOrder --- .../bill-form/bill-form.lines.component.jsx | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/client/src/components/bill-form/bill-form.lines.component.jsx b/client/src/components/bill-form/bill-form.lines.component.jsx index 73b0a21a9..a841f0534 100644 --- a/client/src/components/bill-form/bill-form.lines.component.jsx +++ b/client/src/components/bill-form/bill-form.lines.component.jsx @@ -1,6 +1,7 @@ import { DeleteFilled, DollarCircleFilled } from "@ant-design/icons"; import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { Button, Checkbox, Form, Input, InputNumber, Select, Space, Switch, Table, Tooltip } from "antd"; +import { useRef } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; @@ -32,6 +33,7 @@ export function BillEnterModalLinesComponent({ }) { const { t } = useTranslation(); const { setFieldsValue, getFieldsValue, getFieldValue } = form; + const firstFieldRefs = useRef({}); const CONTROL_HEIGHT = 32; @@ -155,6 +157,9 @@ export function BillEnterModalLinesComponent({ ), formInput: (record, index) => ( { + firstFieldRefs.current[index] = el; + }} disabled={disabled} options={lineData} style={{ @@ -205,7 +210,7 @@ export function BillEnterModalLinesComponent({ label: t("billlines.fields.line_desc"), rules: [{ required: true }] }), - formInput: () => + formInput: () => }, { title: t("billlines.fields.quantity"), @@ -234,7 +239,7 @@ export function BillEnterModalLinesComponent({ }) ] }), - formInput: () => + formInput: () => }, { title: t("billlines.fields.actual_price"), @@ -251,6 +256,7 @@ export function BillEnterModalLinesComponent({ { if (e.key === "Tab" && !e.shiftKey) autofillActualCost(index); @@ -328,6 +334,7 @@ export function BillEnterModalLinesComponent({ min={0} disabled={disabled} controls={false} + tabIndex={0} style={{ width: "100%", height: CONTROL_HEIGHT }} // NOTE: No auto-fill on focus/blur; only triggered from Retail on Tab /> @@ -392,7 +399,7 @@ export function BillEnterModalLinesComponent({ rules: [{ required: true }] }), formInput: () => ( - {bodyshopHasDmsKey(bodyshop) ? CiecaSelect(true, false) : responsibilityCenters.costs.map((item) => {item.name})} @@ -412,7 +419,7 @@ export function BillEnterModalLinesComponent({ name: [field.name, "location"] }), formInput: () => ( - {bodyshop.md_parts_locations.map((loc, idx) => ( {loc} @@ -432,7 +439,7 @@ export function BillEnterModalLinesComponent({ key: `${field.name}deductedfromlbr`, name: [field.name, "deductedfromlbr"] }), - formInput: () => , + formInput: () => , additional: (record, index) => ( {() => { @@ -523,7 +530,7 @@ export function BillEnterModalLinesComponent({ rome: false }) }), - formInput: () => + formInput: () => } ] }), @@ -538,7 +545,7 @@ export function BillEnterModalLinesComponent({ valuePropName: "checked", name: [field.name, "applicable_taxes", "state"] }), - formInput: () => + formInput: () => }, ...InstanceRenderManager({ @@ -554,7 +561,7 @@ export function BillEnterModalLinesComponent({ valuePropName: "checked", name: [field.name, "applicable_taxes", "local"] }), - formInput: () => + formInput: () => } ] }), @@ -574,6 +581,7 @@ export function BillEnterModalLinesComponent({ icon={} disabled={disabled || invLen > 0} onClick={() => remove(record.name)} + tabIndex={0} /> {Simple_Inventory.treatment === "on" && ( @@ -645,12 +653,19 @@ export function BillEnterModalLinesComponent({