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", "@amplitude/analytics-browser": "^2.34.0",
"@ant-design/pro-layout": "^7.22.6", "@ant-design/pro-layout": "^7.22.6",
"@apollo/client": "^4.1.3", "@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", "@emotion/is-prop-valid": "^1.4.0",
"@fingerprintjs/fingerprintjs": "^5.0.1", "@fingerprintjs/fingerprintjs": "^5.0.1",
"@firebase/analytics": "^0.10.19", "@firebase/analytics": "^0.10.19",
@@ -60,7 +64,6 @@
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-cookie": "^8.0.1", "react-cookie": "^8.0.1",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-drag-listview": "^2.0.0",
"react-grid-gallery": "^1.0.1", "react-grid-gallery": "^1.0.1",
"react-grid-layout": "^2.2.2", "react-grid-layout": "^2.2.2",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.4",
@@ -2494,6 +2497,73 @@
"node": ">=10" "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": { "node_modules/@dotenvx/dotenvx": {
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz",
@@ -8396,16 +8466,6 @@
"@babel/types": "^7.26.0" "@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": { "node_modules/bail": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
@@ -9192,14 +9252,6 @@
"node": ">=18" "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": { "node_modules/core-js-compat": {
"version": "3.47.0", "version": "3.47.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz",
@@ -15376,16 +15428,6 @@
"react": "^19.2.4" "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": { "node_modules/react-draggable": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz", "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz",
@@ -15972,12 +16014,6 @@
"node": ">=4" "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": { "node_modules/regexp.prototype.flags": {
"version": "1.5.4", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "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", "@amplitude/analytics-browser": "^2.34.0",
"@ant-design/pro-layout": "^7.22.6", "@ant-design/pro-layout": "^7.22.6",
"@apollo/client": "^4.1.3", "@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", "@emotion/is-prop-valid": "^1.4.0",
"@fingerprintjs/fingerprintjs": "^5.0.1", "@fingerprintjs/fingerprintjs": "^5.0.1",
"@firebase/analytics": "^0.10.19", "@firebase/analytics": "^0.10.19",
@@ -59,7 +63,6 @@
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-cookie": "^8.0.1", "react-cookie": "^8.0.1",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-drag-listview": "^2.0.0",
"react-grid-gallery": "^1.0.1", "react-grid-gallery": "^1.0.1",
"react-grid-layout": "^2.2.2", "react-grid-layout": "^2.2.2",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.4",

View File

@@ -446,3 +446,32 @@
//.rbc-time-header-gutter { //.rbc-time-header-gutter {
// padding: 0; // 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 { DeleteFilled, DollarCircleFilled } from "@ant-design/icons";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Checkbox, Form, Input, InputNumber, Select, Space, Switch, Table, Tooltip } from "antd"; import { Button, Checkbox, Form, Input, InputNumber, Select, Space, Switch, Table, Tooltip } from "antd";
import { useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -32,6 +33,7 @@ export function BillEnterModalLinesComponent({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { setFieldsValue, getFieldsValue, getFieldValue } = form; const { setFieldsValue, getFieldsValue, getFieldValue } = form;
const firstFieldRefs = useRef({});
const CONTROL_HEIGHT = 32; const CONTROL_HEIGHT = 32;
@@ -155,6 +157,9 @@ export function BillEnterModalLinesComponent({
), ),
formInput: (record, index) => ( formInput: (record, index) => (
<BillLineSearchSelect <BillLineSearchSelect
ref={(el) => {
firstFieldRefs.current[index] = el;
}}
disabled={disabled} disabled={disabled}
options={lineData} options={lineData}
style={{ style={{
@@ -205,7 +210,7 @@ export function BillEnterModalLinesComponent({
label: t("billlines.fields.line_desc"), label: t("billlines.fields.line_desc"),
rules: [{ required: true }] rules: [{ required: true }]
}), }),
formInput: () => <Input.TextArea disabled={disabled} autoSize /> formInput: () => <Input.TextArea disabled={disabled} autoSize tabIndex={0} />
}, },
{ {
title: t("billlines.fields.quantity"), 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"), title: t("billlines.fields.actual_price"),
@@ -251,6 +256,7 @@ export function BillEnterModalLinesComponent({
<CurrencyInput <CurrencyInput
min={0} min={0}
disabled={disabled} disabled={disabled}
tabIndex={0}
// NOTE: Autofill should only happen on forward Tab out of Retail // NOTE: Autofill should only happen on forward Tab out of Retail
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Tab" && !e.shiftKey) autofillActualCost(index); if (e.key === "Tab" && !e.shiftKey) autofillActualCost(index);
@@ -328,6 +334,7 @@ export function BillEnterModalLinesComponent({
min={0} min={0}
disabled={disabled} disabled={disabled}
controls={false} controls={false}
tabIndex={0}
style={{ width: "100%", height: CONTROL_HEIGHT }} style={{ width: "100%", height: CONTROL_HEIGHT }}
// NOTE: No auto-fill on focus/blur; only triggered from Retail on Tab // NOTE: No auto-fill on focus/blur; only triggered from Retail on Tab
/> />
@@ -392,7 +399,7 @@ export function BillEnterModalLinesComponent({
rules: [{ required: true }] rules: [{ required: true }]
}), }),
formInput: () => ( formInput: () => (
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled}> <Select showSearch style={{ minWidth: "3rem" }} disabled={disabled} tabIndex={0}>
{bodyshopHasDmsKey(bodyshop) {bodyshopHasDmsKey(bodyshop)
? CiecaSelect(true, false) ? CiecaSelect(true, false)
: responsibilityCenters.costs.map((item) => <Select.Option key={item.name}>{item.name}</Select.Option>)} : responsibilityCenters.costs.map((item) => <Select.Option key={item.name}>{item.name}</Select.Option>)}
@@ -412,7 +419,7 @@ export function BillEnterModalLinesComponent({
name: [field.name, "location"] name: [field.name, "location"]
}), }),
formInput: () => ( formInput: () => (
<Select disabled={disabled}> <Select disabled={disabled} tabIndex={0}>
{bodyshop.md_parts_locations.map((loc, idx) => ( {bodyshop.md_parts_locations.map((loc, idx) => (
<Select.Option key={idx} value={loc}> <Select.Option key={idx} value={loc}>
{loc} {loc}
@@ -432,7 +439,7 @@ export function BillEnterModalLinesComponent({
key: `${field.name}deductedfromlbr`, key: `${field.name}deductedfromlbr`,
name: [field.name, "deductedfromlbr"] name: [field.name, "deductedfromlbr"]
}), }),
formInput: () => <Switch disabled={disabled} />, formInput: () => <Switch disabled={disabled} tabIndex={0} />,
additional: (record, index) => ( additional: (record, index) => (
<Form.Item shouldUpdate noStyle style={{ display: "inline-block" }}> <Form.Item shouldUpdate noStyle style={{ display: "inline-block" }}>
{() => { {() => {
@@ -523,7 +530,7 @@ export function BillEnterModalLinesComponent({
rome: false rome: false
}) })
}), }),
formInput: () => <Switch disabled={disabled} /> formInput: () => <Switch disabled={disabled} tabIndex={0} />
} }
] ]
}), }),
@@ -538,7 +545,7 @@ export function BillEnterModalLinesComponent({
valuePropName: "checked", valuePropName: "checked",
name: [field.name, "applicable_taxes", "state"] name: [field.name, "applicable_taxes", "state"]
}), }),
formInput: () => <Switch disabled={disabled} /> formInput: () => <Switch disabled={disabled} tabIndex={0} />
}, },
...InstanceRenderManager({ ...InstanceRenderManager({
@@ -554,7 +561,7 @@ export function BillEnterModalLinesComponent({
valuePropName: "checked", valuePropName: "checked",
name: [field.name, "applicable_taxes", "local"] 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 />} icon={<DeleteFilled />}
disabled={disabled || invLen > 0} disabled={disabled || invLen > 0}
onClick={() => remove(record.name)} onClick={() => remove(record.name)}
tabIndex={0}
/> />
{Simple_Inventory.treatment === "on" && ( {Simple_Inventory.treatment === "on" && (
@@ -645,12 +653,19 @@ export function BillEnterModalLinesComponent({
<Button <Button
disabled={disabled} disabled={disabled}
onClick={() => { onClick={() => {
const newIndex = fields.length;
add( add(
InstanceRenderManager({ InstanceRenderManager({
imex: { applicable_taxes: { federal: true } }, imex: { applicable_taxes: { federal: true } },
rome: { applicable_taxes: { federal: false } } rome: { applicable_taxes: { federal: false } }
}) })
); );
setTimeout(() => {
const firstField = firstFieldRefs.current[newIndex];
if (firstField?.focus) {
firstField.focus();
}
}, 100);
}} }}
style={{ width: "100%" }} style={{ width: "100%" }}
> >

View File

@@ -132,7 +132,13 @@ export function LaborAllocationsAdjustmentEdit({
); );
return ( 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} {children}
</Popover> </Popover>
); );

View File

@@ -35,8 +35,6 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
const result = await updateJob({ const result = await updateJob({
variables: { jobId: record.id, job: { [empAssignment]: employeeid } } variables: { jobId: record.id, job: { [empAssignment]: employeeid } }
// awaitRefetchQueries: true,
}); });
insertAuditTrail({ insertAuditTrail({
@@ -55,6 +53,7 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
await refetch(); await refetch();
setAssignment({ operation: null, employeeid: null });
setLoading(false); setLoading(false);
}; };
const handleRemove = async (operation) => { const handleRemove = async (operation) => {
@@ -84,6 +83,7 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
await refetch(); await refetch();
setAssignment({ operation: null, employeeid: null });
setLoading(false); setLoading(false);
}; };
@@ -94,29 +94,31 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
const [visibility, setVisibility] = useState(false); const [visibility, setVisibility] = useState(false);
const onChange = (e, option) => { 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 = ( const popContent = (
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col span={24}> <Col span={24}>
<Select <Select
id="employeeSelector" id="employeeSelector"
showSearch={{ showSearch={{
optionFilterProp: "children", optionFilterProp: "label",
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0 filterOption: (input, option) => option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
}} }}
style={{ width: 200 }} style={{ width: 200 }}
value={assignment.employeeid}
onChange={onChange} onChange={onChange}
> options={employeeOptions}
{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>
</Col> </Col>
<Col span={24}> <Col span={24}>
<Space wrap> <Space wrap>
@@ -141,25 +143,25 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
if (record[type]) theEmployee = bodyshop.employees.find((e) => e.id === record[type]); if (record[type]) theEmployee = bodyshop.employees.find((e) => e.id === record[type]);
return ( return (
<Popover destroyOnHidden content={popContent} open={visibility}> <Spin spinning={loading}>
<Spin spinning={loading}> {record[type] ? (
{record[type] ? ( <div style={{ cursor: "pointer" }}>
<div style={{ cursor: "pointer" }}> <span>{`${theEmployee?.first_name || ""} ${theEmployee?.last_name || ""}`}</span>
<span>{`${theEmployee?.first_name || ""} ${theEmployee?.last_name || ""}`}</span> <DeleteFilled style={iconStyle} onClick={() => handleRemove(type)} />
<DeleteFilled style={iconStyle} onClick={() => handleRemove(type)} /> </div>
</div> ) : (
) : ( <Popover destroyOnHidden content={popContent} open={visibility} trigger="click">
<PlusCircleFilled <PlusCircleFilled
style={{ ...iconStyle, cursor: "pointer" }} style={{ ...iconStyle, cursor: "pointer" }}
className="muted-button" className="muted-button"
onClick={() => { onClick={() => {
setAssignment({ operation: type }); setAssignment({ operation: type, employeeid: null });
setVisibility(true); setVisibility(true);
}} }}
/> />
)} </Popover>
</Spin> )}
</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 { PageHeader } from "@ant-design/pro-layout";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Dropdown, Input, Space, Statistic, Table } from "antd"; import { Button, Dropdown, Input, Space, Statistic, Table } from "antd";
import _ from "lodash"; import _ from "lodash";
import { useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactDragListView from "react-drag-listview"; 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 { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectTechnician } from "../../redux/tech/tech.selectors"; import { selectTechnician } from "../../redux/tech/tech.selectors";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { selectDarkMode } from "../../redux/application/application.selectors.js";
import Prompt from "../../utils/prompt.js"; import Prompt from "../../utils/prompt.js";
import AlertComponent from "../alert/alert.component.jsx"; import AlertComponent from "../alert/alert.component.jsx";
import ProductionListColumnsAdd from "../production-list-columns/production-list-columns.add.component"; 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({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
technician: selectTechnician, 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 [searchText, setSearchText] = useState("");
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); 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 { const {
treatments: { Production_List_Status_Colors, Enhanced_Payroll } treatments: { Production_List_Status_Colors, Enhanced_Payroll }
} = useTreatmentsWithConfig({ } = useTreatmentsWithConfig({
@@ -36,8 +109,10 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
names: ["Production_List_Status_Colors", "Enhanced_Payroll"], names: ["Production_List_Status_Colors", "Enhanced_Payroll"],
splitKey: bodyshop.imexshopid splitKey: bodyshop.imexshopid
}); });
const assoc = bodyshop.associations.find((a) => a.useremail === currentUser.email); const assoc = bodyshop.associations.find((a) => a.useremail === currentUser.email);
const defaultView = assoc?.default_prod_list_view; const defaultView = assoc?.default_prod_list_view;
const initialStateRef = useRef( const initialStateRef = useRef(
(bodyshop.production_config && (bodyshop.production_config &&
bodyshop.production_config.find((p) => p.name === defaultView)?.columns.tableState) || bodyshop.production_config.find((p) => p.name === defaultView)?.columns.tableState) ||
@@ -46,6 +121,7 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
filteredInfo: { text: "" } filteredInfo: { text: "" }
} }
); );
const initialColumnsRef = useRef( const initialColumnsRef = useRef(
(initialStateRef.current && (initialStateRef.current &&
bodyshop?.production_config bodyshop?.production_config
@@ -66,14 +142,36 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
})) || })) ||
[] []
); );
const [state, setState] = useState(initialStateRef.current); const [state, setState] = useState(initialStateRef.current);
const [columns, setColumns] = useState(initialColumnsRef.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 { t } = useTranslation();
const matchingColumnConfig = useMemo(() => { const matchingColumnConfig = useMemo(() => {
return bodyshop?.production_config?.find((p) => p.name === defaultView); return bodyshop?.production_config?.find((p) => p.name === defaultView);
}, [bodyshop.production_config, defaultView]); }, [bodyshop.production_config, defaultView]);
// NEW: cleanup RAF on unmount
useEffect(() => { 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 = const newColumns =
matchingColumnConfig?.columns.columnKeys.map((k) => { matchingColumnConfig?.columns.columnKeys.map((k) => {
return { return {
@@ -89,10 +187,8 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
width: k.width ?? 100 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, matchingColumnConfig,
bodyshop, bodyshop,
@@ -102,7 +198,8 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
Production_List_Status_Colors, Production_List_Status_Colors,
refetch, refetch,
state, state,
columns columns,
isResizing
]); ]);
const handleTableChange = (pagination, filters, sorter) => { 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 }); logImEXEvent("production_list_sort_filter", { pagination, filters, sorter });
}; };
const onDragEnd = (fromIndex, toIndex) => { const onDragStart = ({ active }) => {
if (fromIndex === toIndex) return; setActiveId(active.id);
const columnsCopy = [...columns]; };
const [movedItem] = columnsCopy.splice(fromIndex, 1);
columnsCopy.splice(toIndex, 0, movedItem); const onDragEnd = ({ active, over }) => {
if (!_.isEqual(columnsCopy, columns)) { setActiveId(null);
setColumns(columnsCopy); if (!over || active.id === over.id) return;
setHasUnsavedChanges(true);
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 removeColumn = (e) => {
const { key } = e; const { key } = e;
const newColumns = columns.filter((i) => i.key !== key); 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 }); logImEXEvent("production_list_remove_column", { key });
}; };
const handleResize = // NEW: commit widths via rAF (less jank)
(index) => const applyColumnWidth = useCallback((columnKey, width) => {
(e, { size }) => { const nextWidth = Math.max(MIN_COL_WIDTH, Math.round(width));
const nextColumns = [...columns]; setColumns((prev) => {
nextColumns[index] = { const idx = prev.findIndex((c) => c.key === columnKey);
...nextColumns[index], if (idx === -1) return prev;
width: size.width
}; const currentWidth = prev[idx].width ?? 100;
if (!_.isEqual(nextColumns, columns)) { if (currentWidth === nextWidth) return prev;
setColumns(nextColumns);
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); setHasUnsavedChanges(true);
} logImEXEvent("production_list_resize_column", { key: columnKey, width: size.width });
}; },
[applyColumnWidth]
);
const addColumn = (newColumn) => { const addColumn = (newColumn) => {
const updatedColumns = [...columns, newColumn]; const updatedColumns = [...columns, newColumn];
@@ -163,19 +309,53 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
}; };
const headerItem = (col) => { const headerItem = (col) => {
const menu = { const menu = { onClick: removeColumn, items: [{ key: col.key, label: t("production.actions.removecolumn") }] };
onClick: removeColumn,
items: [
{
key: col.key,
label: t("production.actions.removecolumn")
}
]
};
return ( return (
<Dropdown className="prod-header-dropdown" menu={menu} trigger={["contextMenu"]}> <div
<span>{col.title}</span> style={{
</Dropdown> 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={() => { onSave={() => {
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
initialStateRef.current = state; initialStateRef.current = state;
// NEW: after saving, treat current columns as the baseline
initialColumnsRef.current = columns;
}} }}
/> />
<Input <Input
@@ -286,60 +469,104 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
} }
/> />
<ProductionListDetail jobs={dataSource} /> <ProductionListDetail jobs={dataSource} />
<ReactDragListView.DragColumn onDragEnd={onDragEnd} nodeSelector="th" handleSelector=".prod-header-dropdown"> <DndContext
<Table sensors={sensors}
sticky onDragStart={onDragStart}
pagination={false} onDragEnd={onDragEnd}
size="small" onDragCancel={onDragCancel}
{...(Production_List_Status_Colors.treatment === "on" && { collisionDetection={closestCenter}
onRow: (record, index) => { modifiers={[restrictToHorizontalAxis]}
if (!bodyshop.md_ro_statuses.production_colors) return null; >
const color = bodyshop.md_ro_statuses.production_colors.find((x) => x.status === record.status); <SortableContext items={columns.map((col) => col.key)} strategy={horizontalListSortingStrategy}>
if (!color) { <Table
if (index % 2 === 0) 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 { return {
className: "rowWithColor",
style: { 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)
})
}; };
} })}
})} rowKey="id"
components={{ loading={loading}
header: { dataSource={dataSource}
cell: ResizeableTitle scroll={{ x: scrollX }}
} onChange={handleTableChange}
}} />
columns={columns.map((c, index) => { </SortableContext>
return {
...c, <DragOverlay dropAnimation={null}>
filteredValue: state.filteredInfo[c.key] || null, {activeId ? (
sortOrder: state.sortedInfo.columnKey === c.key && state.sortedInfo.order, <div
title: headerItem(c), style={{
ellipsis: true, backgroundColor: isDarkMode ? "#141414" : "white",
width: c.width ?? 100, color: isDarkMode ? "white" : "#000",
onHeaderCell: (column) => ({ border: `2px solid ${isDarkMode ? "#177ddc" : "#1890ff"}`,
width: column.width, borderRadius: "4px",
onResize: handleResize(index) padding: "12px 16px",
}) boxShadow: "0 4px 12px rgba(0, 0, 0, 0.25)",
}; cursor: "grabbing",
})} display: "flex",
rowKey="id" alignItems: "center",
loading={loading} fontWeight: 500,
dataSource={dataSource} minWidth: "120px"
scroll={{ x: 1000 }} }}
onChange={handleTableChange} >
/> <HolderOutlined style={{ marginRight: "8px", color: isDarkMode ? "white" : "#000", fontSize: "16px" }} />
</ReactDragListView.DragColumn> <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> </div>
); );
} }

View File

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