Merged in hotfix/2026-02-03 (pull request #2965)

Hotfix/2026 02 03

Approved-by: Allan Carr
This commit is contained in:
Dave Richer
2026-02-03 21:49:28 +00:00
committed by Allan Carr
8 changed files with 500 additions and 173 deletions

106
client/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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) => (
<BillLineSearchSelect
ref={(el) => {
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: () => <Input.TextArea disabled={disabled} autoSize />
formInput: () => <Input.TextArea disabled={disabled} autoSize tabIndex={0} />
},
{
title: t("billlines.fields.quantity"),
@@ -234,7 +239,7 @@ export function BillEnterModalLinesComponent({
})
]
}),
formInput: () => <InputNumber precision={0} min={1} disabled={disabled} />
formInput: () => <InputNumber precision={0} min={1} disabled={disabled} tabIndex={0} />
},
{
title: t("billlines.fields.actual_price"),
@@ -251,6 +256,7 @@ export function BillEnterModalLinesComponent({
<CurrencyInput
min={0}
disabled={disabled}
tabIndex={0}
// NOTE: Autofill should only happen on forward Tab out of Retail
onKeyDown={(e) => {
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: () => (
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled}>
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled} tabIndex={0}>
{bodyshopHasDmsKey(bodyshop)
? CiecaSelect(true, false)
: responsibilityCenters.costs.map((item) => <Select.Option key={item.name}>{item.name}</Select.Option>)}
@@ -412,7 +419,7 @@ export function BillEnterModalLinesComponent({
name: [field.name, "location"]
}),
formInput: () => (
<Select disabled={disabled}>
<Select disabled={disabled} tabIndex={0}>
{bodyshop.md_parts_locations.map((loc, idx) => (
<Select.Option key={idx} value={loc}>
{loc}
@@ -432,7 +439,7 @@ export function BillEnterModalLinesComponent({
key: `${field.name}deductedfromlbr`,
name: [field.name, "deductedfromlbr"]
}),
formInput: () => <Switch disabled={disabled} />,
formInput: () => <Switch disabled={disabled} tabIndex={0} />,
additional: (record, index) => (
<Form.Item shouldUpdate noStyle style={{ display: "inline-block" }}>
{() => {
@@ -523,7 +530,7 @@ export function BillEnterModalLinesComponent({
rome: false
})
}),
formInput: () => <Switch disabled={disabled} />
formInput: () => <Switch disabled={disabled} tabIndex={0} />
}
]
}),
@@ -538,7 +545,7 @@ export function BillEnterModalLinesComponent({
valuePropName: "checked",
name: [field.name, "applicable_taxes", "state"]
}),
formInput: () => <Switch disabled={disabled} />
formInput: () => <Switch disabled={disabled} tabIndex={0} />
},
...InstanceRenderManager({
@@ -554,7 +561,7 @@ export function BillEnterModalLinesComponent({
valuePropName: "checked",
name: [field.name, "applicable_taxes", "local"]
}),
formInput: () => <Switch disabled={disabled} />
formInput: () => <Switch disabled={disabled} tabIndex={0} />
}
]
}),
@@ -574,6 +581,7 @@ export function BillEnterModalLinesComponent({
icon={<DeleteFilled />}
disabled={disabled || invLen > 0}
onClick={() => remove(record.name)}
tabIndex={0}
/>
{Simple_Inventory.treatment === "on" && (
@@ -645,12 +653,19 @@ export function BillEnterModalLinesComponent({
<Button
disabled={disabled}
onClick={() => {
const newIndex = fields.length;
add(
InstanceRenderManager({
imex: { applicable_taxes: { federal: true } },
rome: { applicable_taxes: { federal: false } }
})
);
setTimeout(() => {
const firstField = firstFieldRefs.current[newIndex];
if (firstField?.focus) {
firstField.focus();
}
}, 100);
}}
style={{ width: "100%" }}
>

View File

@@ -132,7 +132,13 @@ export function LaborAllocationsAdjustmentEdit({
);
return (
<Popover open={open} onOpenChange={(vis) => setOpen(vis)} content={overlay} trigger="click">
<Popover
getPopupContainer={(trigger) => trigger?.parentElement || document.body}
open={open}
onOpenChange={(vis) => setOpen(vis)}
content={overlay}
trigger="click"
>
{children}
</Popover>
);

View File

@@ -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);
};
@@ -94,29 +94,31 @@ 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 = (
<Row gutter={[16, 16]}>
<Col span={24}>
<Select
id="employeeSelector"
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
optionFilterProp: "label",
filterOption: (input, option) => option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
}}
style={{ width: 200 }}
value={assignment.employeeid}
onChange={onChange}
>
{bodyshop.employees
.filter((emp) => emp.active)
.map((emp) => (
<Select.Option value={emp.id} key={emp.id} name={`${emp.first_name} ${emp.last_name}`}>
{`${emp.first_name} ${emp.last_name}`}
</Select.Option>
))}
</Select>
options={employeeOptions}
/>
</Col>
<Col span={24}>
<Space wrap>
@@ -141,25 +143,25 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
if (record[type]) theEmployee = bodyshop.employees.find((e) => e.id === record[type]);
return (
<Popover destroyOnHidden content={popContent} open={visibility}>
<Spin spinning={loading}>
{record[type] ? (
<div style={{ cursor: "pointer" }}>
<span>{`${theEmployee?.first_name || ""} ${theEmployee?.last_name || ""}`}</span>
<DeleteFilled style={iconStyle} onClick={() => handleRemove(type)} />
</div>
) : (
<Spin spinning={loading}>
{record[type] ? (
<div style={{ cursor: "pointer" }}>
<span>{`${theEmployee?.first_name || ""} ${theEmployee?.last_name || ""}`}</span>
<DeleteFilled style={iconStyle} onClick={() => handleRemove(type)} />
</div>
) : (
<Popover destroyOnHidden content={popContent} open={visibility} trigger="click">
<PlusCircleFilled
style={{ ...iconStyle, cursor: "pointer" }}
className="muted-button"
onClick={() => {
setAssignment({ operation: type });
setAssignment({ operation: type, employeeid: null });
setVisibility(true);
}}
/>
)}
</Spin>
</Popover>
</Popover>
)}
</Spin>
);
}

View File

@@ -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 { 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";
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,81 @@ 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,
opacity: isDragging ? 0.3 : 1,
userSelect: "none",
textAlign: "left"
};
// If no columnKey, render as regular header
if (!columnKey) {
return <ResizeableTitle {...props} />;
}
// 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 (
<ResizeableTitle
{...restProps}
ref={setNodeRef}
style={style}
onResize={onResize}
width={width}
dragAttributes={attributes}
dragListeners={filteredListeners}
>
{children}
</ResizeableTitle>
);
}
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 MIN_COL_WIDTH = 20;
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 1
}
})
);
const {
treatments: { Production_List_Status_Colors, Enhanced_Payroll }
} = useTreatmentsWithConfig({
@@ -36,8 +109,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) ||
@@ -46,6 +121,7 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
filteredInfo: { text: "" }
}
);
const initialColumnsRef = useRef(
(initialStateRef.current &&
bodyshop?.production_config
@@ -66,14 +142,36 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
})) ||
[]
);
const [state, setState] = useState(initialStateRef.current);
const [columns, setColumns] = useState(initialColumnsRef.current);
const scrollX = useMemo(() => {
// keep scroll width aligned with the actual column widths so AntD doesn't clamp at a fixed floor
const sum = columns.reduce((acc, c) => acc + (c.width ?? 100), 0);
return Math.max(sum, 1);
}, [columns]);
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, dont 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 {
@@ -89,10 +187,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,
@@ -102,7 +198,8 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
Production_List_Status_Colors,
refetch,
state,
columns
columns,
isResizing
]);
const handleTableChange = (pagination, filters, sorter) => {
@@ -118,17 +215,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);
@@ -139,19 +249,55 @@ 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) => {
const nextWidth = Math.max(MIN_COL_WIDTH, Math.round(width));
setColumns((prev) => {
const idx = prev.findIndex((c) => c.key === columnKey);
if (idx === -1) return prev;
const currentWidth = prev[idx].width ?? 100;
if (currentWidth === nextWidth) return prev;
const next = prev.slice();
next[idx] = { ...next[idx], width: nextWidth };
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];
@@ -163,19 +309,53 @@ 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 (
<Dropdown className="prod-header-dropdown" menu={menu} trigger={["contextMenu"]}>
<span>{col.title}</span>
</Dropdown>
<div
style={{
display: "flex",
alignItems: "left",
width: "100%",
userSelect: "none",
minWidth: 0 // critical: allow the flex row to shrink
}}
>
<span
className="drag-handle-trigger"
data-drag-handle="true"
style={{
marginRight: 8,
color: "#999",
cursor: "grab",
padding: 4,
display: "inline-flex",
alignItems: "left",
userSelect: "none",
flex: "0 0 auto"
}}
title="Drag to reorder column"
>
<HolderOutlined />
</span>
<Dropdown className="prod-header-dropdown" menu={menu} trigger={["contextMenu"]}>
<span
style={{
flex: "1 1 auto",
minWidth: 0, // critical: allow text to shrink
overflow: "hidden", // clip
textOverflow: "ellipsis", // show …
whiteSpace: "nowrap", // keep single line
cursor: "default",
userSelect: "none",
display: "block"
}}
>
{col.title}
</span>
</Dropdown>
</div>
);
};
@@ -274,6 +454,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;
}}
/>
<Input
@@ -286,60 +469,104 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
}
/>
<ProductionListDetail jobs={dataSource} />
<ReactDragListView.DragColumn onDragEnd={onDragEnd} nodeSelector="th" handleSelector=".prod-header-dropdown">
<Table
sticky
pagination={false}
size="small"
{...(Production_List_Status_Colors.treatment === "on" && {
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)
<DndContext
sensors={sensors}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={onDragCancel}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
>
<SortableContext items={columns.map((col) => col.key)} strategy={horizontalListSortingStrategy}>
<Table
sticky
tableLayout="fixed"
className="prod-list-table"
pagination={false}
size="small"
{...(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: {
backgroundColor: "var(--table-row-even-bg)"
"--bgColor": color.color
? `rgba(${color.color.r},${color.color.g},${color.color.b},${color.color.a || 1})`
: "var(--status-row-bg-fallback)"
}
};
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) => {
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(column.key),
onResizeStart: handleResizeStart,
onResizeStop: handleResizeStop(column.key)
})
};
}
})}
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}
/>
</ReactDragListView.DragColumn>
})}
rowKey="id"
loading={loading}
dataSource={dataSource}
scroll={{ x: scrollX }}
onChange={handleTableChange}
/>
</SortableContext>
<DragOverlay dropAnimation={null}>
{activeId ? (
<div
style={{
backgroundColor: isDarkMode ? "#141414" : "white",
color: isDarkMode ? "white" : "#000",
border: `2px solid ${isDarkMode ? "#177ddc" : "#1890ff"}`,
borderRadius: "4px",
padding: "12px 16px",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.25)",
cursor: "grabbing",
display: "flex",
alignItems: "center",
fontWeight: 500,
minWidth: "120px"
}}
>
<HolderOutlined style={{ marginRight: "8px", color: isDarkMode ? "white" : "#000", fontSize: "16px" }} />
<span>
{(() => {
const col = columns.find((c) => c.key === activeId);
const title = typeof col?.title === "string" ? col.title : col?.dataIndex || col?.key || "Column";
return title;
})()}
</span>
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
);
}

View File

@@ -1,28 +1,37 @@
import { forwardRef } from "react";
import { Resizable } from "react-resizable";
import "react-resizable/css/styles.css";
export default function ResizableComponent(props) {
const { onResize, width, ...restProps } = props;
const ResizableComponent = forwardRef((props, ref) => {
const { onResize, onResizeStart, onResizeStop, width, dragAttributes, dragListeners, ...restProps } = props;
if (!width) {
return <th {...restProps} />;
return <th ref={ref} {...restProps} {...(dragAttributes || {})} {...(dragListeners || {})} />;
}
return (
<Resizable
width={width || 200}
width={width}
height={0}
onResize={onResize}
onResizeStart={onResizeStart}
onResizeStop={onResizeStop}
draggableOpts={{ enableUserSelectHack: false }}
handle={
resizeHandles={["e"]}
axis="x"
handle={(axis, handleRef) => (
<span
className="react-resizable-handle"
onClick={(e) => {
e.stopPropagation();
}}
ref={handleRef}
className={`react-resizable-handle react-resizable-handle-${axis}`}
onClick={(e) => e.stopPropagation()}
/>
}
)}
>
<th {...restProps} />
<th ref={ref} {...restProps} {...(dragAttributes || {})} {...(dragListeners || {})} />
</Resizable>
);
}
});
ResizableComponent.displayName = "ResizableComponent";
export default ResizableComponent;