Compare commits

...

51 Commits

Author SHA1 Message Date
Allan Carr
d8b400cb8c IO-3503 InstanceManager change
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-06 15:15:11 -08:00
Allan Carr
fe7bf684aa IO-3503 Job Costing Fix
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-06 15:04:00 -08:00
Dave Richer
7e6c97b3cf Merged in hotfix/2026-02-03 (pull request #2970)
Hotfix/2026 02 03
2026-02-06 22:20:58 +00:00
Allan Carr
9c6fe1905d Merged in feature/IO-3533-Actual-Cost-Click-to-Focus (pull request #2967)
IO-3533 Actual Cost Click to Focus

Approved-by: Dave Richer
2026-02-06 19:53:32 +00:00
Allan Carr
2126cccff1 IO-3533 Actual Cost Click to Focus
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-06 11:23:34 -08:00
Dave
fde137d7f7 Merge branch 'feature/IO-3550-Labor-Adjustment-Popover' into hotfix/2026-02-03 2026-02-03 16:00:28 -05:00
Dave
b797bf7dc9 Merge branch 'feature/IO-3548-Bill-Modal-TabOrder' into hotfix/2026-02-03 2026-02-03 16:00:08 -05:00
Dave Richer
37c3be5cde Merged in feature/IO-3550-Labor-Adjustment-Popover (pull request #2963)
feature/IO-3550-Labor-Adjustment-Popover - Fix
2026-02-03 20:58:01 +00:00
Dave
b87d1a65fe feature/IO-3550-Labor-Adjustment-Popover - Fix 2026-02-03 15:57:24 -05:00
Dave Richer
35c832dbc3 Merged in feature/IO-3545-Production-Board-List-DND (pull request #2961)
feature/IO-3548-Bill-Modal-TabOrder
2026-02-03 20:51:19 +00:00
Dave
019b3cf4da feature/IO-3548-Bill-Modal-TabOrder 2026-02-03 15:50:48 -05:00
Dave Richer
27f4385539 Merged in feature/IO-3548-Bill-Modal-TabOrder (pull request #2959)
feature/IO-3548-Bill-Modal-TabOrder
2026-02-03 20:43:39 +00:00
Dave
ad520ab23e feature/IO-3548-Bill-Modal-TabOrder 2026-02-03 15:42:10 -05:00
Dave Richer
b3716521ec Merged in feature/IO-3545-Production-Board-List-DND (pull request #2957)
Feature/IO-3545 Production Board List DND
2026-02-03 20:30:42 +00:00
Dave
05ae0801e5 feature/IO-3545-Production-Board-List-DND - EMP assignment selector fix 2026-02-03 15:29:03 -05:00
Dave
332ade96e5 feature/IO-3545-Production-Board-List-DND - Checkpoint 2026-02-03 15:17:20 -05:00
Dave
3acec55c0e feature/IO-3545-Production-Board-List-DND - Checkpoint 2026-02-03 15:01:10 -05:00
Dave
da0462f14c feature/IO-3545-Production-Board-List-DND - Checkpoint 2026-02-03 14:56:04 -05:00
Dave
2cc9fa961e feature/IO-3545-Production-Board-List-DND - Checkpoint 2026-02-03 14:34:42 -05:00
Allan Carr
2646e85863 Merged in feature/IO-3510-Autohouse-Datapump-Enhancements (pull request #2956)
IO-3510 Autohouse Datapump Enhancements

Approved-by: Dave Richer
2026-02-03 19:20:30 +00:00
Dave
1b6fe4d18e feature/IO-3545-Production-Board-List-DND - Checkpoint 2026-02-03 13:26:17 -05:00
Dave
22aae0a7f1 feature/IO-3545-Production-Board-List-DND - Checkpoint 2026-02-03 13:21:32 -05:00
Dave Richer
cfbd6f93c3 Merged in master-AIO (pull request #2954)
Master AIO
2026-02-03 15:52:51 +00:00
Patrick Fic
db1b701a96 Merged in hotfix/2026-02-02 (pull request #2951)
Hotfix/2026 02 02

Approved-by: Dave Richer
2026-02-02 22:49:14 +00:00
Dave
2746421c09 hotfix/2026-02-02 - 2026-02-02 17:48:03 -05:00
Dave
5217120994 hotfix/2026-02-02 - Parts order manual discounting box 2026-02-02 17:39:47 -05:00
Dave
77f72a2a12 Merge branch 'hotfix/2026-02-02' of bitbucket.org:snaptsoft/bodyshop into hotfix/2026-02-02 2026-02-02 17:11:06 -05:00
Dave
a84ad4ee32 hotfix/2026-02-02 - remove check on missing line ids 2026-02-02 17:10:56 -05:00
Dave Richer
2cacd75822 Merged in bugfix/IO-3533 (pull request #2948)
bugfix/IO-3533 - Fix
2026-02-02 22:05:56 +00:00
Dave
217a0b84ac bugfix/IO-3533 - Fix 2026-02-02 17:04:06 -05:00
Dave Richer
f53ed8c427 Merged in feature/IO-3532-parts-queue-ui-adjustments (pull request #2944)
feature/IO-3532-parts-queue-ui-adjustments

Approved-by: Patrick Fic
2026-02-02 21:51:31 +00:00
Dave Richer
f8b7588a04 Merged in feature/IO-3542-fix-searches (pull request #2945)
feature/IO-3542-fix-searches

Approved-by: Patrick Fic
2026-02-02 21:46:24 +00:00
Patrick Fic
ee3cb4456d Merged in feature/IO-3531-apollo-rerender (pull request #2946)
IO-3531 remove loading on parts order page.
2026-02-02 21:45:59 +00:00
Patrick Fic
ae05692c46 IO-3531 remove loading on parts order page. 2026-02-02 13:45:25 -08:00
Dave
e01a2af5a4 feature/IO-3542-fix-searches 2026-02-02 16:44:49 -05:00
Patrick Fic
b9f398cf2d Merged in feature/IO-3532-parts-queue-ui-adjustments (pull request #2943)
IO-3532 resolve tooltip on owner name.
2026-02-02 20:57:05 +00:00
Patrick Fic
1e44d4fe42 Merged in feature/IO-3539-print-center-popovers (pull request #2941)
IO-3539 resolve print center popoves.
2026-02-02 20:38:49 +00:00
Patrick Fic
0f42875d1b IO-3539 resolve print center popoves. 2026-02-02 12:38:29 -08:00
Patrick Fic
a0f1299006 Merged in feature/IO-3538-receivec-cm-on-parts-order (pull request #2940)
IO-3538 Resolve missing id on receive return.
2026-02-02 20:23:20 +00:00
Patrick Fic
87d8a5d746 IO-3538 Resolve missing id on receive return. 2026-02-02 12:22:58 -08:00
Patrick Fic
268851902a Merged in feature/IO-3535-fed-tax-toggle-bill-posting (pull request #2935)
IO-3535 Resolve federal tax default off on received parts order.
2026-02-02 20:07:26 +00:00
Dave Richer
68bb7d2529 Merged in bugfix/IO-3533 (pull request #2937)
bugfix/IO-3533 - Disable on blurr and on focus handlers in bill entry modal

Approved-by: Patrick Fic
2026-02-02 20:06:13 +00:00
Patrick Fic
d50db12330 Merged in feature/IO-3534-bill-discrep-coloring (pull request #2936)
IO-3534 Adjust value prop to content for antd prop change to fix color display.
2026-02-02 20:05:54 +00:00
Patrick Fic
1438986c18 Merged in feature/IO-3532-parts-queue-ui-adjustments (pull request #2938)
IO-3532 Resolve parts queue pages.
2026-02-02 20:05:15 +00:00
Patrick Fic
c047699fbb Merged in feature/IO-3531-apollo-rerender (pull request #2939)
IO-3531 Change global apollo config setting to prevent rerenders.
2026-02-02 20:03:29 +00:00
Patrick Fic
e5b7fcb919 IO-3531 Change global apollo config setting to prevent rerenders. 2026-02-02 12:02:11 -08:00
Dave
55023ceaca feature/IO-3534-bill-discrep-coloring: Remove unused console.log 2026-02-02 12:47:04 -05:00
Dave
45e143578c bugfix/IO-3533 - Disable on blurr and on focus handlers in bill entry modal 2026-02-02 12:34:54 -05:00
Patrick Fic
28a41f7637 IO-3534 Adjust value prop to content for antd prop change to fix color display. 2026-02-02 09:33:37 -08:00
Patrick Fic
2a2edeadb9 IO-3535 Resolve federal tax default off on received parts order. 2026-02-02 09:25:58 -08:00
Allan Carr
52c9b9a290 IO-3510 Autohouse Datapump Enhancements
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-27 19:20:12 -08:00
33 changed files with 764 additions and 316 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

@@ -52,6 +52,9 @@ export default function BillCmdReturnsTableComponent({ form, returnLoading, retu
{fields.map((field, index) => (
<tr key={field.key}>
<td>
<Form.Item hidden key={`${index}id`} name={[field.name, "id"]}>
<ReadOnlyFormItemComponent />
</Form.Item>
<Form.Item
// label={t("joblines.fields.line_desc")}
key={`${index}line_desc`}

View File

@@ -373,9 +373,11 @@ export function BillFormComponent({
"local_tax_rate"
]);
let totals;
if (!!values.total && !!values.billlines && values.billlines.length > 0)
if (!!values.total && !!values.billlines && values.billlines.length > 0) {
totals = CalculateBillTotal(values);
if (totals)
}
if (totals) {
return (
// TODO: Align is not correct
// eslint-disable-next-line react/no-unknown-property
@@ -414,7 +416,7 @@ export function BillFormComponent({
<Statistic
title={t("bills.labels.discrepancy")}
styles={{
value: {
content: {
color: totals.discrepancy.getAmount() === 0 ? "green" : "red"
}
}}
@@ -427,6 +429,7 @@ export function BillFormComponent({
) : null}
</div>
);
}
return null;
}}
</Form.Item>

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;
@@ -90,6 +92,7 @@ export function BillEnterModalLinesComponent({
});
};
// Only fill actual_cost when the user forward-tabs out of Retail (actual_price)
const autofillActualCost = (index) => {
Promise.resolve().then(() => {
const retailRaw = form.getFieldValue(["billlines", index, "actual_price"]);
@@ -154,6 +157,9 @@ export function BillEnterModalLinesComponent({
),
formInput: (record, index) => (
<BillLineSearchSelect
ref={(el) => {
firstFieldRefs.current[index] = el;
}}
disabled={disabled}
options={lineData}
style={{
@@ -164,10 +170,9 @@ export function BillEnterModalLinesComponent({
}}
allowRemoved={form.getFieldValue("is_credit_memo") || false}
onSelect={(value, opt) => {
const d = normalizeDiscount(discount);
const retail = Number(opt.cost);
const computedActual = Number.isFinite(retail) ? round2(retail * (1 - d)) : null;
// IMPORTANT:
// Do NOT autofill actual_cost here. It should only fill when the user forward-tabs
// from Retail (actual_price) -> Actual Cost (actual_cost).
setFieldsValue({
billlines: (getFieldValue("billlines") || []).map((item, idx) => {
if (idx !== index) return item;
@@ -178,7 +183,7 @@ export function BillEnterModalLinesComponent({
quantity: opt.part_qty || 1,
actual_price: opt.cost,
original_actual_price: opt.cost,
actual_cost: isBlank(item.actual_cost) ? computedActual : item.actual_cost,
// actual_cost intentionally untouched here
cost_center: opt.part_type
? bodyshopHasDmsKey(bodyshop)
? opt.part_type !== "PAE"
@@ -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,9 +256,10 @@ export function BillEnterModalLinesComponent({
<CurrencyInput
min={0}
disabled={disabled}
onBlur={() => autofillActualCost(index)}
tabIndex={0}
// NOTE: Autofill should only happen on forward Tab out of Retail
onKeyDown={(e) => {
if (e.key === "Tab") autofillActualCost(index);
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 }}
onFocus={() => autofillActualCost(index)}
/>
@@ -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" }}>
{() => {
@@ -517,9 +524,13 @@ export function BillEnterModalLinesComponent({
formItemProps: (field) => ({
key: `${field.name}fedtax`,
valuePropName: "checked",
name: [field.name, "applicable_taxes", "federal"]
name: [field.name, "applicable_taxes", "federal"],
initialValue: InstanceRenderManager({
imex: true,
rome: false
})
}),
formInput: () => <Switch disabled={disabled} />
formInput: () => <Switch disabled={disabled} tabIndex={0} />
}
]
}),
@@ -534,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({
@@ -550,7 +561,7 @@ export function BillEnterModalLinesComponent({
valuePropName: "checked",
name: [field.name, "applicable_taxes", "local"]
}),
formInput: () => <Switch disabled={disabled} />
formInput: () => <Switch disabled={disabled} tabIndex={0} />
}
]
}),
@@ -570,6 +581,7 @@ export function BillEnterModalLinesComponent({
icon={<DeleteFilled />}
disabled={disabled || invLen > 0}
onClick={() => remove(record.name)}
tabIndex={0}
/>
{Simple_Inventory.treatment === "on" && (
@@ -641,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

@@ -57,7 +57,6 @@ const CardPaymentModalComponent = ({
QUERY_RO_AND_OWNER_BY_JOB_PKS,
{
fetchPolicy: "network-only",
notifyOnNetworkStatusChange: true
}
);

View File

@@ -47,7 +47,6 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
const [getConversations, { loading, data, refetch, called }] = useLazyQuery(CONVERSATION_LIST_QUERY, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
notifyOnNetworkStatusChange: true,
...(pollInterval > 0 ? { pollInterval } : {})
});

View File

@@ -36,7 +36,7 @@ export function DashboardTotalProductionHours({ bodyshop, data, ...cardProps })
<Statistic
title={t("dashboard.labels.prodhrs")}
value={hours.total.toFixed(1)}
styles={{ value: { color: aboveTargetHours ? "green" : "red" } }}
styles={{ content: { color: aboveTargetHours ? "green" : "red" } }}
/>
</Space>
</Card>

View File

@@ -404,7 +404,7 @@ export default function CdkLikePostForm({ bodyshop, socket, job, logsRef, mode,
<Typography.Title>=</Typography.Title>
<Statistic
title={t("jobs.labels.dms.notallocated")}
styles={{ value: { color: discrep.getAmount() === 0 ? "green" : "red" } }}
styles={{ content: { color: discrep.getAmount() === 0 ? "green" : "red" } }}
value={discrep.toFormat()}
/>
<Button disabled={disablePost} htmlType="submit">

View File

@@ -14,8 +14,11 @@ export default function GlobalSearch() {
const [callSearch, { loading, error, data }] = useLazyQuery(GLOBAL_SEARCH_QUERY);
const navigate = useNavigate();
const executeSearch = (v) => {
if (v && v.variables.search && v.variables.search !== "" && v.variables.search.length >= 3) callSearch(v);
const executeSearch = (variables) => {
if (variables?.search !== "" && variables?.search?.length >= 3)
callSearch({
variables
});
};
const debouncedExecuteSearch = _.debounce(executeSearch, 750);
@@ -157,7 +160,9 @@ export default function GlobalSearch() {
return (
<AutoComplete
options={options}
onSearch={handleSearch}
showSearch={{
onSearch: handleSearch
}}
defaultActiveFirstOption
onKeyDown={(e) => {
if (e.key !== "Enter") return;

View File

@@ -97,7 +97,7 @@ export function Jobd3RdPartyModal({ bodyshop, jobId, job, technician }) {
return (
<>
<Button onClick={showModal}>{t("printcenter.jobs.3rdpartypayer")}</Button>
<Modal open={isModalVisible} onOk={handleOk} onCancel={handleCancel}>
<Modal open={isModalVisible} onOk={handleOk} onCancel={handleCancel} getContainer={() => document.body}>
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
<Form.Item label={t("bills.fields.vendor")} name="vendorid">
<VendorSearchSelect options={VendorAutoCompleteData?.vendors} onSelect={handleVendorSelect} />

View File

@@ -157,7 +157,6 @@ export function JobsDetailHeaderActions({
variables: watcherVars,
skip: !jobId,
fetchPolicy: "cache-first",
notifyOnNetworkStatusChange: true
});
const jobWatchersCount = jobWatchersData?.job_watchers?.length ?? job?.job_watchers?.length ?? 0;

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

@@ -56,7 +56,6 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount,
where: whereClause
},
fetchPolicy: "cache-and-network",
notifyOnNetworkStatusChange: true,
errorPolicy: "all",
pollInterval: isConnected ? 0 : day.duration(NOTIFICATION_POLL_INTERVAL_SECONDS, "seconds").asMilliseconds(),
skip: skipQuery

View File

@@ -16,9 +16,10 @@ const OwnerSearchSelect = ({ value, onChange, onBlur, disabled, ref }) => {
SEARCH_OWNERS_BY_ID_FOR_AUTOCOMPLETE
);
const executeSearch = (v) => {
if (v && v.variables?.search !== "" && v.variables.search.length >= 2) callSearch({ variables: v.variables });
const executeSearch = (variables) => {
if (variables?.search !== "" && variables?.search?.length >= 2) callSearch({ variables });
};
const debouncedExecuteSearch = _.debounce(executeSearch, 500);
const handleSearch = (value) => {

View File

@@ -1,94 +1,121 @@
import { DownOutlined } from "@ant-design/icons";
import { Dropdown, InputNumber, Space } from "antd";
import { Button, Divider, Dropdown, InputNumber, Space, theme } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils";
const DISCOUNT_PRESETS = [5, 10, 15, 20, 25, 40];
export default function PartsOrderModalPriceChange({ form, field }) {
const { t } = useTranslation();
const menu = {
items: [
{
key: "5",
label: t("parts_orders.labels.discount", { percent: "5%" })
},
{
key: "10",
label: t("parts_orders.labels.discount", { percent: "10%" })
},
{
key: "15",
label: t("parts_orders.labels.discount", { percent: "15%" })
},
{
key: "20",
label: t("parts_orders.labels.discount", { percent: "20%" })
},
{
key: "25",
label: t("parts_orders.labels.discount", { percent: "25%" })
},
{
key: "40",
label: t("parts_orders.labels.discount", { percent: "40%" })
},
{
key: "custom",
label: (
<Space.Compact>
<InputNumber
onClick={(e) => e.stopPropagation()}
onKeyUp={(e) => {
if (e.key === "Enter") {
const values = form.getFieldsValue();
const { parts_order_lines } = values;
const { token } = theme.useToken();
form.setFieldsValue({
parts_order_lines: {
data: parts_order_lines.data.map((p, idx) => {
if (idx !== field.name) return p;
console.log(p, e.target.value, (p.act_price || 0) * ((100 - (e.target.value || 0)) / 100));
return {
...p,
act_price: (p.act_price || 0) * ((100 - (e.target.value || 0)) / 100)
};
})
}
});
e.target.value = 0;
}
}}
min={0}
max={100}
/>
<span style={{ padding: "0 11px", backgroundColor: "#fafafa", border: "1px solid #d9d9d9", borderLeft: 0 }}>%</span>
</Space.Compact>
)
const [open, setOpen] = useState(false);
const [customPercent, setCustomPercent] = useState(0);
const applyDiscountPercent = (percent) => {
const pct = Number(percent) || 0;
const values = form.getFieldsValue();
const parts_order_lines = values?.parts_order_lines;
const data = Array.isArray(parts_order_lines?.data) ? parts_order_lines.data : [];
if (!data.length) return;
form.setFieldsValue({
parts_order_lines: {
data: data.map((p, idx) => {
if (idx !== field.name) return p;
return {
...p,
act_price: (p.act_price || 0) * ((100 - pct) / 100)
};
})
}
],
});
};
const applyCustom = () => {
logImEXEvent("parts_order_manual_discount", {});
applyDiscountPercent(customPercent);
setCustomPercent(0);
setOpen(false);
};
const menu = {
// Kill the menu “card” styling so our wrapper becomes the single card.
style: {
background: "transparent",
boxShadow: "none"
},
items: DISCOUNT_PRESETS.map((pct) => ({
key: String(pct),
label: t("parts_orders.labels.discount", { percent: `${pct}%` })
})),
onClick: ({ key }) => {
logImEXEvent("parts_order_manual_discount", {});
if (key === "custom") return;
const values = form.getFieldsValue();
const { parts_order_lines } = values;
form.setFieldsValue({
parts_order_lines: {
data: parts_order_lines.data.map((p, idx) => {
if (idx !== field.name) return p;
return {
...p,
act_price: (p.act_price || 0) * ((100 - key) / 100)
};
})
}
});
applyDiscountPercent(key);
setOpen(false);
}
};
return (
<Dropdown menu={menu} trigger="click">
<Dropdown
menu={menu}
trigger={["click"]}
open={open}
onOpenChange={(nextOpen) => setOpen(nextOpen)}
getPopupContainer={(triggerNode) => triggerNode?.parentElement ?? document.body}
popupRender={(menus) => (
<div
// This makes the whole dropdown (menu + footer) look like one panel in both light/dark.
style={{
background: token.colorBgElevated,
borderRadius: token.borderRadiusLG,
boxShadow: token.boxShadowSecondary,
overflow: "hidden",
minWidth: 180
}}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{menus}
<Divider style={{ margin: 0 }} />
<div style={{ padding: token.paddingXS }}>
<Space.Compact style={{ width: "100%" }}>
<InputNumber
value={customPercent}
min={0}
max={100}
precision={0}
controls={false}
style={{ width: "100%" }}
formatter={(v) => (v === null || v === undefined ? "" : `${v}%`)}
parser={(v) =>
String(v ?? "")
.replace("%", "")
.trim()
}
onChange={(v) => setCustomPercent(v ?? 0)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
applyCustom();
}
}}
/>
<Button type="primary" onClick={applyCustom}>
{t("general.labels.apply")}
</Button>
</Space.Compact>
</div>
</div>
)}
>
<Space>
%
<DownOutlined />
% <DownOutlined />
</Space>
</Dropdown>
);

View File

@@ -17,7 +17,6 @@ import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { GenerateDocument } from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants";
import AlertComponent from "../alert/alert.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import PartsOrderModalComponent from "./parts-order-modal.component";
import axios from "axios";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
@@ -66,7 +65,7 @@ export function PartsOrderModalContainer({
const sendTypeState = useState("e");
const sendType = sendTypeState[0];
const { loading, error, data } = useQuery(QUERY_ALL_VENDORS_FOR_ORDER, {
const { error, data } = useQuery(QUERY_ALL_VENDORS_FOR_ORDER, {
skip: !open,
variables: { jobId: jobId },
fetchPolicy: "network-only",
@@ -94,16 +93,6 @@ export function PartsOrderModalContainer({
};
});
const missingIdx = forcedLines.findIndex((l) => !l?.job_line_id);
if (missingIdx !== -1) {
notification.error({
title: t("parts_orders.errors.creating"),
description: `Missing job_line_id for parts line #${missingIdx + 1}`
});
setSaving(false);
return;
}
let insertResult;
try {
insertResult = await insertPartOrder({
@@ -372,6 +361,7 @@ export function PartsOrderModalContainer({
}
}, [open, linesToOrder, form]);
//This used to have a loading component spinner for the vendor data. With Apollo 4, the NetworkState isn't emitting correctly, so loading just gets set to true the second time, and no longer works as expected.
return (
<Modal
open={open}
@@ -390,18 +380,14 @@ export function PartsOrderModalContainer({
>
{error ? <AlertComponent title={error.message} type="error" /> : null}
<Form form={form} layout="vertical" autoComplete="no" onFinish={handleFinish} initialValues={initialValues}>
{loading ? (
<LoadingSpinner />
) : (
<PartsOrderModalComponent
form={form}
vendorList={data?.vendors || []}
sendTypeState={sendTypeState}
isReturn={isReturn}
preferredMake={data && data.jobs[0] && data.jobs[0].v_make_desc}
job={job}
/>
)}
<PartsOrderModalComponent
form={form}
vendorList={data?.vendors || []}
sendTypeState={sendTypeState}
isReturn={isReturn}
preferredMake={data && data.jobs[0] && data.jobs[0].v_make_desc}
job={job}
/>
</Form>
</Modal>
);

View File

@@ -33,7 +33,7 @@ export default function PaymentFormTotalPayments({ jobid }) {
{balance && (
<Statistic
title={t("payments.labels.balance")}
styles={{ value: { color: balance.getAmount() !== 0 ? "red" : "green" } }}
styles={{ content: { color: balance.getAmount() !== 0 ? "red" : "green" } }}
value={(balance && balance.toFormat()) || ""}
/>
)}

View File

@@ -108,7 +108,7 @@ export function PrintCenterJobsLabels({ jobId }) {
</Card>
);
return (
<Popover content={content} open={isModalVisible}>
<Popover content={content} open={isModalVisible} getPopupContainer={(trigger) => trigger.parentElement}>
<Button onClick={() => setIsModalVisible(true)}>{t("printcenter.jobs.labels.labels")}</Button>
</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;

View File

@@ -18,9 +18,10 @@ const VehicleSearchSelect = ({ value, onChange, onBlur, disabled, ref }) => {
SEARCH_VEHICLES_BY_ID_FOR_AUTOCOMPLETE
);
const executeSearch = (v) => {
if (v && v.variables?.search !== "" && v.variables.search.length >= 2) callSearch({ variables: v.variables });
const executeSearch = (variables) => {
if (variables?.search !== "" && variables?.search?.length >= 2) callSearch({ variables });
};
const debouncedExecuteSearch = _.debounce(executeSearch, 500);
const handleSearch = (value) => {

View File

@@ -510,7 +510,7 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
<Statistic
title={t("jobs.labels.pimraryamountpayable")}
styles={{
value: {
content: {
color: discrep.getAmount() >= 0 ? "green" : "red"
}
}}

View File

@@ -17,7 +17,10 @@ import "./tech.page.styles.scss";
import UpsellComponent, { upsellEnum } from "../../components/upsell/upsell.component.jsx";
import { lazyDev } from "../../utils/lazyWithPreload.jsx";
const TimeTicketModalContainer = lazyDev(() => import("../../components/time-ticket-modal/time-ticket-modal.container"));
const NoteUpsertModal = lazyDev(() => import("../../components/note-upsert-modal/note-upsert-modal.container.jsx"));
const TimeTicketModalContainer = lazyDev(
() => import("../../components/time-ticket-modal/time-ticket-modal.container")
);
const EmailOverlayContainer = lazyDev(() => import("../../components/email-overlay/email-overlay.container.jsx"));
const PrintCenterModalContainer = lazyDev(
() => import("../../components/print-center-modal/print-center-modal.container")
@@ -34,7 +37,9 @@ const TimeTicketModalTask = lazyDev(
const TechAssignedProdJobs = lazyDev(() => import("../tech-assigned-prod-jobs/tech-assigned-prod-jobs.component"));
const TechDispatchedParts = lazyDev(() => import("../tech-dispatched-parts/tech-dispatched-parts.page"));
const TaskUpsertModalContainer = lazyDev(() => import("../../components/task-upsert-modal/task-upsert-modal.container"));
const TaskUpsertModalContainer = lazyDev(
() => import("../../components/task-upsert-modal/task-upsert-modal.container")
);
const { Content } = Layout;
@@ -70,6 +75,8 @@ export function TechPage({ technician }) {
<TechHeader />
<TaskUpsertModalContainer />
<NoteUpsertModal />
<Content className="tech-content-container">
<ErrorBoundary>
<Suspense

View File

@@ -1288,6 +1288,7 @@
"vehicle": "Vehicle"
},
"labels": {
"apply": "Apply",
"actions": "Actions",
"areyousure": "Are you sure?",
"barcode": "Barcode",

View File

@@ -1288,6 +1288,7 @@
"vehicle": ""
},
"labels": {
"apply": "",
"actions": "Comportamiento",
"areyousure": "",
"barcode": "código de barras",

View File

@@ -1288,6 +1288,7 @@
"vehicle": ""
},
"labels": {
"apply": "",
"actions": "actes",
"areyousure": "",
"barcode": "code à barre",

View File

@@ -248,7 +248,8 @@ const client = new ApolloClient({
watchQuery: {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
errorPolicy: "ignore"
errorPolicy: "ignore",
notifyOnNetworkStatusChange: false
},
query: {
fetchPolicy: "network-only",

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -13,6 +13,9 @@ const { DiscountNotAlreadyCounted } = InstanceManager({
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
const isImEX = InstanceManager({ imex: true, rome: false });
const isRome = InstanceManager({ imex: false, rome: true });
async function JobCosting(req, res) {
const { jobid } = req.body;
@@ -266,9 +269,7 @@ function GenerateCostingData(job) {
);
const materialsHours = { mapaHrs: 0, mashHrs: 0 };
let mashOpCodes = InstanceManager({
rome: ParseCalopCode(job.materials["MASH"]?.cal_opcode)
});
let mashOpCodes = isRome && ParseCalopCode(job.materials["MASH"]?.cal_opcode);
let hasMapaLine = false;
let hasMashLine = false;
@@ -355,7 +356,7 @@ function GenerateCostingData(job) {
if (val.mod_lbr_ty === "LAR") {
materialsHours.mapaHrs += val.mod_lb_hrs || 0;
}
if (InstanceManager({ imex: true, rome: false })) {
if (isImEX) {
if (val.mod_lbr_ty !== "LAR") {
materialsHours.mashHrs += val.mod_lb_hrs || 0;
}
@@ -363,7 +364,7 @@ function GenerateCostingData(job) {
if (val.mod_lbr_ty !== "LAR" && mashOpCodes.includes(val.lbr_op)) {
materialsHours.mashHrs += val.mod_lb_hrs || 0;
}
if (val.manual_line === true && !mashOpCodes.includes(val.lbr_op) && val.mod_lbr_ty !== "LAR" ) {
if (val.manual_line === true && !mashOpCodes.includes(val.lbr_op) && val.mod_lbr_ty !== "LAR") {
materialsHours.mashHrs += val.mod_lb_hrs || 0;
}
}
@@ -525,14 +526,15 @@ function GenerateCostingData(job) {
}
}
if (InstanceManager({ rome: true })) {
if (isRome) {
if (convertedKey) {
const correspondingCiecaStlTotalLine = job.cieca_stl?.data.find(
(c) => c.ttl_typecd === convertedKey.toUpperCase()
);
if (
correspondingCiecaStlTotalLine &&
Math.abs(jobLineTotalsByProfitCenter.parts[key].getAmount() - correspondingCiecaStlTotalLine.ttl_amt * 100) > 1
Math.abs(jobLineTotalsByProfitCenter.parts[key].getAmount() - correspondingCiecaStlTotalLine.ttl_amt * 100) >
1
) {
jobLineTotalsByProfitCenter.parts[key] = jobLineTotalsByProfitCenter.parts[key].add(disc).add(markup);
}
@@ -545,7 +547,7 @@ function GenerateCostingData(job) {
if (
job.materials["MAPA"] &&
job.materials["MAPA"].cal_maxdlr !== undefined &&
job.materials["MAPA"].cal_maxdlr >= 0
(isRome ? job.materials["MAPA"].cal_maxdlr >= 0 : job.materials["MAPA"].cal_maxdlr > 0)
) {
//It has an upper threshhold.
threshold = Dinero({
@@ -595,7 +597,7 @@ function GenerateCostingData(job) {
if (
job.materials["MASH"] &&
job.materials["MASH"].cal_maxdlr !== undefined &&
job.materials["MASH"].cal_maxdlr >= 0
(isRome ? job.materials["MASH"].cal_maxdlr >= 0 : job.materials["MASH"].cal_maxdlr > 0)
) {
//It has an upper threshhold.
threshold = Dinero({
@@ -641,7 +643,7 @@ function GenerateCostingData(job) {
}
}
if (InstanceManager({ imex: false, rome: true })) {
if (isRome) {
const stlTowing = job.cieca_stl?.data.find((c) => c.ttl_type === "OTTW");
const stlStorage = job.cieca_stl?.data.find((c) => c.ttl_type === "OTST");