Compare commits

...

75 Commits

Author SHA1 Message Date
Dave
8422ea83ae feature/IO-3544-Ant-Select-Deprecation - Make cdklike-dms-post-form.jsx compatible with search on the payer name 2026-02-18 13:50:46 -05:00
Dave
1b84087ef8 feature/IO-3544-Ant-Select-Deprecation - Package Bumps 2026-02-18 12:31:55 -05:00
Dave
a9fdf3da18 Merge remote-tracking branch 'origin/master-AIO' into feature/IO-3544-Ant-Select-Deprecation 2026-02-18 12:25:42 -05:00
Dave Richer
fa2c729ac2 Merged in release/2026-02-27 (pull request #3008)
feature/IO-3523-Fortellis-Corrections-2 - Fix

Approved-by: Allan Carr
2026-02-18 00:45:08 +00:00
Dave Richer
95bb5b03c2 Merged in feature/IO-3523-Fortellis-Corrections-2 (pull request #3006)
feature/IO-3523-Fortellis-Corrections-2 - Fix
2026-02-17 21:06:45 +00:00
Dave
318482c195 feature/IO-3523-Fortellis-Corrections-2 - Fix 2026-02-17 16:06:12 -05:00
Dave Richer
eea9e8e2cc Merged in release/2026-02-13 (pull request #3003)
feature/IO-3558-Reynolds-Part-2 - Send zeroed out estimate line

Approved-by: Allan Carr
2026-02-13 16:48:27 +00:00
Dave Richer
cde12f9970 Merged in feature/IO-3558-Reynolds-Part-2 (pull request #3002)
feature/IO-3558-Reynolds-Part-2 - Send zeroed out estimate line
2026-02-13 16:14:39 +00:00
Dave
48def2b74d feature/IO-3558-Reynolds-Part-2 - Send zeroed out estimate line 2026-02-13 11:13:42 -05:00
Dave Richer
dde7a99956 Merged in release/2026-02-13 (pull request #2999)
Release/2026 02 13 into master-AIO - IO-3503, IO-3510, IO-3521, IO-3533, IO-3551, IO-3556, IO-3557, IO-3558
2026-02-13 00:33:37 +00:00
Dave Richer
49fb2caac0 Merged in release/2026-02-13 (pull request #3000)
Release/2026 02 13
2026-02-13 00:32:41 +00:00
Dave Richer
df964aa14e Merged in feature/IO-3558-Reynolds-Part-2 (pull request #2997)
feature/IO-3558-Reynolds-Part-2 - Prevent exporting without early ro / add a way to fake sconvert state in admin panel
2026-02-12 22:13:38 +00:00
Dave
7619360f37 feature/IO-3558-Reynolds-Part-2 - Prevent exporting without early ro / add a way to fake sconvert state in admin panel 2026-02-12 17:13:06 -05:00
Dave Richer
f15f371e86 Merged in feature/IO-3556-Chattr-Integration (pull request #2995)
6feature/IO-3556-Chattr-Integration - Move to BULLMQ stack
2026-02-12 18:00:27 +00:00
Dave
34fe0cc3bf 6feature/IO-3556-Chattr-Integration - Move to BULLMQ stack 2026-02-12 12:56:17 -05:00
Allan Carr
7acaefb5c5 Merged in feature/IO-3557-Reynold-DMS-Info (pull request #2993)
IO-3557 Reynolds DMS Info

Approved-by: Dave Richer
2026-02-12 14:26:38 +00:00
Allan Carr
ab02da47a2 IO-3557 Reynolds DMS Info
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-11 18:39:36 -08:00
Dave
673670eeb4 Merge remote-tracking branch 'origin/release/2026-02-13' into feature/IO-3544-Ant-Select-Deprecation
# Conflicts:
#	client/src/components/jobs-convert-button/jobs-convert-button.component.jsx
2026-02-11 18:23:02 -05:00
Dave Richer
2a7dec90d5 Merged in feature/IO-3558-Reynolds-Part-2 (pull request #2990)
feature/IO-3558-Reynolds-Part-2 - Admin Panel
2026-02-11 23:13:22 +00:00
Dave
6e0b1f65a7 feature/IO-3558-Reynolds-Part-2 - Admin Panel 2026-02-11 18:12:56 -05:00
Dave Richer
8671d1254d Merged in feature/IO-3558-Reynolds-Part-2 (pull request #2987)
Feature/IO-3558 Reynolds Part 2
2026-02-11 22:25:29 +00:00
Dave
0ea254ed4e feature/IO-3558-Reynolds-Part-2 - Admin Panel 2026-02-11 16:57:13 -05:00
Dave
331dcfc063 feature/IO-3558-Reynolds-Part-2 - Admin Panel 2026-02-11 15:47:46 -05:00
Dave
c46804cfdf feature/IO-3558-Reynolds-Part-2 - Initial 2026-02-11 15:33:59 -05:00
Dave Richer
484d09d635 Merged in feature/IO-3556-Chattr-Integration (pull request #2986)
feature/IO-3556-Chattr-Integration - Switch Consent to true
2026-02-11 16:59:51 +00:00
Dave
188a7b47b1 feature/IO-3556-Chattr-Integration - Switch Consent to true 2026-02-11 11:59:16 -05:00
Dave Richer
a6ca93f482 Merged in feature/IO-3556-Chattr-Integration (pull request #2984)
feature/IO-3556-Chattr-Integration - Retry beef up / tweeks
2026-02-11 16:40:26 +00:00
Dave
d08bfc61cd feature/IO-3556-Chattr-Integration - Retry beef up / tweeks 2026-02-11 11:37:47 -05:00
Dave
e6100851b8 feature/IO-3544-Ant-Select-Deprecation - Packages 2026-02-11 10:14:55 -05:00
Dave
e9795072d5 Merge remote-tracking branch 'origin/release/2026-02-13' into feature/IO-3544-Ant-Select-Deprecation 2026-02-11 10:05:58 -05:00
Dave Richer
9b4de1645e Merged in feature/IO-3556-Chattr-Integration (pull request #2982)
fix
2026-02-11 14:54:33 +00:00
Dave
503c217c99 fix 2026-02-11 09:52:22 -05:00
Dave Richer
2333067e02 Merged in hotfix/2026-02-10-backend (pull request #2981)
hotfix/2026-02-10-backend - Move chatter DB stuff over
2026-02-10 23:13:50 +00:00
Dave Richer
953172493e Merged in feature/IO-3556-Chattr-Integration (pull request #2979)
hotfix/2026-02-10-backend - Move chatter DB stuff over
2026-02-10 23:05:13 +00:00
Dave
b444639fca Merge branch 'hotfix/2026-02-10-backend' into feature/IO-3556-Chattr-Integration 2026-02-10 18:03:37 -05:00
Dave
6ee7e56b9b hotfix/2026-02-10-backend - Move chatter DB stuff over 2026-02-10 17:58:26 -05:00
Dave Richer
ffd5acb21a Merged in feature/IO-3556-Chattr-Integration (pull request #2976)
Feature/IO-3556 Chattr Integration
2026-02-10 22:28:54 +00:00
Dave
0340ca5fcc feature/IO-3556-Chattr-Integration - Add in Redis caching for Chatter 2026-02-10 17:25:59 -05:00
Dave
1b2fc8b114 feature/IO-3556-Chattr-Integration 2026-02-10 17:17:44 -05:00
Dave
3745d7a414 feature/IO-3556-Chattr-Integration 2026-02-10 12:48:48 -05:00
Allan Carr
a0efac9bd8 Merged in feature/IO-3551-Export-Reports-Return-Data (pull request #2974)
IO-3551 Export Reports Return Data

Approved-by: Dave Richer
2026-02-09 15:34:22 +00:00
Allan Carr
17a772563c Merged in feature/IO-3521-Pagination-Export-Screens (pull request #2973)
IO-3521 Pagination Disable Show Size Changer

Approved-by: Dave Richer
2026-02-09 15:34:01 +00:00
Allan Carr
b1ce356bd8 IO-3551 Export Reports Return Data
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-06 20:45:44 -08:00
Allan Carr
9818cac30e IO-3521 Pagination Disable Show Size Changer
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-06 16:10:03 -08:00
Allan Carr
171277630e Merged in feature/IO-3503-Job-Costing-MASH-MAPA-Fix (pull request #2971)
IO-3503 Job Costing Fix

Approved-by: Dave Richer
2026-02-06 23:14:23 +00:00
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
Dave Richer
773f3d4c84 Merged in release/2026-02-13 (pull request #2969)
IO-3533 Actual Cost Click to Focus
2026-02-06 19:54:36 +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
40d5e02415 feature/IO-3feature/IO-3544-Ant-Select-Deprecation: Dep Bumps 2026-02-05 13:55:50 -05:00
Dave
5b891281d1 Merge remote-tracking branch 'origin/release/2026-02-13' into feature/IO-3544-Ant-Select-Deprecation
# Conflicts:
#	client/src/components/bill-form/bill-form.lines.component.jsx
2026-02-03 16:52:36 -05:00
Dave Richer
56559dd3ff Merged in hotfix/2026-02-03 (pull request #2965)
Hotfix/2026 02 03

Approved-by: Allan Carr
2026-02-03 21:49:28 +00: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 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
71043313d6 feature/IO-3544-Ant-Select-Deprecation - Fix filtering 2026-02-03 12:40:01 -05:00
Dave
c9620a3f6f feature/IO-3544-Ant-Select-Deprecation - Fix filtering 2026-02-03 11:10:44 -05:00
Dave Richer
cfbd6f93c3 Merged in master-AIO (pull request #2954)
Master AIO
2026-02-03 15:52:51 +00:00
Dave
cdfae5a429 feature/IO-3544-Ant-Select-Deprecation - finish 2026-02-03 10:51:14 -05: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
105 changed files with 5918 additions and 3362 deletions

View File

@@ -13,4 +13,5 @@
.env.development.local
.env.test.local
.env.production.local
bodyshop_translations.babel
.env.localstack.docker
bodyshop_translations.babel

949
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,41 +8,45 @@
"private": true,
"proxy": "http://localhost:4000",
"dependencies": {
"@amplitude/analytics-browser": "^2.34.0",
"@amplitude/analytics-browser": "^2.35.0",
"@ant-design/pro-layout": "^7.22.6",
"@apollo/client": "^4.1.3",
"@apollo/client": "^4.1.4",
"@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",
"@firebase/app": "^0.14.7",
"@firebase/app": "^0.14.8",
"@firebase/auth": "^1.12.0",
"@firebase/firestore": "^4.10.0",
"@firebase/firestore": "^4.11.0",
"@firebase/messaging": "^0.12.22",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.11.2",
"@sentry/cli": "^3.1.0",
"@sentry/react": "^10.38.0",
"@sentry/vite-plugin": "^4.8.0",
"@sentry/cli": "^3.2.0",
"@sentry/react": "^10.39.0",
"@sentry/vite-plugin": "^4.9.1",
"@splitsoftware/splitio-react": "^2.6.1",
"@tanem/react-nprogress": "^5.0.58",
"antd": "^6.2.2",
"@tanem/react-nprogress": "^5.0.63",
"antd": "^6.3.0",
"apollo-link-logger": "^3.0.0",
"autosize": "^6.0.1",
"axios": "^1.13.4",
"axios": "^1.13.5",
"classnames": "^2.5.1",
"css-box-model": "^1.2.1",
"dayjs": "^1.11.19",
"dayjs-business-days2": "^1.3.2",
"dinero.js": "^1.9.1",
"dotenv": "^17.2.3",
"dotenv": "^17.3.1",
"env-cmd": "^11.0.0",
"exifr": "^7.1.3",
"graphql": "^16.12.0",
"graphql-ws": "^6.0.7",
"i18next": "^25.8.0",
"i18next-browser-languagedetector": "^8.2.0",
"i18next": "^25.8.11",
"i18next-browser-languagedetector": "^8.2.1",
"immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.36",
"libphonenumber-js": "^1.12.37",
"lightningcss": "^1.31.1",
"logrocket": "^12.0.0",
"markerjs2": "^2.32.7",
@@ -50,7 +54,7 @@
"normalize-url": "^8.1.1",
"object-hash": "^3.0.0",
"phone": "^3.1.70",
"posthog-js": "^1.336.4",
"posthog-js": "^1.351.1",
"prop-types": "^15.8.1",
"query-string": "^9.3.1",
"raf-schd": "^4.0.3",
@@ -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",
@@ -84,7 +87,7 @@
"rxjs": "^7.8.2",
"sass": "^1.97.3",
"socket.io-client": "^4.8.3",
"styled-components": "^6.3.8",
"styled-components": "^6.3.10",
"vite-plugin-ejs": "^1.7.0",
"web-vitals": "^5.1.0"
},
@@ -141,11 +144,11 @@
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.39.2",
"@playwright/test": "^1.58.0",
"@playwright/test": "^1.58.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@vitejs/plugin-react": "^5.1.2",
"@vitejs/plugin-react": "^5.1.4",
"babel-plugin-react-compiler": "^1.0.0",
"browserslist": "^4.28.1",
"browserslist-to-esbuild": "^2.1.1",
@@ -153,16 +156,16 @@
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"globals": "^17.2.0",
"jsdom": "^27.4.0",
"globals": "^17.3.0",
"jsdom": "^28.1.0",
"memfs": "^4.56.10",
"os-browserify": "^0.3.0",
"playwright": "^1.58.0",
"playwright": "^1.58.2",
"react-error-overlay": "^6.1.0",
"redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.3",
"vite": "^7.3.1",
"vite-plugin-babel": "^1.4.1",
"vite-plugin-babel": "^1.5.1",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-node-polyfills": "^0.25.0",
"vite-plugin-pwa": "^1.2.0",

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

@@ -182,7 +182,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, bills, ref
<Table
loading={loading}
dataSource={dataSource}
pagination={{ placement: "top", pageSize: exportPageLimit }}
pagination={{ placement: "top", pageSize: exportPageLimit, showSizeChanger: false }}
columns={columns}
rowKey="id"
onChange={handleTableChange}

View File

@@ -195,7 +195,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, payments,
<Table
loading={loading}
dataSource={dataSource}
pagination={{ placement: "top", pageSize: exportPageLimit }}
pagination={{ placement: "top", pageSize: exportPageLimit, showSizeChanger: false }}
columns={columns}
rowKey="id"
onChange={handleTableChange}

View File

@@ -212,7 +212,7 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
<Table
loading={loading}
dataSource={dataSource}
pagination={{ placement: "top", pageSize: exportPageLimit }}
pagination={{ placement: "top", pageSize: exportPageLimit, showSizeChanger: false }}
columns={columns}
rowKey="id"
onChange={handleTableChange}

View File

@@ -29,19 +29,18 @@ export function AllocationsAssignmentComponent({
<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 }}
placeholder="Select a person"
onChange={onChange}
>
{bodyshop.employees.map((emp) => (
<Select.Option value={emp.id} key={emp.id}>
{`${emp.first_name} ${emp.last_name}`}
</Select.Option>
))}
</Select>
options={bodyshop.employees.map((emp) => ({
value: emp.id,
key: emp.id,
label: `${emp.first_name} ${emp.last_name}`
}))}
/>
<InputNumber
defaultValue={assignment.hours}
placeholder={t("joblines.fields.mod_lb_hrs")}

View File

@@ -31,19 +31,17 @@ export default connect(
<div>
<Select
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 }}
placeholder="Select a person"
onChange={onChange}
>
{bodyshop.employees.map((emp) => (
<Select.Option value={emp.id} key={emp.id}>
{`${emp.first_name} ${emp.last_name}`}
</Select.Option>
))}
</Select>
options={bodyshop.employees.map((emp) => ({
value: emp.id,
label: `${emp.first_name} ${emp.last_name}`
}))}
/>
<Button type="primary" disabled={!assignment.employeeid} onClick={handleAssignment}>
Assign

View File

@@ -99,20 +99,22 @@ export function BillFormItemsExtendedFormItem({
}}
</Form.Item>
<Form.Item label={t("billlines.fields.cost_center")} name={["billlineskeys", record.id, "cost_center"]}>
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled}>
{bodyshopHasDmsKey(bodyshop)
? CiecaSelect(true, false)
: responsibilityCenters.costs.map((item) => <Select.Option key={item.name}>{item.name}</Select.Option>)}
</Select>
<Select
showSearch
style={{ minWidth: "3rem" }}
disabled={disabled}
options={
bodyshopHasDmsKey(bodyshop)
? CiecaSelect(true, false)
: responsibilityCenters.costs.map((item) => ({ value: item.name, label: item.name }))
}
/>
</Form.Item>
<Form.Item label={t("billlines.fields.location")} name={["billlineskeys", record.id, "location"]}>
<Select disabled={disabled}>
{bodyshop.md_parts_locations.map((loc, idx) => (
<Select.Option key={idx} value={loc}>
{loc}
</Select.Option>
))}
</Select>
<Select
disabled={disabled}
options={bodyshop.md_parts_locations.map((loc) => ({ value: loc, label: loc }))}
/>
</Form.Item>
<Form.Item
label={t("billlines.fields.deductedfromlbr")}
@@ -136,22 +138,10 @@ export function BillFormItemsExtendedFormItem({
]}
name={["billlineskeys", record.id, "lbr_adjustment", "mod_lbr_ty"]}
>
<Select allowClear>
<Select.Option value="LAA">{t("joblines.fields.lbr_types.LAA")}</Select.Option>
<Select.Option value="LAB">{t("joblines.fields.lbr_types.LAB")}</Select.Option>
<Select.Option value="LAD">{t("joblines.fields.lbr_types.LAD")}</Select.Option>
<Select.Option value="LAE">{t("joblines.fields.lbr_types.LAE")}</Select.Option>
<Select.Option value="LAF">{t("joblines.fields.lbr_types.LAF")}</Select.Option>
<Select.Option value="LAG">{t("joblines.fields.lbr_types.LAG")}</Select.Option>
<Select.Option value="LAM">{t("joblines.fields.lbr_types.LAM")}</Select.Option>
<Select.Option value="LAR">{t("joblines.fields.lbr_types.LAR")}</Select.Option>
<Select.Option value="LAS">{t("joblines.fields.lbr_types.LAS")}</Select.Option>
<Select.Option value="LAU">{t("joblines.fields.lbr_types.LAU")}</Select.Option>
<Select.Option value="LA1">{t("joblines.fields.lbr_types.LA1")}</Select.Option>
<Select.Option value="LA2">{t("joblines.fields.lbr_types.LA2")}</Select.Option>
<Select.Option value="LA3">{t("joblines.fields.lbr_types.LA3")}</Select.Option>
<Select.Option value="LA4">{t("joblines.fields.lbr_types.LA4")}</Select.Option>
</Select>
<Select
allowClear
options={CiecaSelect(false, true)}
/>
</Form.Item>
<Form.Item
label={t("jobs.labels.adjustmentrate")}

View File

@@ -328,13 +328,12 @@ export function BillFormComponent({
</Form.Item>
{!billEdit && (
<Form.Item label={t("bills.fields.allpartslocation")} name="location">
<Select style={{ width: "10rem" }} disabled={disabled} allowClear>
{bodyshop.md_parts_locations.map((loc, idx) => (
<Select.Option key={idx} value={loc}>
{loc}
</Select.Option>
))}
</Select>
<Select
style={{ width: "10rem" }}
disabled={disabled}
allowClear
options={bodyshop.md_parts_locations.map((loc) => ({ value: loc, label: loc }))}
/>
</Form.Item>
)}
</LayoutFormRow>

View File

@@ -336,7 +336,7 @@ export function BillEnterModalLinesComponent({
controls={false}
tabIndex={0}
style={{ width: "100%", height: CONTROL_HEIGHT }}
// NOTE: No auto-fill on focus/blur; only triggered from Retail on Tab
onFocus={() => autofillActualCost(index)}
/>
</Form.Item>
</div>
@@ -399,11 +399,17 @@ export function BillEnterModalLinesComponent({
rules: [{ required: true }]
}),
formInput: () => (
<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>)}
</Select>
<Select
showSearch
style={{ minWidth: "3rem" }}
disabled={disabled}
tabIndex={0}
options={
bodyshopHasDmsKey(bodyshop)
? CiecaSelect(true, false)
: responsibilityCenters.costs.map((item) => ({ value: item.name, label: item.name }))
}
/>
)
},
...(billEdit
@@ -419,13 +425,11 @@ export function BillEnterModalLinesComponent({
name: [field.name, "location"]
}),
formInput: () => (
<Select disabled={disabled} tabIndex={0}>
{bodyshop.md_parts_locations.map((loc, idx) => (
<Select.Option key={idx} value={loc}>
{loc}
</Select.Option>
))}
</Select>
<Select
disabled={disabled}
tabIndex={0}
options={bodyshop.md_parts_locations.map((loc) => ({ value: loc, label: loc }))}
/>
)
}
]),
@@ -466,22 +470,10 @@ export function BillEnterModalLinesComponent({
rules={[{ required: true }]}
name={[record.name, "lbr_adjustment", "mod_lbr_ty"]}
>
<Select allowClear>
<Select.Option value="LAA">{t("joblines.fields.lbr_types.LAA")}</Select.Option>
<Select.Option value="LAB">{t("joblines.fields.lbr_types.LAB")}</Select.Option>
<Select.Option value="LAD">{t("joblines.fields.lbr_types.LAD")}</Select.Option>
<Select.Option value="LAE">{t("joblines.fields.lbr_types.LAE")}</Select.Option>
<Select.Option value="LAF">{t("joblines.fields.lbr_types.LAF")}</Select.Option>
<Select.Option value="LAG">{t("joblines.fields.lbr_types.LAG")}</Select.Option>
<Select.Option value="LAM">{t("joblines.fields.lbr_types.LAM")}</Select.Option>
<Select.Option value="LAR">{t("joblines.fields.lbr_types.LAR")}</Select.Option>
<Select.Option value="LAS">{t("joblines.fields.lbr_types.LAS")}</Select.Option>
<Select.Option value="LAU">{t("joblines.fields.lbr_types.LAU")}</Select.Option>
<Select.Option value="LA1">{t("joblines.fields.lbr_types.LA1")}</Select.Option>
<Select.Option value="LA2">{t("joblines.fields.lbr_types.LA2")}</Select.Option>
<Select.Option value="LA3">{t("joblines.fields.lbr_types.LA3")}</Select.Option>
<Select.Option value="LA4">{t("joblines.fields.lbr_types.LA4")}</Select.Option>
</Select>
<Select
allowClear
options={CiecaSelect(false, true)}
/>
</Form.Item>
{Enhanced_Payroll.treatment === "on" ? (

View File

@@ -19,13 +19,12 @@ export default function ChatTagRoComponent({ roOptions, loading, handleSearch, h
placeholder={t("general.labels.search")}
onSelect={handleInsertTag}
notFoundContent={loading ? <LoadingOutlined /> : <Empty />}
>
{roOptions.map((item, idx) => (
<Select.Option key={item.id || idx}>
{` ${item.ro_number || ""} | ${OwnerNameDisplayFunction(item)}`}
</Select.Option>
))}
</Select>
options={roOptions.map((item, idx) => ({
key: item.id || idx,
value: item.id || idx,
label: ` ${item.ro_number || ""} | ${OwnerNameDisplayFunction(item)}`
}))}
/>
</div>
{loading ? <LoadingOutlined /> : null}

View File

@@ -309,13 +309,13 @@ export function ContractConvertToRo({ bodyshop, currentUser, contract, disabled
}
]}
>
<Select>
{bodyshop.md_ins_cos.map((s) => (
<Select.Option key={s.name} value={s.name}>
{s.name}
</Select.Option>
))}
</Select>
<Select
options={bodyshop.md_ins_cos.map((s) => ({
key: s.name,
value: s.name,
label: s.name
}))}
/>
</Form.Item>
<Form.Item
name={"class"}
@@ -327,13 +327,13 @@ export function ContractConvertToRo({ bodyshop, currentUser, contract, disabled
}
]}
>
<Select>
{bodyshop.md_classes.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
<Select
options={bodyshop.md_classes.map((s) => ({
key: s,
value: s,
label: s
}))}
/>
</Form.Item>
<Form.Item
label={t("contracts.labels.convertform.applycleanupcharge")}

View File

@@ -2,8 +2,6 @@ import { useEffect, useState } from "react";
import { Select } from "antd";
import { useTranslation } from "react-i18next";
const { Option } = Select;
const ContractStatusComponent = ({ value, onChange, ref }) => {
const [option, setOption] = useState(value);
const { t } = useTranslation();
@@ -15,11 +13,17 @@ const ContractStatusComponent = ({ value, onChange, ref }) => {
}, [value, option, onChange]);
return (
<Select ref={ref} value={option} style={{ width: 100 }} onChange={setOption}>
<Option value="contracts.status.new">{t("contracts.status.new")}</Option>
<Option value="contracts.status.out">{t("contracts.status.out")}</Option>
<Option value="contracts.status.returned">{t("contracts.status.out")}</Option>
</Select>
<Select
ref={ref}
value={option}
style={{ width: 100 }}
onChange={setOption}
options={[
{ value: "contracts.status.new", label: t("contracts.status.new") },
{ value: "contracts.status.out", label: t("contracts.status.out") },
{ value: "contracts.status.returned", label: t("contracts.status.out") }
]}
/>
);
};

View File

@@ -2,8 +2,6 @@ import { Select } from "antd";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
const { Option } = Select;
const CourtesyCarReadinessComponent = ({ value, onChange, ref }) => {
const [option, setOption] = useState(value);
const { t } = useTranslation();
@@ -23,10 +21,11 @@ const CourtesyCarReadinessComponent = ({ value, onChange, ref }) => {
width: 100
}}
onChange={setOption}
>
<Option value="courtesycars.readiness.ready">{t("courtesycars.readiness.ready")}</Option>
<Option value="courtesycars.readiness.notready">{t("courtesycars.readiness.notready")}</Option>
</Select>
options={[
{ value: "courtesycars.readiness.ready", label: t("courtesycars.readiness.ready") },
{ value: "courtesycars.readiness.notready", label: t("courtesycars.readiness.notready") }
]}
/>
);
};
export default CourtesyCarReadinessComponent;

View File

@@ -2,8 +2,6 @@ import { useEffect, useState } from "react";
import { Select } from "antd";
import { useTranslation } from "react-i18next";
const { Option } = Select;
const CourtesyCarStatusComponent = ({ value, onChange, ref }) => {
const [option, setOption] = useState(value);
const { t } = useTranslation();
@@ -22,14 +20,15 @@ const CourtesyCarStatusComponent = ({ value, onChange, ref }) => {
width: 100
}}
onChange={setOption}
>
<Option value="courtesycars.status.in">{t("courtesycars.status.in")}</Option>
<Option value="courtesycars.status.inservice">{t("courtesycars.status.inservice")}</Option>
<Option value="courtesycars.status.out">{t("courtesycars.status.out")}</Option>
<Option value="courtesycars.status.sold">{t("courtesycars.status.sold")}</Option>
<Option value="courtesycars.status.leasereturn">{t("courtesycars.status.leasereturn")}</Option>
<Option value="courtesycars.status.unavailable">{t("courtesycars.status.unavailable")}</Option>
</Select>
options={[
{ value: "courtesycars.status.in", label: t("courtesycars.status.in") },
{ value: "courtesycars.status.inservice", label: t("courtesycars.status.inservice") },
{ value: "courtesycars.status.out", label: t("courtesycars.status.out") },
{ value: "courtesycars.status.sold", label: t("courtesycars.status.sold") },
{ value: "courtesycars.status.leasereturn", label: t("courtesycars.status.leasereturn") },
{ value: "courtesycars.status.unavailable", label: t("courtesycars.status.unavailable") }
]}
/>
);
};
export default CourtesyCarStatusComponent;

View File

@@ -23,13 +23,13 @@ export default connect(mapStateToProps, mapDispatchToProps)(DmsCustomerSelector)
* @constructor
*/
export function DmsCustomerSelector(props) {
const { bodyshop, jobid, socket, rrOptions = {} } = props;
const { bodyshop, jobid, job, socket, rrOptions = {} } = props;
// Centralized "mode" (provider + transport)
const mode = props.mode;
// Stable base props for children
const base = useMemo(() => ({ bodyshop, jobid, socket }), [bodyshop, jobid, socket]);
const base = useMemo(() => ({ bodyshop, jobid, job, socket }), [bodyshop, jobid, job, socket]);
switch (mode) {
case DMS_MAP.reynolds: {

View File

@@ -1,4 +1,4 @@
import { Alert, Button, Checkbox, Col, message, Space, Table } from "antd";
import { Alert, Button, Checkbox, message, Modal, Space, Table } from "antd";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { alphaSort } from "../../utils/sorters";
@@ -47,6 +47,7 @@ const rrAddressToString = (addr) => {
export default function RRCustomerSelector({
jobid,
socket,
job,
rrOpenRoLimit = false,
onRrOpenRoFinished,
rrValidationPending = false,
@@ -59,15 +60,26 @@ export default function RRCustomerSelector({
const [refreshing, setRefreshing] = useState(false);
// Show dialog automatically when validation is pending
// BUT: skip this for early RO flow (job already has dms_id)
useEffect(() => {
if (rrValidationPending) setOpen(true);
}, [rrValidationPending]);
if (rrValidationPending && !job?.dms_id) {
setOpen(true);
}
}, [rrValidationPending, job?.dms_id]);
// Listen for RR customer selection list
useEffect(() => {
if (!socket) return;
const handleRrSelectCustomer = (list) => {
const normalized = normalizeRrList(list);
// If list is empty, it means early RO exists and customer selection should be skipped
// Don't open the modal in this case
if (normalized.length === 0) {
setRefreshing(false);
return;
}
setOpen(true);
setCustomerList(normalized);
const firstOwner = normalized.find((r) => r.vinOwner)?.custNo;
@@ -127,6 +139,10 @@ export default function RRCustomerSelector({
});
};
const handleClose = () => {
setOpen(false);
};
const refreshRrSearch = () => {
setRefreshing(true);
const to = setTimeout(() => setRefreshing(false), 12000);
@@ -141,8 +157,6 @@ export default function RRCustomerSelector({
socket.emit("rr-export-job", { jobId: jobid });
};
if (!open) return null;
const columns = [
{ title: t("jobs.fields.dms.id"), dataIndex: "custNo", key: "custNo" },
{
@@ -169,8 +183,45 @@ export default function RRCustomerSelector({
return !rrOwnerSet.has(String(record.custNo));
};
// For early RO flow: show validation banner even when modal is closed
if (!open) {
if (rrValidationPending && job?.dms_id) {
return (
<div style={{ marginBottom: 16 }}>
<Alert
type="info"
showIcon
title="Complete Validation in Reynolds"
description={
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div>
We created the Repair Order. Please validate the totals and taxes in the DMS system. When done,
click <strong>Finished</strong> to finalize and mark this export as complete.
</div>
<div>
<Space>
<Button type="primary" onClick={onValidationFinished}>
Finished
</Button>
</Space>
</div>
</div>
}
/>
</div>
);
}
return null;
}
return (
<Col span={24}>
<Modal
open={open}
onCancel={handleClose}
footer={null}
width={800}
title={t("dms.selectCustomer")}
>
<Table
title={() => (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
@@ -196,8 +247,8 @@ export default function RRCustomerSelector({
/>
)}
{/* Validation step banner */}
{rrValidationPending && (
{/* Validation step banner - only show for NON-early RO flow (legacy) */}
{rrValidationPending && !job?.dms_id && (
<Alert
type="info"
showIcon
@@ -262,6 +313,6 @@ export default function RRCustomerSelector({
getCheckboxProps: (record) => ({ disabled: rrDisableRow(record) })
}}
/>
</Col>
</Modal>
);
}

View File

@@ -69,7 +69,7 @@ export function DmsLogEvents({
return {
key: idx,
color: logLevelColor(level),
children: (
content: (
<Space orientation="vertical" size={4} style={{ display: "flex" }}>
{/* Row 1: summary + inline "Details" toggle */}
<Space wrap align="start">
@@ -113,7 +113,7 @@ export function DmsLogEvents({
[logs, openSet, colorizeJson, isDarkMode, showDetails]
);
return <Timeline pending reverse items={items} />;
return <Timeline reverse items={items} />;
}
/**

View File

@@ -272,11 +272,19 @@ export default function CdkLikePostForm({ bodyshop, socket, job, logsRef, mode,
name={[field.name, "name"]}
rules={[{ required: true }]}
>
<Select style={{ width: "100%" }} onSelect={(value) => handlePayerSelect(value, index)}>
{bodyshop.cdk_configuration?.payers?.map((payer) => (
<Select.Option key={payer.name}>{payer.name}</Select.Option>
))}
</Select>
<Select
showSearch={{
optionFilterProp: "label",
filterOption: (input, option) => option.label.toLowerCase().includes(input.toLowerCase())
}}
style={{ width: "100%" }}
onSelect={(value) => handlePayerSelect(value, index)}
options={bodyshop.cdk_configuration?.payers?.map((payer) => ({
key: payer.name,
value: payer.name,
label: payer.name
}))}
/>
</Form.Item>
</Col>

View File

@@ -208,8 +208,18 @@ export default function RRPostForm({
});
};
// Check if early RO was created (job has all early RO fields)
const hasEarlyRO = !!(job?.dms_id && job?.dms_customer_id && job?.dms_advisor_id);
return (
<Card title={t("jobs.labels.dms.postingform")}>
{hasEarlyRO && (
<Typography.Paragraph type="success" strong style={{ marginBottom: 16 }}>
{t("jobs.labels.dms.earlyro.created")} {job.dms_id}
<br />
<Typography.Text type="secondary">{t("jobs.labels.dms.earlyro.willupdate")}</Typography.Text>
</Typography.Paragraph>
)}
<Form
form={form}
layout="vertical"
@@ -218,96 +228,96 @@ export default function RRPostForm({
initialValues={initialValues}
>
<Row gutter={[16, 12]} align="bottom">
{/* Advisor + inline Refresh */}
<Col xs={24} sm={24} md={12} lg={8}>
<Form.Item label={t("jobs.fields.dms.advisor")} required>
<Space.Compact block>
<Form.Item
name="advisorNo"
noStyle
rules={[{ required: true, message: t("general.validation.required") }]}
>
<Select
style={{ flex: 1 }}
loading={advLoading}
allowClear
placeholder={t("general.actions.select", "Select...")}
popupMatchSelectWidth
options={advisors
.map((a) => {
const value = getAdvisorNumber(a);
if (value == null) return null;
return { value: String(value), label: getAdvisorLabel(a) || String(value) };
})
.filter(Boolean)}
notFoundContent={advLoading ? t("general.labels.loading") : t("general.labels.none")}
/>
</Form.Item>
<Tooltip title={t("general.actions.refresh")}>
<Button
aria-label={t("general.actions.refresh")}
icon={<ReloadOutlined />}
onClick={() => fetchRrAdvisors(true)}
loading={advLoading}
/>
</Tooltip>
</Space.Compact>
</Form.Item>
</Col>
{/* RR OpCode (prefix / base / suffix) */}
<Col xs={24} sm={12} md={12} lg={8}>
<Form.Item
required
label={
<Space size="small" align="center">
{t("jobs.fields.dms.rr_opcode", "RR OpCode")}
{isCustomOpCode && (
{/* Advisor + inline Refresh - Only show if no early RO */}
{!hasEarlyRO && (
<Col xs={24} sm={24} md={12} lg={8}>
<Form.Item label={t("jobs.fields.dms.advisor")} required>
<Space.Compact block>
<Form.Item
name="advisorNo"
noStyle
rules={[{ required: true, message: t("general.validation.required") }]}
>
<Select
style={{ flex: 1 }}
loading={advLoading}
allowClear
placeholder={t("general.actions.select", "Select...")}
popupMatchSelectWidth
options={advisors
.map((a) => {
const value = getAdvisorNumber(a);
if (value == null) return null;
return { value: String(value), label: getAdvisorLabel(a) || String(value) };
})
.filter(Boolean)}
notFoundContent={advLoading ? t("general.labels.loading") : t("general.labels.none")}
/>
</Form.Item>
<Tooltip title={t("general.actions.refresh")}>
<Button
type="link"
size="small"
icon={<RollbackOutlined />}
onClick={handleResetOpCode}
style={{ padding: 0 }}
>
{t("jobs.fields.dms.rr_opcode_reset", "Reset")}
</Button>
)}
</Space>
}
>
<Space.Compact block>
<Form.Item name="opPrefix" noStyle>
<Input
allowClear
maxLength={4}
style={{ width: "30%" }}
placeholder={t("jobs.fields.dms.rr_opcode_prefix", "Prefix")}
/>
</Form.Item>
<Form.Item
name="opBase"
noStyle
rules={[{ required: true, message: t("general.validation.required") }]}
>
<Input
allowClear
maxLength={10}
style={{ width: "40%" }}
placeholder={t("jobs.fields.dms.rr_opcode_base", "Base")}
/>
</Form.Item>
<Form.Item name="opSuffix" noStyle>
<Input
allowClear
maxLength={4}
style={{ width: "30%" }}
placeholder={t("jobs.fields.dms.rr_opcode_suffix", "Suffix")}
/>
</Form.Item>
</Space.Compact>
</Form.Item>
</Col>
aria-label={t("general.actions.refresh")}
icon={<ReloadOutlined />}
onClick={() => fetchRrAdvisors(true)}
loading={advLoading}
/>
</Tooltip>
</Space.Compact>
</Form.Item>
</Col>
)}
{/* RR OpCode (prefix / base / suffix) - Only show if no early RO */}
{!hasEarlyRO && (
<Col xs={24} sm={12} md={12} lg={8}>
<Form.Item
required
label={
<Space size="small" align="center">
{t("jobs.fields.dms.rr_opcode", "RR OpCode")}
{isCustomOpCode && (
<Button
type="link"
size="small"
icon={<RollbackOutlined />}
onClick={handleResetOpCode}
style={{ padding: 0 }}
>
{t("jobs.fields.dms.rr_opcode_reset", "Reset")}
</Button>
)}
</Space>
}
>
<Space.Compact block>
<Form.Item name="opPrefix" noStyle>
<Input
allowClear
maxLength={4}
style={{ width: "30%" }}
placeholder={t("jobs.fields.dms.rr_opcode_prefix", "Prefix")}
/>
</Form.Item>
<Form.Item name="opBase" noStyle rules={[{ required: true }]}>
<Input
allowClear
maxLength={10}
style={{ width: "40%" }}
placeholder={t("jobs.fields.dms.rr_opcode_base", "Base")}
/>
</Form.Item>
<Form.Item name="opSuffix" noStyle>
<Input
allowClear
maxLength={4}
style={{ width: "30%" }}
placeholder={t("jobs.fields.dms.rr_opcode_suffix", "Suffix")}
/>
</Form.Item>
</Space.Compact>
</Form.Item>
</Col>
)}
<Col xs={12} sm={8} md={6} lg={4}>
<Form.Item name="kmin" label={t("jobs.fields.kmin")} initialValue={job?.kmin} rules={[{ required: true }]}>
@@ -355,13 +365,14 @@ export default function RRPostForm({
{/* Validation */}
<Form.Item shouldUpdate>
{() => {
const advisorOk = !!form.getFieldValue("advisorNo");
// When early RO exists, advisor is already set, so we don't need to validate it
const advisorOk = hasEarlyRO ? true : !!form.getFieldValue("advisorNo");
return (
<Space size="large" wrap align="center">
<Statistic title={t("jobs.labels.subtotal")} value={totals.totalSale.toFormat()} />
<Typography.Title>=</Typography.Title>
<Button disabled={!advisorOk} htmlType="submit">
{t("jobs.actions.dms.post")}
<Button disabled={!advisorOk} htmlType="submit" type={hasEarlyRO ? "default" : "primary"}>
{hasEarlyRO ? t("jobs.actions.dms.update_ro") : t("jobs.actions.dms.post")}
</Button>
</Space>
);

View File

@@ -0,0 +1,367 @@
import { ReloadOutlined } from "@ant-design/icons";
import { Alert, Button, Form, Input, InputNumber, Modal, Radio, Select, Space, Table, Typography } from "antd";
import { useEffect, useMemo, useState } from "react";
// Simple customer selector table
function CustomerSelectorTable({ customers, onSelect, isSubmitting }) {
const [selectedCustNo, setSelectedCustNo] = useState(null);
const columns = [
{
title: "Select",
key: "select",
width: 80,
render: (_, record) => (
<Radio checked={selectedCustNo === record.custNo} onChange={() => setSelectedCustNo(record.custNo)} />
)
},
{ title: "Customer ID", dataIndex: "custNo", key: "custNo" },
{ title: "Name", dataIndex: "name", key: "name" },
{
title: "VIN Owner",
key: "vinOwner",
render: (_, record) => (record.vinOwner || record.isVehicleOwner ? "Yes" : "No")
}
];
return (
<div>
<Table columns={columns} dataSource={customers} rowKey="custNo" pagination={false} size="small" />
<div style={{ marginTop: 16, display: "flex", gap: 8 }}>
<Button
type="primary"
onClick={() => onSelect(selectedCustNo, false)}
disabled={!selectedCustNo || isSubmitting}
loading={isSubmitting}
>
Use Selected Customer
</Button>
<Button onClick={() => onSelect(null, true)} disabled={isSubmitting} loading={isSubmitting}>
Create New Customer
</Button>
</div>
</div>
);
}
/**
* RR Early RO Creation Form
* Used from convert button or admin page to create minimal RO before full export
* @param bodyshop
* @param socket
* @param job
* @param onSuccess - callback when RO is created successfully
* @param onCancel - callback to close modal
* @param showCancelButton - whether to show cancel button
* @returns {JSX.Element}
* @constructor
*/
export default function RREarlyROForm({ bodyshop, socket, job, onSuccess, onCancel, showCancelButton = true }) {
const [form] = Form.useForm();
// Advisors
const [advisors, setAdvisors] = useState([]);
const [advLoading, setAdvLoading] = useState(false);
// Customer selection
const [customerCandidates, setCustomerCandidates] = useState([]);
const [showCustomerSelector, setShowCustomerSelector] = useState(false);
// Loading and success states
const [isSubmitting, setIsSubmitting] = useState(false);
const [earlyRoCreated, setEarlyRoCreated] = useState(!!job?.dms_id);
const [createdRoNumber, setCreatedRoNumber] = useState(job?.dms_id || null);
// Derive default OpCode parts from bodyshop config (matching dms.container.jsx logic)
const initialValues = useMemo(() => {
const cfg = bodyshop?.rr_configuration || {};
const defaults =
cfg.opCodeDefault ||
cfg.op_code_default ||
cfg.op_codes?.default ||
cfg.defaults?.opCode ||
cfg.defaults ||
cfg.default ||
{};
const prefix = defaults.prefix ?? defaults.opCodePrefix ?? "";
const base = defaults.base ?? defaults.opCodeBase ?? "";
const suffix = defaults.suffix ?? defaults.opCodeSuffix ?? "";
return {
kmin: job?.kmin || 0,
opPrefix: prefix,
opBase: base,
opSuffix: suffix
};
}, [bodyshop, job]);
const getAdvisorNumber = (a) => a?.advisorId;
const getAdvisorLabel = (a) => `${a?.firstName || ""} ${a?.lastName || ""}`.trim();
const fetchRrAdvisors = (refresh = false) => {
if (!socket) return;
setAdvLoading(true);
const onResult = (payload) => {
try {
const list = payload?.result ?? payload ?? [];
setAdvisors(Array.isArray(list) ? list : []);
} finally {
setAdvLoading(false);
socket.off("rr-get-advisors:result", onResult);
}
};
socket.once("rr-get-advisors:result", onResult);
socket.emit("rr-get-advisors", { departmentType: "B", refresh }, (ack) => {
if (ack?.ok) {
const list = ack.result ?? [];
setAdvisors(Array.isArray(list) ? list : []);
} else if (ack) {
console.error("Error fetching RR Advisors:", ack.error);
}
setAdvLoading(false);
socket.off("rr-get-advisors:result", onResult);
});
};
useEffect(() => {
fetchRrAdvisors(false);
}, [bodyshop?.id, socket]);
const handleStartEarlyRO = async (values) => {
if (!socket) {
console.error("Socket not available");
return;
}
setIsSubmitting(true);
const txEnvelope = {
advisorNo: values.advisorNo,
story: values.story || "",
kmin: values.kmin || job?.kmin || 0,
opPrefix: values.opPrefix || "",
opBase: values.opBase || "",
opSuffix: values.opSuffix || ""
};
// Emit the early RO creation request
socket.emit("rr-create-early-ro", {
jobId: job.id,
txEnvelope
});
// Wait for customer selection
const customerListener = (candidates) => {
console.log("Received rr-select-customer event with candidates:", candidates);
setCustomerCandidates(candidates || []);
setShowCustomerSelector(true);
setIsSubmitting(false);
socket.off("rr-select-customer", customerListener);
};
socket.once("rr-select-customer", customerListener);
// Handle failures
const failureListener = (payload) => {
if (payload?.jobId === job.id) {
console.error("Early RO creation failed:", payload.error);
alert(`Failed to create early RO: ${payload.error}`);
setIsSubmitting(false);
setShowCustomerSelector(false);
socket.off("export-failed", failureListener);
socket.off("rr-select-customer", customerListener);
}
};
socket.once("export-failed", failureListener);
};
const handleCustomerSelected = (custNo, createNew = false) => {
if (!socket) return;
console.log("handleCustomerSelected called:", { custNo, createNew, custNoType: typeof custNo });
setIsSubmitting(true);
setShowCustomerSelector(false);
const payload = {
jobId: job.id,
custNo: createNew ? null : custNo,
create: createNew
};
console.log("Emitting rr-early-customer-selected:", payload);
// Emit customer selection
socket.emit("rr-early-customer-selected", payload, (ack) => {
console.log("Received ack from rr-early-customer-selected:", ack);
setIsSubmitting(false);
if (ack?.ok) {
const roNumber = ack.dmsRoNo || ack.outsdRoNo;
setEarlyRoCreated(true);
setCreatedRoNumber(roNumber);
onSuccess?.({ roNumber, ...ack });
} else {
alert(`Failed to create early RO: ${ack?.error || "Unknown error"}`);
}
});
// Also listen for socket events
const successListener = (payload) => {
if (payload?.jobId === job.id) {
const roNumber = payload.dmsRoNo || payload.outsdRoNo;
console.log("Early RO created:", roNumber);
socket.off("rr-early-ro-created", successListener);
socket.off("export-failed", failureListener);
}
};
const failureListener = (payload) => {
if (payload?.jobId === job.id) {
console.error("Early RO creation failed:", payload.error);
setIsSubmitting(false);
setEarlyRoCreated(false);
socket.off("rr-early-ro-created", successListener);
socket.off("export-failed", failureListener);
}
};
socket.once("rr-early-ro-created", successListener);
socket.once("export-failed", failureListener);
};
// If early RO already created, show success message
if (earlyRoCreated) {
return (
<Alert
title="Early Reynolds RO Created"
description={`RO Number: ${createdRoNumber || "N/A"} - You can now convert the job.`}
type="success"
showIcon
style={{ marginBottom: 16 }}
/>
);
}
// If showing customer selector, render modal
if (showCustomerSelector) {
return (
<>
<Typography.Title level={5}>Create Early Reynolds RO</Typography.Title>
<Typography.Paragraph type="secondary">Waiting for customer selection...</Typography.Paragraph>
<Modal
title="Select Customer for Early RO"
open={true}
width={800}
footer={null}
onCancel={() => {
setShowCustomerSelector(false);
setIsSubmitting(false);
}}
>
<CustomerSelectorTable
customers={customerCandidates}
onSelect={handleCustomerSelected}
isSubmitting={isSubmitting}
/>
</Modal>
</>
);
}
// Handle manual submit (since we can't nest forms)
const handleManualSubmit = async () => {
try {
const values = await form.validateFields();
handleStartEarlyRO(values);
} catch (error) {
console.error("Validation failed:", error);
}
};
// Show the form
return (
<div style={{ marginTop: 16 }}>
<Typography.Title level={5}>Create Early Reynolds RO</Typography.Title>
<Typography.Paragraph type="secondary" style={{ fontSize: "12px" }}>
Complete this section to create a minimal RO in Reynolds before converting the job.
</Typography.Paragraph>
<Form form={form} layout="vertical" component={false} initialValues={initialValues}>
<Form.Item name="advisorNo" label="Advisor" rules={[{ required: true, message: "Please select an advisor" }]}>
<Select
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) => (option?.children?.toLowerCase() ?? "").includes(input.toLowerCase())
}}
loading={advLoading}
placeholder="Select advisor..."
popupRender={(menu) => (
<>
{menu}
<Button
type="link"
icon={<ReloadOutlined />}
onClick={() => fetchRrAdvisors(true)}
style={{ width: "100%", textAlign: "left" }}
>
Refresh Advisors
</Button>
</>
)}
>
{advisors.map((adv) => (
<Select.Option key={getAdvisorNumber(adv)} value={getAdvisorNumber(adv)}>
{getAdvisorLabel(adv)}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name="kmin"
label="Mileage In"
rules={[
{ required: true, message: "Please enter initial mileage" },
{ type: "number", min: 1, message: "Mileage must be greater than 0" }
]}
>
<InputNumber min={1} style={{ width: "100%" }} />
</Form.Item>
{/* RR OpCode (prefix / base / suffix) */}
<Form.Item required label="RR OpCode">
<Space.Compact block>
<Form.Item name="opPrefix" noStyle>
<Input allowClear maxLength={4} style={{ width: "30%" }} placeholder="Prefix" />
</Form.Item>
<Form.Item name="opBase" noStyle rules={[{ required: true, message: "Base Required" }]}>
<Input allowClear maxLength={10} style={{ width: "40%" }} placeholder="Base" />
</Form.Item>
<Form.Item name="opSuffix" noStyle>
<Input allowClear maxLength={4} style={{ width: "30%" }} placeholder="Suffix" />
</Form.Item>
</Space.Compact>
</Form.Item>
<Form.Item name="story" label="Comments / Story (Optional)">
<Input.TextArea rows={2} maxLength={240} showCount placeholder="Enter comments or story..." />
</Form.Item>
<div style={{ marginTop: 16 }}>
<Space>
<Button type="primary" onClick={handleManualSubmit} loading={isSubmitting} disabled={advLoading}>
Create Early RO
</Button>
{showCancelButton && <Button onClick={onCancel}>Cancel</Button>}
</Space>
</div>
</Form>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { Modal } from "antd";
import RREarlyROForm from "./rr-early-ro-form";
/**
* Modal wrapper for RR Early RO Creation Form
* @param open - boolean to control modal visibility
* @param onClose - callback when modal is closed
* @param onSuccess - callback when RO is created successfully
* @param bodyshop - bodyshop object
* @param socket - socket.io connection
* @param job - job object
* @returns {JSX.Element}
* @constructor
*/
export default function RREarlyROModal({ open, onClose, onSuccess, bodyshop, socket, job }) {
const handleSuccess = (result) => {
onSuccess?.(result);
onClose?.();
};
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
width={700}
destroyOnHidden
title="Create Reynolds Repair Order"
>
<RREarlyROForm bodyshop={bodyshop} socket={socket} job={job} onSuccess={handleSuccess} onCancel={onClose} />
</Modal>
);
}

View File

@@ -86,11 +86,13 @@ export function EmailOverlayComponent({ emailConfig, form, selectedMediaState, b
}
]}
>
<Select>
<Select.Option key={currentUser.email}>{currentUser.email}</Select.Option>
<Select.Option key={bodyshop.email}>{bodyshop.email}</Select.Option>
{bodyshop.md_from_emails && bodyshop.md_from_emails.map((e) => <Select.Option key={e}>{e}</Select.Option>)}
</Select>
<Select
options={[
{ key: currentUser.email, value: currentUser.email, label: currentUser.email },
{ key: bodyshop.email, value: bodyshop.email, label: bodyshop.email },
...(bodyshop.md_from_emails ? bodyshop.md_from_emails.map((e) => ({ key: e, value: e, label: e })) : [])
]}
/>
</Form.Item>
<Form.Item
label={

View File

@@ -1,7 +1,6 @@
import { Select, Space, Tag } from "antd";
import { useTranslation } from "react-i18next";
const { Option } = Select;
//To be used as a form element only.
const EmployeeSearchSelectEmail = ({ options, ...props }) => {
@@ -12,26 +11,24 @@ const EmployeeSearchSelectEmail = ({ options, ...props }) => {
showSearch={{
optionFilterProp: "search"
}}
// value={option}
style={{
width: 400
}}
options={options?.map((o) => ({
key: o.id,
value: o.user_email,
search: `${o.employee_number} ${o.first_name} ${o.last_name}`,
label: (
<Space>
{`${o.employee_number} ${o.first_name} ${o.last_name}`}
<Tag color="green">
{o.flat_rate ? t("timetickets.labels.flat_rate") : t("timetickets.labels.straight_time")}
</Tag>
</Space>
)
}))}
{...props}
>
{options
? options.map((o) => (
<Option key={o.id} value={o.user_email} search={`${o.employee_number} ${o.first_name} ${o.last_name}`}>
<Space>
{`${o.employee_number} ${o.first_name} ${o.last_name}`}
<Tag color="green">
{o.flat_rate ? t("timetickets.labels.flat_rate") : t("timetickets.labels.straight_time")}
</Tag>
</Space>
</Option>
))
: null}
</Select>
/>
);
};
export default EmployeeSearchSelectEmail;

View File

@@ -1,7 +1,6 @@
import { Select, Space, Tag } from "antd";
import { useTranslation } from "react-i18next";
const { Option } = Select;
//To be used as a form element only.
const EmployeeSearchSelect = ({ options, showEmail, ...props }) => {
@@ -12,30 +11,29 @@ const EmployeeSearchSelect = ({ options, showEmail, ...props }) => {
showSearch={{
optionFilterProp: "search"
}}
// value={option}
style={{
width: 400
}}
options={options?.map((o) => ({
key: o.id,
value: o.id,
search: `${o.employee_number} ${o.first_name} ${o.last_name}`,
label: (
<Space size="small">
{`${o.employee_number ?? ""} ${o.first_name} ${o.last_name}`}
<Tag color="green" style={{ padding: "0.1 0.1rem", marginRight: "1px", marginLeft: "1px" }}>
{o.flat_rate ? t("timetickets.labels.flat_rate") : t("timetickets.labels.straight_time")}
</Tag>
{showEmail && o.user_email ? (
<Tag color="blue" style={{ padding: "0.1 0.1rem", marginRight: "1px", marginLeft: "1px" }}>
{o.user_email}
</Tag>
) : null}
</Space>
)
}))}
{...props}
>
{options
? options.map((o) => (
<Option key={o.id} value={o.id} search={`${o.employee_number} ${o.first_name} ${o.last_name}`}>
<Space size="small">
{`${o.employee_number ?? ""} ${o.first_name} ${o.last_name}`}
<Tag color="green" style={{ padding: "0.1 0.1rem", marginRight: "1px", marginLeft: "1px" }}>
{o.flat_rate ? t("timetickets.labels.flat_rate") : t("timetickets.labels.straight_time")}
</Tag>
{showEmail && o.user_email ? (
<Tag color="blue" style={{ padding: "0.1 0.1rem", marginRight: "1px", marginLeft: "1px" }}>
{o.user_email}
</Tag>
) : null}
</Space>
</Option>
))
: null}
</Select>
/>
);
};
export default EmployeeSearchSelect;

View File

@@ -67,16 +67,19 @@ export function Jobd3RdPartyModal({ bodyshop, jobId, job, technician }) {
);
};
const handleInsSelect = (value, option) => {
form.setFieldsValue({
addr1: option.obj.name,
addr2: option.obj.street1,
addr3: option.obj.street2,
city: option.obj.city,
state: option.obj.state,
zip: option.obj.zip,
vendorid: null
});
const handleInsSelect = (value) => {
const selectedVendor = bodyshop.md_ins_cos.find(s => s.name === value);
if (selectedVendor) {
form.setFieldsValue({
addr1: selectedVendor.name,
addr2: selectedVendor.street1,
addr3: selectedVendor.street2,
city: selectedVendor.city,
state: selectedVendor.state,
zip: selectedVendor.zip,
vendorid: null
});
}
};
const handleVendorSelect = (vendorid) => {
@@ -103,13 +106,13 @@ export function Jobd3RdPartyModal({ bodyshop, jobId, job, technician }) {
<VendorSearchSelect options={VendorAutoCompleteData?.vendors} onSelect={handleVendorSelect} />
</Form.Item>
<Form.Item label={t("bodyshop.fields.md_ins_co.name")} name="ins_co_id">
<Select onSelect={handleInsSelect}>
{bodyshop.md_ins_cos.map((s) => (
<Select.Option key={s.name} obj={s} value={s.name}>
{s.name}
</Select.Option>
))}
</Select>
<Select
onSelect={handleInsSelect}
options={bodyshop.md_ins_cos.map((s) => ({
value: s.name,
label: s.name
}))}
/>
</Form.Item>
<LayoutFormRow grow>
<Form.Item label={t("printcenter.jobs.3rdpartyfields.addr1")} name="addr1">

View File

@@ -88,17 +88,15 @@ export function JoblineBulkAssign({ setSelectedLines, selectedLines, insertAudit
>
<Select
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 }}
>
{bodyshop.employee_teams.map((team) => (
<Select.Option value={team.id} key={team.id} name={team.name}>
{team.name}
</Select.Option>
))}
</Select>
options={bodyshop.employee_teams.map((team) => ({
value: team.id,
label: team.name
}))}
/>
</Form.Item>
<Space wrap>

View File

@@ -122,22 +122,26 @@ export function JobLineConvertToLabor({
}
]}
>
<Select allowClear showSearch={{ optionFilterProp: "children" }}>
<Select.Option value="LAA">{t("joblines.fields.lbr_types.LAA")}</Select.Option>
<Select.Option value="LAB">{t("joblines.fields.lbr_types.LAB")}</Select.Option>
<Select.Option value="LAD">{t("joblines.fields.lbr_types.LAD")}</Select.Option>
<Select.Option value="LAE">{t("joblines.fields.lbr_types.LAE")}</Select.Option>
<Select.Option value="LAF">{t("joblines.fields.lbr_types.LAF")}</Select.Option>
<Select.Option value="LAG">{t("joblines.fields.lbr_types.LAG")}</Select.Option>
<Select.Option value="LAM">{t("joblines.fields.lbr_types.LAM")}</Select.Option>
<Select.Option value="LAR">{t("joblines.fields.lbr_types.LAR")}</Select.Option>
<Select.Option value="LAS">{t("joblines.fields.lbr_types.LAS")}</Select.Option>
<Select.Option value="LAU">{t("joblines.fields.lbr_types.LAU")}</Select.Option>
<Select.Option value="LA1">{t("joblines.fields.lbr_types.LA1")}</Select.Option>
<Select.Option value="LA2">{t("joblines.fields.lbr_types.LA2")}</Select.Option>
<Select.Option value="LA3">{t("joblines.fields.lbr_types.LA3")}</Select.Option>
<Select.Option value="LA4">{t("joblines.fields.lbr_types.LA4")}</Select.Option>
</Select>
<Select
allowClear
showSearch
options={[
{ value: "LAA", label: t("joblines.fields.lbr_types.LAA") },
{ value: "LAB", label: t("joblines.fields.lbr_types.LAB") },
{ value: "LAD", label: t("joblines.fields.lbr_types.LAD") },
{ value: "LAE", label: t("joblines.fields.lbr_types.LAE") },
{ value: "LAF", label: t("joblines.fields.lbr_types.LAF") },
{ value: "LAG", label: t("joblines.fields.lbr_types.LAG") },
{ value: "LAM", label: t("joblines.fields.lbr_types.LAM") },
{ value: "LAR", label: t("joblines.fields.lbr_types.LAR") },
{ value: "LAS", label: t("joblines.fields.lbr_types.LAS") },
{ value: "LAU", label: t("joblines.fields.lbr_types.LAU") },
{ value: "LA1", label: t("joblines.fields.lbr_types.LA1") },
{ value: "LA2", label: t("joblines.fields.lbr_types.LA2") },
{ value: "LA3", label: t("joblines.fields.lbr_types.LA3") },
{ value: "LA4", label: t("joblines.fields.lbr_types.LA4") }
]}
/>
</Form.Item>
<Form.Item shouldUpdate>

View File

@@ -115,19 +115,18 @@ export function JobLineDispatchButton({
>
<Select
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 }}
>
{bodyshop.employees
options={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>
.map((emp) => ({
value: emp.id,
key: emp.id,
label: `${emp.first_name} ${emp.last_name}`
}))}
/>
</Form.Item>
<Space wrap>

View File

@@ -64,13 +64,12 @@ export function JobLineStatusPopup({ bodyshop, jobline, disabled }) {
onSelect={handleChange}
onBlur={handleSave}
onClear={() => handleChange(null)}
>
{Object.values(bodyshop.md_order_statuses).map((s, idx) => (
<Select.Option key={idx} value={s}>
{s}
</Select.Option>
))}
</Select>
options={Object.values(bodyshop.md_order_statuses).map((s, idx) => ({
key: idx,
value: s,
label: s
}))}
/>
</LoadingSpinner>
</div>
);

View File

@@ -75,13 +75,12 @@ export function JoblineTeamAssignment({ bodyshop, jobline, disabled, jobId, inse
onSelect={handleChange}
onBlur={handleSave}
onClear={() => handleChange(null)}
>
{Object.values(bodyshop.employee_teams).map((s, idx) => (
<Select.Option key={idx} value={s.id}>
{s.name}
</Select.Option>
))}
</Select>
options={Object.values(bodyshop.employee_teams).map((s) => ({
key: s.id,
value: s.id,
label: s.name
}))}
/>
</LoadingSpinner>
</div>
);

View File

@@ -67,22 +67,22 @@ export function JobLinesUpsertModalComponent({ bodyshop, open, jobLine, handleCa
</LayoutFormRow>
<LayoutFormRow grow>
<Form.Item label={t("joblines.fields.mod_lbr_ty")} name="mod_lbr_ty">
<Select allowClear>
<Select.Option value="LAA">{t("joblines.fields.lbr_types.LAA")}</Select.Option>
<Select.Option value="LAB">{t("joblines.fields.lbr_types.LAB")}</Select.Option>
<Select.Option value="LAD">{t("joblines.fields.lbr_types.LAD")}</Select.Option>
<Select.Option value="LAE">{t("joblines.fields.lbr_types.LAE")}</Select.Option>
<Select.Option value="LAF">{t("joblines.fields.lbr_types.LAF")}</Select.Option>
<Select.Option value="LAG">{t("joblines.fields.lbr_types.LAG")}</Select.Option>
<Select.Option value="LAM">{t("joblines.fields.lbr_types.LAM")}</Select.Option>
<Select.Option value="LAR">{t("joblines.fields.lbr_types.LAR")}</Select.Option>
<Select.Option value="LAS">{t("joblines.fields.lbr_types.LAS")}</Select.Option>
<Select.Option value="LAU">{t("joblines.fields.lbr_types.LAU")}</Select.Option>
<Select.Option value="LA1">{t("joblines.fields.lbr_types.LA1")}</Select.Option>
<Select.Option value="LA2">{t("joblines.fields.lbr_types.LA2")}</Select.Option>
<Select.Option value="LA3">{t("joblines.fields.lbr_types.LA3")}</Select.Option>
<Select.Option value="LA4">{t("joblines.fields.lbr_types.LA4")}</Select.Option>
</Select>
<Select allowClear options={[
{ value: "LAA", label: t("joblines.fields.lbr_types.LAA") },
{ value: "LAB", label: t("joblines.fields.lbr_types.LAB") },
{ value: "LAD", label: t("joblines.fields.lbr_types.LAD") },
{ value: "LAE", label: t("joblines.fields.lbr_types.LAE") },
{ value: "LAF", label: t("joblines.fields.lbr_types.LAF") },
{ value: "LAG", label: t("joblines.fields.lbr_types.LAG") },
{ value: "LAM", label: t("joblines.fields.lbr_types.LAM") },
{ value: "LAR", label: t("joblines.fields.lbr_types.LAR") },
{ value: "LAS", label: t("joblines.fields.lbr_types.LAS") },
{ value: "LAU", label: t("joblines.fields.lbr_types.LAU") },
{ value: "LA1", label: t("joblines.fields.lbr_types.LA1") },
{ value: "LA2", label: t("joblines.fields.lbr_types.LA2") },
{ value: "LA3", label: t("joblines.fields.lbr_types.LA3") },
{ value: "LA4", label: t("joblines.fields.lbr_types.LA4") }
]} />
</Form.Item>
<Form.Item label={t("joblines.fields.op_code_desc")} name="op_code_desc">
<Input />
@@ -128,17 +128,17 @@ export function JobLinesUpsertModalComponent({ bodyshop, open, jobLine, handleCa
</LayoutFormRow>
<LayoutFormRow>
<Form.Item label={t("joblines.fields.part_type")} name="part_type">
<Select allowClear>
<Select.Option value="PAA">{t("joblines.fields.part_types.PAA")}</Select.Option>
<Select.Option value="PAC">{t("joblines.fields.part_types.PAC")}</Select.Option>
<Select.Option value="PAE">{t("joblines.fields.part_types.PAE")}</Select.Option>
<Select.Option value="PAL">{t("joblines.fields.part_types.PAL")}</Select.Option>
<Select.Option value="PAM">{t("joblines.fields.part_types.PAM")}</Select.Option>
<Select.Option value="PAN">{t("joblines.fields.part_types.PAN")}</Select.Option>
<Select.Option value="PAO">{t("joblines.fields.part_types.PAO")}</Select.Option>
<Select.Option value="PAR">{t("joblines.fields.part_types.PAR")}</Select.Option>
<Select.Option value="PAS">{t("joblines.fields.part_types.PAS")}</Select.Option>
</Select>
<Select allowClear options={[
{ value: "PAA", label: t("joblines.fields.part_types.PAA") },
{ value: "PAC", label: t("joblines.fields.part_types.PAC") },
{ value: "PAE", label: t("joblines.fields.part_types.PAE") },
{ value: "PAL", label: t("joblines.fields.part_types.PAL") },
{ value: "PAM", label: t("joblines.fields.part_types.PAM") },
{ value: "PAN", label: t("joblines.fields.part_types.PAN") },
{ value: "PAO", label: t("joblines.fields.part_types.PAO") },
{ value: "PAR", label: t("joblines.fields.part_types.PAR") },
{ value: "PAS", label: t("joblines.fields.part_types.PAS") }
]} />
</Form.Item>
<Form.Item label={t("joblines.fields.oem_partno")} name="oem_partno">
<Input />

View File

@@ -8,8 +8,6 @@ import { SEARCH_JOBS_BY_ID_FOR_AUTOCOMPLETE, SEARCH_JOBS_FOR_AUTOCOMPLETE } from
import AlertComponent from "../alert/alert.component";
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
const { Option } = Select;
const JobSearchSelect = ({
disabled,
convertedOnly = false,
@@ -87,24 +85,24 @@ const JobSearchSelect = ({
style={{ width: "100%" }}
suffixIcon={(loading || idLoading) && <Spin />} // matches OLD spinner semantics
notFoundContent={loading ? <LoadingOutlined /> : null} // matches OLD (loading only)
>
{theOptions
? theOptions.map((o) => (
<Option key={o.id} value={o.id} status={o.status}>
<Space align="center">
<span>
{`${clm_no && o.clm_no ? `${o.clm_no} | ` : ""}${o.ro_number || t("general.labels.na")} | ${OwnerNameDisplayFunction(
o
)} | ${o.v_model_yr || ""} ${o.v_make_desc || ""} ${o.v_model_desc || ""}`}
</span>
<Tag>
<strong>{o.status}</strong>
</Tag>
</Space>
</Option>
))
: null}
</Select>
options={theOptions?.map((o) => ({
key: o.id,
value: o.id,
status: o.status,
label: (
<Space align="center">
<span>
{`${clm_no && o.clm_no ? `${o.clm_no} | ` : ""}${o.ro_number || t("general.labels.na")} | ${OwnerNameDisplayFunction(
o
)} | ${o.v_model_yr || ""} ${o.v_make_desc || ""} ${o.v_model_desc || ""}`}
</span>
<Tag>
<strong>{o.status}</strong>
</Tag>
</Space>
)
}))}
/>
{error ? <AlertComponent title={error.message} type="error" /> : null}
{idError ? <AlertComponent title={idError.message} type="error" /> : null}

View File

@@ -59,13 +59,12 @@ export function JobsAdminClass({ bodyshop, job }) {
}
]}
>
<Select>
{bodyshop.md_classes.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
<Select
options={bodyshop.md_classes.map((s) => ({
value: s,
label: s
}))}
/>
</Form.Item>
</Form>

View File

@@ -42,11 +42,11 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
<tbody>
{fields.map((field, index) => (
<tr key={field.key}>
{/* Hidden field to preserve jobline ID */}
<Form.Item hidden name={[field.name, "id"]}>
<input />
</Form.Item>
<td>
{/* Hidden field to preserve jobline ID without injecting a div under <tr> */}
<Form.Item noStyle name={[field.name, "id"]}>
<input type="hidden" />
</Form.Item>
<Form.Item
// label={t("joblines.fields.line_desc")}
key={`${index}line_desc`}
@@ -141,13 +141,11 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}}
disabled={jobRO}
>
{bodyshop.md_responsibility_centers.profits.map((p) => (
<Select.Option key={p.name} value={p.name}>
{p.name}
</Select.Option>
))}
</Select>
options={bodyshop.md_responsibility_centers.profits.map((p) => ({
value: p.name,
label: p.name
}))}
/>
</Form.Item>
</td>
<td>
@@ -171,13 +169,11 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}}
disabled={jobRO}
>
{bodyshop.md_responsibility_centers.profits.map((p) => (
<Select.Option key={p.name} value={p.name}>
{p.name}
</Select.Option>
))}
</Select>
options={bodyshop.md_responsibility_centers.profits.map((p) => ({
value: p.name,
label: p.name
}))}
/>
</Form.Item>
</td>
</tr>

View File

@@ -1,24 +1,28 @@
import { useMutation } from "@apollo/client/react";
import { Button, Form, Input, Popover, Select, Space, Switch } from "antd";
import { Button, Divider, Form, Input, Modal, Select, Space, Switch } from "antd";
import axios from "axios";
import { some } from "lodash";
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import RREarlyROForm from "../dms-post-form/rr-early-ro-form";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
bodyshop: selectBodyshop,
jobRO: selectJobReadOnly
});
const mapDispatchToProps = (dispatch) => ({
insertAuditTrail: ({ jobid, operation, type }) =>
dispatch(
@@ -33,18 +37,83 @@ const mapDispatchToProps = (dispatch) => ({
export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTrail, parentFormIsFieldsTouched }) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [earlyRoCreated, setEarlyRoCreated] = useState(!!job?.dms_id);
const [earlyRoCreatedThisSession, setEarlyRoCreatedThisSession] = useState(false);
const [mutationConvertJob] = useMutation(CONVERT_JOB_TO_RO);
const { t } = useTranslation();
const [form] = Form.useForm();
const notification = useNotification();
const allFormValues = Form.useWatch([], form);
const { socket } = useSocket();
const {
treatments: { Fortellis }
} = useTreatmentsWithConfig({
attributes: {},
names: ["Fortellis"],
splitKey: bodyshop?.imexshopid
});
const dmsMode = getDmsMode(bodyshop, Fortellis.treatment);
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
const insuranceOptions = useMemo(
() =>
(bodyshop?.md_ins_cos ?? []).map((s) => ({
value: s.name,
label: s.name
})),
[bodyshop?.md_ins_cos]
);
const classOptions = useMemo(
() =>
(bodyshop?.md_classes ?? []).map((s) => ({
value: s,
label: s
})),
[bodyshop?.md_classes]
);
const referralOptions = useMemo(
() =>
(bodyshop?.md_referral_sources ?? []).map((s) => ({
value: s,
label: s
})),
[bodyshop?.md_referral_sources]
);
const csrOptions = useMemo(
() =>
(bodyshop?.employees ?? [])
.filter((emp) => emp.active)
.map((emp) => ({
value: emp.id,
label: `${emp.first_name} ${emp.last_name}`
})),
[bodyshop?.employees]
);
const categoryOptions = useMemo(
() =>
(bodyshop?.md_categories ?? []).map((s) => ({
value: s,
label: s
})),
[bodyshop?.md_categories]
);
const handleConvert = async ({ employee_csr, category, ...values }) => {
if (parentFormIsFieldsTouched()) {
alert(t("jobs.labels.savebeforeconversion"));
return;
}
setLoading(true);
const res = await mutationConvertJob({
variables: {
jobId: job.id,
@@ -58,13 +127,11 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
});
if (values.ca_gst_registrant) {
await axios.post("/job/totalsssu", {
id: job.id
});
await axios.post("/job/totalsssu", { id: job.id });
}
if (!res.errors) {
refetch();
refetch?.();
notification.success({
title: t("jobs.successes.converted")
});
@@ -77,182 +144,183 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
setOpen(false);
}
setLoading(false);
};
const submitDisabled = useCallback(() => some(allFormValues, (v) => v === undefined), [allFormValues]);
const popMenu = (
<div>
<Form
layout="vertical"
form={form}
onFinish={handleConvert}
initialValues={{
driveable: true,
towin: job.towin,
ca_gst_registrant: job.ca_gst_registrant,
employee_csr: job.employee_csr,
category: job.category,
referral_source: job.referral_source,
referral_source_extra: job.referral_source_extra ?? ""
}}
>
<Form.Item
name={["ins_co_nm"]}
label={t("jobs.fields.ins_co_nm")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Select showSearch>
{bodyshop.md_ins_cos.map((s, i) => (
<Select.Option key={i} value={s.name}>
{s.name}
</Select.Option>
))}
</Select>
</Form.Item>
{bodyshop.enforce_class && (
<Form.Item
name={"class"}
label={t("jobs.fields.class")}
rules={[
{
required: bodyshop.enforce_class
//message: t("general.validation.required"),
}
]}
>
<Select>
{bodyshop.md_classes.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop.enforce_referral && (
<>
<Form.Item
name={"referral_source"}
label={t("jobs.fields.referralsource")}
rules={[
{
required: bodyshop.enforce_referral
//message: t("general.validation.required"),
}
]}
>
<Select>
{bodyshop.md_referral_sources.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
<Input />
</Form.Item>
</>
)}
{bodyshop.enforce_conversion_csr && (
<Form.Item
name={"employee_csr"}
label={t(
InstanceRenderManager({
imex: "jobs.fields.employee_csr",
rome: "jobs.fields.employee_csr_writer"
})
)}
rules={[
{
required: bodyshop.enforce_conversion_csr
//message: t("general.validation.required"),
}
]}
>
<Select
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}}
style={{ width: 200 }}
>
{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>
</Form.Item>
)}
{bodyshop.enforce_conversion_category && (
<Form.Item
name={"category"}
label={t("jobs.fields.category")}
rules={[
{
required: bodyshop.enforce_conversion_category
//message: t("general.validation.required"),
}
]}
>
<Select allowClear>
{bodyshop.md_categories.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop.region_config.toLowerCase().startsWith("ca") && (
<Form.Item label={t("jobs.fields.ca_gst_registrant")} name="ca_gst_registrant" valuePropName="checked">
<Switch />
</Form.Item>
)}
<Form.Item label={t("jobs.fields.driveable")} name="driveable" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t("jobs.fields.towin")} name="towin" valuePropName="checked">
<Switch />
</Form.Item>
<Space wrap>
<Button disabled={submitDisabled()} type="primary" danger onClick={() => form.submit()} loading={loading}>
{t("jobs.actions.convert")}
</Button>
<Button onClick={() => setOpen(false)}>{t("general.actions.close")}</Button>
</Space>
</Form>
</div>
);
const handleEarlyROSuccess = (result) => {
setEarlyRoCreated(true);
setEarlyRoCreatedThisSession(true);
notification.success({
title: t("jobs.successes.early_ro_created"),
description: `RO Number: ${result.roNumber || "N/A"}`
});
setTimeout(() => {
refetch?.();
}, 2000);
};
const handleModalClose = () => {
setOpen(false);
};
if (job.converted) return <></>;
return (
<Popover open={open} content={popMenu}>
<>
<Button
key="convert"
type="primary"
danger
// style={{ display: job.converted ? "none" : "" }}
disabled={job.converted || jobRO}
loading={loading}
onClick={() => {
setEarlyRoCreated(!!job?.dms_id);
setEarlyRoCreatedThisSession(false);
setOpen(true);
}}
>
{t("jobs.actions.convert")}
</Button>
</Popover>
<Modal
open={open}
onCancel={handleModalClose}
closable={!(earlyRoCreatedThisSession && !job.converted)}
maskClosable={!(earlyRoCreatedThisSession && !job.converted)}
title={t("jobs.actions.convert")}
footer={null}
width={700}
destroyOnHidden
>
<Form
layout="vertical"
form={form}
preserve={false}
onFinish={handleConvert}
initialValues={{
driveable: true,
towin: job.towin,
ca_gst_registrant: job.ca_gst_registrant,
employee_csr: job.employee_csr,
category: job.category,
referral_source: job.referral_source,
referral_source_extra: job.referral_source_extra ?? ""
}}
>
{isReynoldsMode && !job.dms_id && !earlyRoCreated && (
<>
<RREarlyROForm
bodyshop={bodyshop}
socket={socket}
job={job}
onSuccess={handleEarlyROSuccess}
showCancelButton={false}
/>
<Divider />
</>
)}
<Form.Item
name={["ins_co_nm"]}
label={t("jobs.fields.ins_co_nm")}
rules={[{ required: true }]}
>
<Select
showSearch={{
optionFilterProp:'label'
}}
options={insuranceOptions}
/>
</Form.Item>
{bodyshop.enforce_class && (
<Form.Item name="class" label={t("jobs.fields.class")} rules={[{ required: bodyshop.enforce_class }]}>
<Select options={classOptions} />
</Form.Item>
)}
{bodyshop.enforce_referral && (
<>
<Form.Item
name="referral_source"
label={t("jobs.fields.referralsource")}
rules={[{ required: bodyshop.enforce_referral }]}
>
<Select options={referralOptions} />
</Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
<Input />
</Form.Item>
</>
)}
{bodyshop.enforce_conversion_csr && (
<Form.Item
name="employee_csr"
label={t(
InstanceRenderManager({
imex: "jobs.fields.employee_csr",
rome: "jobs.fields.employee_csr_writer"
})
)}
rules={[{ required: bodyshop.enforce_conversion_csr }]}
>
<Select
showSearch={{
optionFilterProp: 'label',
filterOption: (input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}}
style={{ width: 200 }}
options={csrOptions}
/>
</Form.Item>
)}
{bodyshop.enforce_conversion_category && (
<Form.Item name="category" label={t("jobs.fields.category")} rules={[{ required: bodyshop.enforce_conversion_category }]}>
<Select allowClear options={categoryOptions} />
</Form.Item>
)}
{bodyshop.region_config.toLowerCase().startsWith("ca") && (
<Form.Item label={t("jobs.fields.ca_gst_registrant")} name="ca_gst_registrant" valuePropName="checked">
<Switch />
</Form.Item>
)}
<Form.Item label={t("jobs.fields.driveable")} name="driveable" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t("jobs.fields.towin")} name="towin" valuePropName="checked">
<Switch />
</Form.Item>
<Space wrap style={{ marginTop: 16 }}>
<Button
disabled={submitDisabled() || (isReynoldsMode && !job.dms_id && !earlyRoCreated)}
type="primary"
danger
onClick={() => form.submit()}
loading={loading}
>
{t("jobs.actions.convert")}
</Button>
<Button onClick={handleModalClose} disabled={earlyRoCreatedThisSession && !job.converted}>
{t("general.actions.close")}
</Button>
</Space>
</Form>
</Modal>
</>
);
}

View File

@@ -60,13 +60,13 @@ export function JobsCreateJobsInfo({ bodyshop, form, selected }) {
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.ins_co_nm")} name="ins_co_nm">
<Select onChange={handleInsCoChange}>
{bodyshop.md_ins_cos.map((s) => (
<Select.Option key={s.name} value={s.name}>
{s.name}
</Select.Option>
))}
</Select>
<Select
onChange={handleInsCoChange}
options={bodyshop.md_ins_cos.map((s) => ({
value: s.name,
label: s.name
}))}
/>
</Form.Item>
<Form.Item label={t("jobs.fields.ins_addr1")} name="ins_addr1">
<Input />
@@ -192,13 +192,12 @@ export function JobsCreateJobsInfo({ bodyshop, form, selected }) {
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.referralsource")} name="referral_source">
<Select>
{bodyshop.md_referral_sources.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
<Select
options={bodyshop.md_referral_sources.map((s) => ({
value: s,
label: s
}))}
/>
</Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
<Input />
@@ -221,10 +220,13 @@ export function JobsCreateJobsInfo({ bodyshop, form, selected }) {
<CurrencyInput min={0} />
</Form.Item>
<Form.Item label={t("jobs.fields.ded_status")} name="ded_status">
<Select allowClear>
<Select.Option value="W">{t("jobs.labels.deductible.waived")}</Select.Option>
<Select.Option value="Y">{t("jobs.labels.deductible.stands")}</Select.Option>
</Select>
<Select
allowClear
options={[
{ value: "W", label: t("jobs.labels.deductible.waived") },
{ value: "Y", label: t("jobs.labels.deductible.stands") }
]}
/>
</Form.Item>
<Form.Item label={t("jobs.fields.depreciation_taxes")} name="depreciation_taxes">
<CurrencyInput />

View File

@@ -43,20 +43,19 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
<Input disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.ded_status")} name="ded_status">
<Select disabled={jobRO}>
<Select.Option value="W">{t("jobs.labels.deductible.waived")}</Select.Option>
<Select.Option value="Y">{t("jobs.labels.deductible.stands")}</Select.Option>
</Select>
<Select disabled={jobRO} options={[
{ value: "W", label: t("jobs.labels.deductible.waived") },
{ value: "Y", label: t("jobs.labels.deductible.stands") }
]} />
</Form.Item>
<Form.Item label={t("jobs.fields.ded_amt")} name="ded_amt">
<CurrencyInput disabled={jobRO} min={0} />
</Form.Item>
<Form.Item label={t("jobs.fields.ded_note")} name="ded_note">
<Select disabled={jobRO}>
{bodyshop.md_ded_notes.map((n, index) => (
<Select.Option key={index}>{n}</Select.Option>
))}
</Select>
<Select disabled={jobRO} options={bodyshop.md_ded_notes.map((n) => ({
value: n,
label: n
}))} />
</Form.Item>
<Form.Item label={t("jobs.fields.policy_no")} name="policy_no">
<Input disabled={jobRO} />
@@ -66,13 +65,10 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
</Form.Item>
<Form.Item label={t("jobs.fields.ins_co_nm")} name="ins_co_nm">
<Select disabled={jobRO} onChange={handleInsCoChange}>
{bodyshop.md_ins_cos.map((s) => (
<Select.Option key={s.name} value={s.name}>
{s.name}
</Select.Option>
))}
</Select>
<Select disabled={jobRO} onChange={handleInsCoChange} options={bodyshop.md_ins_cos.map((s) => ({
value: s.name,
label: s.name
}))} />
</Form.Item>
<Form.Item label={t("jobs.fields.ins_addr1")} name="ins_addr1">
<Input disabled={jobRO} />
@@ -123,25 +119,19 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
}
]}
>
<Select disabled={jobRO} allowClear>
{bodyshop.md_referral_sources.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
<Select disabled={jobRO} allowClear options={bodyshop.md_referral_sources.map((s) => ({
value: s,
label: s
}))} />
</Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
<Input disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.alt_transport")} name="alt_transport">
<Select disabled={jobRO} allowClear>
{bodyshop.appt_alt_transport.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
<Select disabled={jobRO} allowClear options={bodyshop.appt_alt_transport.map((s) => ({
value: s,
label: s
}))} />
</Form.Item>
</FormRow>
<Row gutter={[16, 16]}>
@@ -243,15 +233,11 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
</FormRow>
<FormRow header={t("jobs.forms.other")}>
<Form.Item label={t("jobs.fields.category")} name="category">
<Select disabled={jobRO} allowClear>
{bodyshop.md_categories.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
<Select disabled={jobRO} allowClear options={bodyshop.md_categories.map((s) => ({
value: s,
label: s
}))} />
</Form.Item>
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
<Input disabled={jobRO} />
</Form.Item>
@@ -267,6 +253,21 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
<Form.Item label={t("jobs.fields.lost_sale_reason")} name="lost_sale_reason">
<Input disabled={jobRO} allowClear />
</Form.Item>
{bodyshop.rr_dealerid && (
<Form.Item label={t("jobs.fields.dms.id")} name="dms_id">
<Input disabled />
</Form.Item>
)}
{bodyshop.rr_dealerid && (
<Form.Item label={t("jobs.fields.dms.advisor")} name="dms_advisor_id">
<Input disabled />
</Form.Item>
)}
{bodyshop.rr_dealerid && (
<Form.Item label={t("jobs.fields.dms.customer")} name="dms_customer_id">
<Input disabled />
</Form.Item>
)}
</FormRow>
</Card>
);

View File

@@ -714,13 +714,12 @@ export function JobsDetailHeaderActions({
<FormDateTimePickerComponent />
</Form.Item>
<Form.Item label={t("appointments.fields.color")} name="color">
<Select>
{bodyshop.appt_colors.map((col, idx) => (
<Select.Option key={idx} value={col.color.hex}>
{col.label}
</Select.Option>
))}
</Select>
<Select
options={bodyshop.appt_colors.map((col) => ({
value: col.color.hex,
label: col.label
}))}
/>
</Form.Item>
<Space wrap>

View File

@@ -94,22 +94,26 @@ export function LaborAllocationsAdjustmentEdit({
}
]}
>
<Select allowClear disabled={!!mod_lbr_ty}>
<Select.Option value="LAA">{t("joblines.fields.lbr_types.LAA")}</Select.Option>
<Select.Option value="LAB">{t("joblines.fields.lbr_types.LAB")}</Select.Option>
<Select.Option value="LAD">{t("joblines.fields.lbr_types.LAD")}</Select.Option>
<Select.Option value="LAE">{t("joblines.fields.lbr_types.LAE")}</Select.Option>
<Select.Option value="LAF">{t("joblines.fields.lbr_types.LAF")}</Select.Option>
<Select.Option value="LAG">{t("joblines.fields.lbr_types.LAG")}</Select.Option>
<Select.Option value="LAM">{t("joblines.fields.lbr_types.LAM")}</Select.Option>
<Select.Option value="LAR">{t("joblines.fields.lbr_types.LAR")}</Select.Option>
<Select.Option value="LAS">{t("joblines.fields.lbr_types.LAS")}</Select.Option>
<Select.Option value="LAU">{t("joblines.fields.lbr_types.LAU")}</Select.Option>
<Select.Option value="LA1">{t("joblines.fields.lbr_types.LA1")}</Select.Option>
<Select.Option value="LA2">{t("joblines.fields.lbr_types.LA2")}</Select.Option>
<Select.Option value="LA3">{t("joblines.fields.lbr_types.LA3")}</Select.Option>
<Select.Option value="LA4">{t("joblines.fields.lbr_types.LA4")}</Select.Option>
</Select>
<Select
allowClear
disabled={!!mod_lbr_ty}
options={[
{ value: "LAA", label: t("joblines.fields.lbr_types.LAA") },
{ value: "LAB", label: t("joblines.fields.lbr_types.LAB") },
{ value: "LAD", label: t("joblines.fields.lbr_types.LAD") },
{ value: "LAE", label: t("joblines.fields.lbr_types.LAE") },
{ value: "LAF", label: t("joblines.fields.lbr_types.LAF") },
{ value: "LAG", label: t("joblines.fields.lbr_types.LAG") },
{ value: "LAM", label: t("joblines.fields.lbr_types.LAM") },
{ value: "LAR", label: t("joblines.fields.lbr_types.LAR") },
{ value: "LAS", label: t("joblines.fields.lbr_types.LAS") },
{ value: "LAU", label: t("joblines.fields.lbr_types.LAU") },
{ value: "LA1", label: t("joblines.fields.lbr_types.LA1") },
{ value: "LA2", label: t("joblines.fields.lbr_types.LA2") },
{ value: "LA3", label: t("joblines.fields.lbr_types.LA3") },
{ value: "LA4", label: t("joblines.fields.lbr_types.LA4") }
]}
/>
</Form.Item>
<Form.Item
label={t("jobs.fields.adjustmenthours")}
@@ -132,7 +136,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

@@ -7,8 +7,6 @@ import { SEARCH_OWNERS_BY_ID_FOR_AUTOCOMPLETE, SEARCH_OWNERS_FOR_AUTOCOMPLETE }
import AlertComponent from "../alert/alert.component";
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
const { Option } = Select;
const OwnerSearchSelect = ({ value, onChange, onBlur, disabled, ref }) => {
const [callSearch, { loading, error, data }] = useLazyQuery(SEARCH_OWNERS_FOR_AUTOCOMPLETE);
@@ -71,15 +69,12 @@ const OwnerSearchSelect = ({ value, onChange, onBlur, disabled, ref }) => {
onSelect={handleSelect}
notFoundContent={loading ? <LoadingOutlined /> : <Empty />}
onBlur={onBlur}
>
{theOptions
? theOptions.map((o) => (
<Option key={o.id} value={o.id}>
{`${OwnerNameDisplayFunction(o)} | ${o.ownr_addr1 || ""} `}
</Option>
))
: null}
</Select>
options={theOptions?.map((o) => ({
key: o.id,
value: o.id,
label: `${OwnerNameDisplayFunction(o)} | ${o.ownr_addr1 || ""} `
}))}
/>
{idLoading || loading ? <LoadingOutlined /> : null}
{error ? <AlertComponent title={error.message} type="error" /> : null}
{idError ? <AlertComponent title={idError.message} type="error" /> : null}

View File

@@ -158,19 +158,21 @@ export function PartsOrderModalComponent({
key={`${index}part_type`}
name={[field.name, "part_type"]}
>
<Select disabled={!(sendType === "oec" && OEConnection_PriceChange.treatment === "on")}>
<Select.Option value="PAA">{t("joblines.fields.part_types.PAA")}</Select.Option>
<Select.Option value="PAC">{t("joblines.fields.part_types.PAC")}</Select.Option>
<Select.Option value="PAL">{t("joblines.fields.part_types.PAL")}</Select.Option>
<Select.Option value="PAG">{t("joblines.fields.part_types.PAG")}</Select.Option>
<Select.Option value="PAM">{t("joblines.fields.part_types.PAM")}</Select.Option>
<Select.Option value="PAP">{t("joblines.fields.part_types.PAP")}</Select.Option>
<Select.Option value="PAN">{t("joblines.fields.part_types.PAN")}</Select.Option>
<Select.Option value="PAO">{t("joblines.fields.part_types.PAO")}</Select.Option>
<Select.Option value="PAR">{t("joblines.fields.part_types.PAR")}</Select.Option>
<Select.Option value="PAS">{t("joblines.fields.part_types.PAS")}</Select.Option>
</Select>
<Select
disabled={!(sendType === "oec" && OEConnection_PriceChange.treatment === "on")}
options={[
{ value: "PAA", label: t("joblines.fields.part_types.PAA") },
{ value: "PAC", label: t("joblines.fields.part_types.PAC") },
{ value: "PAL", label: t("joblines.fields.part_types.PAL") },
{ value: "PAG", label: t("joblines.fields.part_types.PAG") },
{ value: "PAM", label: t("joblines.fields.part_types.PAM") },
{ value: "PAP", label: t("joblines.fields.part_types.PAP") },
{ value: "PAN", label: t("joblines.fields.part_types.PAN") },
{ value: "PAO", label: t("joblines.fields.part_types.PAO") },
{ value: "PAR", label: t("joblines.fields.part_types.PAR") },
{ value: "PAS", label: t("joblines.fields.part_types.PAS") }
]}
/>
</Form.Item>
<Form.Item
label={t("parts_orders.fields.oem_partno")}

View File

@@ -29,13 +29,12 @@ export function PartsReceiveModalComponent({ bodyshop, form }) {
})
});
}}
>
{bodyshop.md_parts_locations.map((loc, idx) => (
<Select.Option key={idx} value={loc}>
{loc}
</Select.Option>
))}
</Select>
options={bodyshop.md_parts_locations.map((loc, idx) => ({
key: idx,
value: loc,
label: loc
}))}
/>
</Form.Item>
</LayoutFormRow>
<Typography.Title level={4}>{t("parts_orders.labels.inthisorder")}</Typography.Title>
@@ -85,13 +84,14 @@ export function PartsReceiveModalComponent({ bodyshop, form }) {
key={`${index}location`}
name={[field.name, "location"]}
>
<Select style={{ width: "10rem" }}>
{bodyshop.md_parts_locations.map((loc, idx) => (
<Select.Option key={idx} value={loc}>
{loc}
</Select.Option>
))}
</Select>
<Select
style={{ width: "10rem" }}
options={bodyshop.md_parts_locations.map((loc, idx) => ({
key: idx,
value: loc,
label: loc
}))}
/>
</Form.Item>
<Form.Item
label={t("parts_orders.fields.quantity")}

View File

@@ -2,8 +2,6 @@ import { Button, Card, Divider, Form, Input, Select, Space } from "antd";
import { DeleteOutlined, PlusOutlined } from "@ant-design/icons";
import { useTranslation } from "react-i18next";
const { Option } = Select;
export default function PartsShopInfoEmailPresets() {
const { t } = useTranslation();
@@ -26,13 +24,7 @@ export default function PartsShopInfoEmailPresets() {
label={t("bodyshop.labels.email_type")}
rules={[{ required: true, message: t("bodyshop.errors.email_type_required") }]}
>
<Select placeholder={t("bodyshop.placeholders.select_email_type")}>
{emailTypes.map((type) => (
<Option key={type.value} value={type.value}>
{type.label}
</Option>
))}
</Select>
<Select placeholder={t("bodyshop.placeholders.select_email_type")} options={emailTypes} />
</Form.Item>
<Form.Item
{...restField}

View File

@@ -91,20 +91,25 @@ export function PaymentFormComponent({ form, bodyshop, disabled }) {
}
]}
>
<Select disabled={disabled}>
<Select.Option value={t("payments.labels.customer")}>{t("payments.labels.customer")}</Select.Option>
{Qb_Multi_Ar.treatment === "on" ? (
<Select.OptGroup label={t("payments.labels.external")}>
{bodyshop.md_ins_cos.map((i, idx) => (
<Select.Option key={idx} value={i.name}>
{i.name}
</Select.Option>
))}
</Select.OptGroup>
) : (
<Select.Option value={t("payments.labels.insurance")}>{t("payments.labels.insurance")}</Select.Option>
)}
</Select>
<Select disabled={disabled}
options={Qb_Multi_Ar.treatment === "on"
? [
{ value: t("payments.labels.customer"), label: t("payments.labels.customer") },
{
label: t("payments.labels.external"),
options: bodyshop.md_ins_cos.map((i, idx) => ({
key: idx,
value: i.name,
label: i.name
}))
}
]
: [
{ value: t("payments.labels.customer"), label: t("payments.labels.customer") },
{ value: t("payments.labels.insurance"), label: t("payments.labels.insurance") }
]
}
/>
</Form.Item>
<Form.Item
@@ -117,13 +122,13 @@ export function PaymentFormComponent({ form, bodyshop, disabled }) {
}
]}
>
<Select disabled={disabled}>
{bodyshop.md_payment_types.map((v, idx) => (
<Select.Option key={idx} value={v}>
{v}
</Select.Option>
))}
</Select>
<Select disabled={disabled}
options={bodyshop.md_payment_types.map((v, idx) => ({
key: idx,
value: v,
label: v
}))}
/>
</Form.Item>
</LayoutFormRow>
<LayoutFormRow grow>

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

@@ -453,10 +453,10 @@ export function ProductionListConfigManager({
}}
onSelect={handleSelect}
placeholder={t("production.labels.selectview")}
optionLabelProp="label"
popupMatchSelectWidth={false}
value={activeView}
disabled={open || isAddingNewProfile} // Disable the Select box when the popover is open or adding a new profile
optionLabelProp="label"
>
{bodyshop?.production_config &&
bodyshop.production_config

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

@@ -158,20 +158,28 @@ export function ScheduleJobModalComponent({
</LayoutFormRow>
<LayoutFormRow grow>
<Form.Item name="color" label={t("appointments.fields.color")}>
<Select allowClear>
{bodyshop.appt_colors &&
bodyshop.appt_colors.map((color) => (
<Select.Option style={{ color: color.color.hex }} key={color.color.hex} value={color.color.hex}>
{color.label}
</Select.Option>
))}
</Select>
<Select
allowClear
options={
bodyshop.appt_colors &&
bodyshop.appt_colors.map((color) => ({
value: color.color.hex,
label: color.label
}))
}
/>
</Form.Item>
<Form.Item name={"alt_transport"} label={t("jobs.fields.alt_transport")}>
<Select allowClear>
{bodyshop.appt_alt_transport &&
bodyshop.appt_alt_transport.map((alt) => <Select.Option key={alt}>{alt}</Select.Option>)}
</Select>
<Select
allowClear
options={
bodyshop.appt_alt_transport &&
bodyshop.appt_alt_transport.map((alt) => ({
value: alt,
label: alt
}))
}
/>
</Form.Item>
<Form.Item name={"note"} label={t("appointments.fields.note")}>
<Input />

View File

@@ -120,13 +120,12 @@ export function ScheduleManualEvent({ bodyshop, event }) {
<FormDateTimePickerComponent />
</Form.Item>
<Form.Item label={t("appointments.fields.color")} name="color">
<Select>
{bodyshop.appt_colors.map((col, idx) => (
<Select.Option key={idx} value={col.color.hex}>
{col.label}
</Select.Option>
))}
</Select>
<Select
options={bodyshop.appt_colors.map((col) => ({
value: col.color.hex,
label: col.label
}))}
/>
</Form.Item>
<Space wrap>

View File

@@ -325,22 +325,20 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
}
]}
>
<Select>
<Select.Option key={"shift"} value="timetickets.labels.shift">
{t("timetickets.labels.shift")}
</Select.Option>
{bodyshop.cdk_dealerid ||
bodyshop.pbs_serialnumber ||
bodyshop.rr_dealerid ||
Enhanced_Payroll.treatment === "on"
? CiecaSelect(false, true)
: bodyshop.md_responsibility_centers.costs.map((c) => (
<Select.Option key={c.name} value={c.name}>
{c.name}
</Select.Option>
))}
</Select>
<Select
options={[
{ value: "timetickets.labels.shift", label: t("timetickets.labels.shift") },
...(bodyshop.cdk_dealerid ||
bodyshop.pbs_serialnumber ||
bodyshop.rr_dealerid ||
Enhanced_Payroll.treatment === "on"
? CiecaSelect(false, true)
: bodyshop.md_responsibility_centers.costs.map((c) => ({
value: c.name,
label: c.name
})))
]}
/>
</Form.Item>
<Form.Item
label={t("employees.fields.rate")}

View File

@@ -1039,22 +1039,25 @@ export function ShopInfoGeneral({ form }) {
key={`${index}mod_lbr_ty`}
name={[field.name, "mod_lbr_ty"]}
>
<Select allowClear>
<Select.Option value="LAA">{t("joblines.fields.lbr_types.LAA")}</Select.Option>
<Select.Option value="LAB">{t("joblines.fields.lbr_types.LAB")}</Select.Option>
<Select.Option value="LAD">{t("joblines.fields.lbr_types.LAD")}</Select.Option>
<Select.Option value="LAE">{t("joblines.fields.lbr_types.LAE")}</Select.Option>
<Select.Option value="LAF">{t("joblines.fields.lbr_types.LAF")}</Select.Option>
<Select.Option value="LAG">{t("joblines.fields.lbr_types.LAG")}</Select.Option>
<Select.Option value="LAM">{t("joblines.fields.lbr_types.LAM")}</Select.Option>
<Select.Option value="LAR">{t("joblines.fields.lbr_types.LAR")}</Select.Option>
<Select.Option value="LAS">{t("joblines.fields.lbr_types.LAS")}</Select.Option>
<Select.Option value="LAU">{t("joblines.fields.lbr_types.LAU")}</Select.Option>
<Select.Option value="LA1">{t("joblines.fields.lbr_types.LA1")}</Select.Option>
<Select.Option value="LA2">{t("joblines.fields.lbr_types.LA2")}</Select.Option>
<Select.Option value="LA3">{t("joblines.fields.lbr_types.LA3")}</Select.Option>
<Select.Option value="LA4">{t("joblines.fields.lbr_types.LA4")}</Select.Option>
</Select>
<Select
allowClear
options={[
{ value: "LAA", label: t("joblines.fields.lbr_types.LAA") },
{ value: "LAB", label: t("joblines.fields.lbr_types.LAB") },
{ value: "LAD", label: t("joblines.fields.lbr_types.LAD") },
{ value: "LAE", label: t("joblines.fields.lbr_types.LAE") },
{ value: "LAF", label: t("joblines.fields.lbr_types.LAF") },
{ value: "LAG", label: t("joblines.fields.lbr_types.LAG") },
{ value: "LAM", label: t("joblines.fields.lbr_types.LAM") },
{ value: "LAR", label: t("joblines.fields.lbr_types.LAR") },
{ value: "LAS", label: t("joblines.fields.lbr_types.LAS") },
{ value: "LAU", label: t("joblines.fields.lbr_types.LAU") },
{ value: "LA1", label: t("joblines.fields.lbr_types.LA1") },
{ value: "LA2", label: t("joblines.fields.lbr_types.LA2") },
{ value: "LA3", label: t("joblines.fields.lbr_types.LA3") },
{ value: "LA4", label: t("joblines.fields.lbr_types.LA4") }
]}
/>
</Form.Item>
<Form.Item
label={t("joblines.fields.mod_lb_hrs")}
@@ -1068,17 +1071,20 @@ export function ShopInfoGeneral({ form }) {
key={`${index}part_type`}
name={[field.name, "part_type"]}
>
<Select allowClear>
<Select.Option value="PAA">{t("joblines.fields.part_types.PAA")}</Select.Option>
<Select.Option value="PAC">{t("joblines.fields.part_types.PAC")}</Select.Option>
<Select.Option value="PAE">{t("joblines.fields.part_types.PAE")}</Select.Option>
<Select.Option value="PAL">{t("joblines.fields.part_types.PAL")}</Select.Option>
<Select.Option value="PAM">{t("joblines.fields.part_types.PAM")}</Select.Option>
<Select.Option value="PAN">{t("joblines.fields.part_types.PAN")}</Select.Option>
<Select.Option value="PAO">{t("joblines.fields.part_types.PAO")}</Select.Option>
<Select.Option value="PAR">{t("joblines.fields.part_types.PAR")}</Select.Option>
<Select.Option value="PAS">{t("joblines.fields.part_types.PAS")}</Select.Option>
</Select>
<Select
allowClear
options={[
{ value: "PAA", label: t("joblines.fields.part_types.PAA") },
{ value: "PAC", label: t("joblines.fields.part_types.PAC") },
{ value: "PAE", label: t("joblines.fields.part_types.PAE") },
{ value: "PAL", label: t("joblines.fields.part_types.PAL") },
{ value: "PAM", label: t("joblines.fields.part_types.PAM") },
{ value: "PAN", label: t("joblines.fields.part_types.PAN") },
{ value: "PAO", label: t("joblines.fields.part_types.PAO") },
{ value: "PAR", label: t("joblines.fields.part_types.PAR") },
{ value: "PAS", label: t("joblines.fields.part_types.PAS") }
]}
/>
</Form.Item>
<Form.Item
label={t("joblines.fields.oem_partno")}

View File

@@ -51,13 +51,7 @@ export default function ShopInfoIntakeChecklistComponent({ form }) {
}
]}
>
<Select>
{Object.keys(ConfigFormTypes).map((i) => (
<Select.Option key={i} value={i}>
{i}
</Select.Option>
))}
</Select>
<Select options={Object.keys(ConfigFormTypes).map((i) => ({ value: i, label: i }))} />
</Form.Item>
<Form.Item
label={t("jobs.fields.intake.label")}
@@ -156,13 +150,13 @@ export default function ShopInfoIntakeChecklistComponent({ form }) {
}
]}
>
<Select mode="multiple">
{Object.keys(TemplateListGenerated).map((i) => (
<Select.Option key={TemplateListGenerated[i].key} value={TemplateListGenerated[i].key}>
{TemplateListGenerated[i].title}
</Select.Option>
))}
</Select>
<Select
mode="multiple"
options={Object.keys(TemplateListGenerated).map((i) => ({
value: TemplateListGenerated[i].key,
label: TemplateListGenerated[i].title
}))}
/>
</Form.Item>
<Form.Item
name={["intakechecklist", "next_contact_hours"]}
@@ -205,13 +199,7 @@ export default function ShopInfoIntakeChecklistComponent({ form }) {
}
]}
>
<Select>
{Object.keys(ConfigFormTypes).map((i) => (
<Select.Option key={i} value={i}>
{i}
</Select.Option>
))}
</Select>
<Select options={Object.keys(ConfigFormTypes).map((i) => ({ value: i, label: i }))} />
</Form.Item>
<Form.Item
@@ -310,13 +298,13 @@ export default function ShopInfoIntakeChecklistComponent({ form }) {
}
]}
>
<Select mode="multiple">
{Object.keys(TemplateListGenerated).map((i) => (
<Select.Option key={TemplateListGenerated[i].key} value={TemplateListGenerated[i].key}>
{TemplateListGenerated[i].title}
</Select.Option>
))}
</Select>
<Select
mode="multiple"
options={Object.keys(TemplateListGenerated).map((i) => ({
value: TemplateListGenerated[i].key,
label: TemplateListGenerated[i].title
}))}
/>
</Form.Item>
<Form.Item
name={["deliverchecklist", "actual_delivery"]}

View File

@@ -80,13 +80,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
}
]}
>
<Select mode="multiple">
{options.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "pre_production_statuses"]}
@@ -99,13 +93,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
}
]}
>
<Select mode="multiple">
{options.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "production_statuses"]}
@@ -118,13 +106,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
}
]}
>
<Select mode="multiple">
{options.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "post_production_statuses"]}
@@ -137,13 +119,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
}
]}
>
<Select mode="multiple">
{options.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "ready_statuses"]}
@@ -156,13 +132,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
}
]}
>
<Select mode="multiple">
{options.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "additional_board_statuses"]}
@@ -175,13 +145,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
}
]}
>
<Select mode="multiple">
{options.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<LayoutFormRow noDivider>
<Form.Item
@@ -194,13 +158,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]}
name={["md_ro_statuses", "default_scheduled"]}
>
<Select>
{options.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.statuses.default_arrived")}
@@ -212,13 +170,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]}
name={["md_ro_statuses", "default_arrived"]}
>
<Select>
{options.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.statuses.default_exported")}
@@ -230,13 +182,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]}
name={["md_ro_statuses", "default_exported"]}
>
<Select>
{options.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.statuses.default_imported")}
@@ -248,13 +194,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]}
name={["md_ro_statuses", "default_imported"]}
>
<Select>
{options.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.statuses.default_invoiced")}
@@ -266,13 +206,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]}
name={["md_ro_statuses", "default_invoiced"]}
>
<Select>
{options.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.statuses.default_completed")}
@@ -284,13 +218,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]}
name={["md_ro_statuses", "default_completed"]}
>
<Select>
{options.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.statuses.default_delivered")}
@@ -302,13 +230,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]}
name={["md_ro_statuses", "default_delivered"]}
>
<Select>
{options.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.statuses.default_void")}
@@ -320,13 +242,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]}
name={["md_ro_statuses", "default_void"]}
>
<Select>
{options.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item>
</LayoutFormRow>
{Production_List_Status_Colors.treatment === "on" && (
@@ -352,13 +268,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
}
]}
>
<Select>
{productionStatus.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select options={productionStatus.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<DeleteFilled
onClick={() => {

View File

@@ -60,13 +60,13 @@ export default function ShopInfoSpeedPrint() {
}
]}
>
<Select mode="multiple">
{Object.keys(TemplateListGenerated).map((key, idx) => (
<Select.Option key={idx} value={TemplateListGenerated[key].key}>
{TemplateListGenerated[key].title}
</Select.Option>
))}
</Select>
<Select
mode="multiple"
options={Object.keys(TemplateListGenerated).map((key) => ({
value: TemplateListGenerated[key].key,
label: TemplateListGenerated[key].title
}))}
/>
</Form.Item>
<Space wrap>

View File

@@ -43,85 +43,43 @@ export function ShopInfoIntellipay({ bodyshop, form }) {
label={t("bodyshop.fields.intellipay_config.payment_map.visa")}
name={["intellipay_config", "payment_map", "visa"]}
>
<Select showSearch>
{bodyshop.md_payment_types.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select showSearch options={bodyshop.md_payment_types.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.intellipay_config.payment_map.mast")}
name={["intellipay_config", "payment_map", "mast"]}
>
<Select showSearch>
{bodyshop.md_payment_types.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select showSearch options={bodyshop.md_payment_types.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.intellipay_config.payment_map.amex")}
name={["intellipay_config", "payment_map", "amex"]}
>
<Select showSearch>
{bodyshop.md_payment_types.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select showSearch options={bodyshop.md_payment_types.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.intellipay_config.payment_map.disc")}
name={["intellipay_config", "payment_map", "disc"]}
>
<Select showSearch>
{bodyshop.md_payment_types.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select showSearch options={bodyshop.md_payment_types.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.intellipay_config.payment_map.dnrs")}
name={["intellipay_config", "payment_map", "dnrs"]}
>
<Select showSearch>
{bodyshop.md_payment_types.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select showSearch options={bodyshop.md_payment_types.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.intellipay_config.payment_map.jcb")}
name={["intellipay_config", "payment_map", "jcb"]}
>
<Select showSearch>
{bodyshop.md_payment_types.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select showSearch options={bodyshop.md_payment_types.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.intellipay_config.payment_map.intr")}
name={["intellipay_config", "payment_map", "intr"]}
>
<Select showSearch>
{bodyshop.md_payment_types.map((item, idx) => (
<Select.Option key={idx} value={item}>
{item}
</Select.Option>
))}
</Select>
<Select showSearch options={bodyshop.md_payment_types.map((item) => ({ value: item, label: item }))} />
</Form.Item>
</LayoutFormRow>
</>

View File

@@ -57,21 +57,23 @@ export function TechClockInComponent({ form, bodyshop, technician }) {
}
]}
>
<Select>
{emps &&
emps.rates.map((item) => (
<Select.Option key={item.cost_center} value={item.cost_center}>
{item.cost_center === "timetickets.labels.shift"
<Select
options={
emps &&
emps.rates.map((item) => ({
value: item.cost_center,
label:
item.cost_center === "timetickets.labels.shift"
? t(item.cost_center)
: bodyshop.cdk_dealerid ||
bodyshop.pbs_serialnumber ||
bodyshop.rr_dealerid ||
Enhanced_Payroll.treatment === "on"
? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`)
: item.cost_center}
</Select.Option>
))}
</Select>
: item.cost_center
}))
}
/>
</Form.Item>
</LayoutFormRow>
<Divider />

View File

@@ -201,22 +201,22 @@ export function TechClockOffButton({
}
]}
>
<Select disabled={isShiftTicket}>
{isShiftTicket ? (
<Select.Option value="timetickets.labels.shift">{t("timetickets.labels.shift")}</Select.Option>
) : (
emps &&
emps.rates.map((item) => (
<Select.Option key={item.cost_center}>
{item.cost_center === "timetickets.labels.shift"
? t(item.cost_center)
: hasDmsKey
? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`)
: item.cost_center}
</Select.Option>
))
)}
</Select>
<Select disabled={isShiftTicket}
options={
isShiftTicket
? [{ value: "timetickets.labels.shift", label: t("timetickets.labels.shift") }]
: emps &&
emps.rates.map((item) => ({
value: item.cost_center,
label:
item.cost_center === "timetickets.labels.shift"
? t(item.cost_center)
: hasDmsKey
? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`)
: item.cost_center
}))
}
/>
</Form.Item>
{isShiftTicket ? (
@@ -232,11 +232,12 @@ export function TechClockOffButton({
}
]}
>
<Select>
{bodyshop.md_ro_statuses.production_statuses.map((item) => (
<Select.Option key={item}></Select.Option>
))}
</Select>
<Select
options={bodyshop.md_ro_statuses.production_statuses.map((item) => ({
value: item,
label: item
}))}
/>
</Form.Item>
)}

View File

@@ -91,21 +91,22 @@ export function TimeTicketModalComponent({
value={value === "timetickets.labels.shift" ? t(value) : value}
{...props}
disabled={value === "timetickets.labels.shift" || disabled}
>
{emps &&
emps.rates.map((item) => (
<Select.Option key={item.cost_center} value={item.cost_center}>
{item.cost_center === "timetickets.labels.shift"
options={
emps &&
emps.rates.map((item) => ({
value: item.cost_center,
label:
item.cost_center === "timetickets.labels.shift"
? t(item.cost_center)
: bodyshop.cdk_dealerid ||
bodyshop.pbs_serialnumber ||
bodyshop.rr_dealerid ||
Enhanced_Payroll.treatment === "on"
? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`)
: item.cost_center}
</Select.Option>
))}
</Select>
: item.cost_center
}))
}
/>
);
const MemoInput = ({ value, ...props }) => (

View File

@@ -20,13 +20,15 @@ export function TimeTicketShiftFormComponent() {
}
]}
>
<Select>
<Select.Option value="timetickets.labels.amshift">{t("timetickets.labels.amshift")}</Select.Option>
<Select.Option value="timetickets.labels.ambreak">{t("timetickets.labels.ambreak")}</Select.Option>
<Select.Option value="timetickets.labels.lunch">{t("timetickets.labels.lunch")}</Select.Option>
<Select.Option value="timetickets.labels.pmshift">{t("timetickets.labels.pmshift")}</Select.Option>
<Select.Option value="timetickets.labels.pmbreak">{t("timetickets.labels.pmbreak")}</Select.Option>
</Select>
<Select
options={[
{ value: "timetickets.labels.amshift", label: t("timetickets.labels.amshift") },
{ value: "timetickets.labels.ambreak", label: t("timetickets.labels.ambreak") },
{ value: "timetickets.labels.lunch", label: t("timetickets.labels.lunch") },
{ value: "timetickets.labels.pmshift", label: t("timetickets.labels.pmshift") },
{ value: "timetickets.labels.pmbreak", label: t("timetickets.labels.pmbreak") }
]}
/>
</Form.Item>
</div>
);

View File

@@ -9,8 +9,6 @@ import {
} from "../../graphql/vehicles.queries";
import AlertComponent from "../alert/alert.component";
const { Option } = Select;
const VehicleSearchSelect = ({ value, onChange, onBlur, disabled, ref }) => {
const [callSearch, { loading, error, data }] = useLazyQuery(SEARCH_VEHICLES_FOR_AUTOCOMPLETE);
@@ -73,15 +71,12 @@ const VehicleSearchSelect = ({ value, onChange, onBlur, disabled, ref }) => {
onSelect={handleSelect}
notFoundContent={loading ? <LoadingOutlined /> : <Empty />}
onBlur={onBlur}
>
{theOptions
? theOptions.map((o) => (
<Option key={o.id} value={o.id}>
{`${o.v_vin || ""} ${o.v_model_yr || ""} ${o.v_make_desc || ""} ${o.v_model_desc || ""} `}
</Option>
))
: null}
</Select>
options={theOptions?.map((o) => ({
key: o.id,
value: o.id,
label: `${o.v_vin || ""} ${o.v_model_yr || ""} ${o.v_make_desc || ""} ${o.v_model_desc || ""} `
}))}
/>
{idLoading || loading ? <LoadingOutlined /> : null}
{error ? <AlertComponent title={error.message} type="error" /> : null}
{idError ? <AlertComponent title={idError.message} type="error" /> : null}

View File

@@ -3,8 +3,6 @@ import { Select, Space, Tag } from "antd";
import { useEffect, useState } from "react";
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
const { Option } = Select;
// To be used as a form element only.
const VendorSearchSelect = ({ value, onChange, options, onSelect, disabled, preferredMake, showPhone, ref }) => {
@@ -21,10 +19,57 @@ const VendorSearchSelect = ({ value, onChange, options, onSelect, disabled, pref
? options.filter((o) => o.favorite.filter((f) => f.toLowerCase() === preferredMake.toLowerCase()).length > 0)
: [];
const formatOption = (o, isFavorite = false) => ({
key: isFavorite ? `favorite-${o.id}` : o.id,
value: o.id,
name: o.name,
discount: o.discount,
label: (
<div
style={{
display: "flex",
alignItems: "center",
flexWrap: "nowrap",
width: "100%"
}}
>
<div
style={{
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}}
>
{o.name}
</div>
<Space style={{ marginLeft: "1rem" }}>
{isFavorite && <HeartOutlined style={{ color: "red" }} />}
{!isFavorite &&
o.tags?.map((tag, idx) => (
<Tag key={idx} style={{ marginLeft: "0.5rem" }}>
{tag}
</Tag>
))}
{o.phone && showPhone && <PhoneNumberFormatter>{o.phone}</PhoneNumberFormatter>}
{o.discount && o.discount !== 0 ? <Tag color="green">{`${o.discount * 100}%`}</Tag> : null}
</Space>
</div>
)
});
const allOptions = [
...(favorites?.map((o) => formatOption(o, true)) || []),
...(options?.map((o) => formatOption(o, false)) || [])
];
return (
<Select
ref={ref}
showSearch
showSearch={{
optionFilterProp: "name"
}}
value={option}
style={{
width: "100%"
@@ -59,76 +104,11 @@ const VendorSearchSelect = ({ value, onChange, options, onSelect, disabled, pref
}}
popupMatchSelectWidth={false}
onChange={setOption}
optionFilterProp="name"
onSelect={onSelect}
disabled={disabled || false}
optionLabelProp="name"
>
{favorites &&
favorites.map((o) => (
<Option key={`favorite-${o.id}`} value={o.id} name={o.name} discount={o.discount}>
<div
style={{
display: "flex",
alignItems: "center",
flexWrap: "nowrap",
width: "100%"
}}
>
<div
style={{
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}}
>
{o.name}
</div>
<Space style={{ marginLeft: "1rem" }}>
<HeartOutlined style={{ color: "red" }} />
{o.phone && showPhone && <PhoneNumberFormatter>{o.phone}</PhoneNumberFormatter>}
{o.discount && o.discount !== 0 ? <Tag color="green">{`${o.discount * 100}%`}</Tag> : null}
</Space>
</div>
</Option>
))}
{options &&
options.map((o) => (
<Option key={o.id} value={o.id} name={o.name} discount={o.discount}>
<div
style={{
display: "flex",
alignItems: "center",
flexWrap: "nowrap",
width: "100%"
}}
>
<div
style={{
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}}
>
{o.name}
</div>
<Space style={{ marginLeft: "1rem" }}>
{o.tags?.map((tag, idx) => (
<Tag key={idx} style={{ marginLeft: "0.5rem" }}>
{tag}
</Tag>
))}
{o.phone && showPhone && <PhoneNumberFormatter>{o.phone}</PhoneNumberFormatter>}
{o.discount && o.discount !== 0 ? <Tag color="green">{`${o.discount * 100}%`}</Tag> : null}
</Space>
</div>
</Option>
))}
</Select>
options={allOptions}
/>
);
};
export default VendorSearchSelect;

View File

@@ -470,6 +470,9 @@ export const GET_JOB_BY_PK = gql`
clm_total
comment
converted
dms_id
dms_customer_id
dms_advisor_id
csiinvites {
completedon
id
@@ -491,6 +494,9 @@ export const GET_JOB_BY_PK = gql`
ded_status
deliverchecklist
depreciation_taxes
dms_id
dms_advisor_id
dms_customer_id
driveable
employee_body
employee_body_rel {
@@ -1995,6 +2001,9 @@ export const QUERY_JOB_CLOSE_DETAILS = gql`
qb_multiple_payers
lbr_adjustments
ownr_ea
dms_id
dms_customer_id
dms_advisor_id
payments {
amount
created_at
@@ -2216,6 +2225,9 @@ export const QUERY_JOB_EXPORT_DMS = gql`
plate_no
plate_st
ownr_co_nm
dms_id
dms_customer_id
dms_advisor_id
}
}
`;

View File

@@ -119,12 +119,13 @@ export function DmsContainer({ setBreadcrumbs, setSelectedHeader }) {
setLogLevel(value);
socket.emit("set-log-level", value);
}}
>
<Select.Option key="DEBUG">DEBUG</Select.Option>
<Select.Option key="INFO">INFO</Select.Option>
<Select.Option key="WARN">WARN</Select.Option>
<Select.Option key="ERROR">ERROR</Select.Option>
</Select>
options={[
{ key: "DEBUG", value: "DEBUG", label: "DEBUG" },
{ key: "INFO", value: "INFO", label: "INFO" },
{ key: "WARN", value: "WARN", label: "WARN" },
{ key: "ERROR", value: "ERROR", label: "ERROR" }
]}
/>
<Button onClick={() => setLogs([])}>Clear Logs</Button>
<Button
onClick={() => {

View File

@@ -426,6 +426,24 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
if (data.jobs_by_pk?.date_exported) return <Result status="warning" title={t("dms.errors.alreadyexported")} />;
// Check if Reynolds mode requires early RO
const hasEarlyRO = !!(data.jobs_by_pk?.dms_id && data.jobs_by_pk?.dms_customer_id && data.jobs_by_pk?.dms_advisor_id);
if (isRrMode && !hasEarlyRO) {
return (
<Result
status="warning"
title={t("dms.errors.earlyrorequired")}
subTitle={t("dms.errors.earlyrorequired.message")}
extra={
<Link to={`/manage/jobs/${jobId}/admin`}>
<Button type="primary">{t("general.actions.gotoadmin")}</Button>
</Link>
}
/>
);
}
return (
<div>
<AlertComponent style={{ marginBottom: 10 }} title={bannerMessage} type="warning" showIcon closable />
@@ -486,6 +504,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
<DmsCustomerSelector
jobid={jobId}
job={data?.jobs_by_pk}
bodyshop={bodyshop}
socket={activeSocket}
mode={mode}
@@ -522,13 +541,14 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
setLogLevel(value);
setActiveLogLevel(value);
}}
>
<Select.Option key="SILLY">SILLY</Select.Option>
<Select.Option key="DEBUG">DEBUG</Select.Option>
<Select.Option key="INFO">INFO</Select.Option>
<Select.Option key="WARN">WARN</Select.Option>
<Select.Option key="ERROR">ERROR</Select.Option>
</Select>
options={[
{ key: "SILLY", value: "SILLY", label: "SILLY" },
{ key: "DEBUG", value: "DEBUG", label: "DEBUG" },
{ key: "INFO", value: "INFO", label: "INFO" },
{ key: "WARN", value: "WARN", label: "WARN" },
{ key: "ERROR", value: "ERROR", label: "ERROR" }
]}
/>
<Button onClick={() => setLogs([])}>Clear Logs</Button>
<Button
onClick={() => {

View File

@@ -1,9 +1,12 @@
import { useQuery } from "@apollo/client/react";
import { Card, Col, Result, Row, Space, Typography } from "antd";
import { useEffect } from "react";
import { useMutation, useQuery } from "@apollo/client/react";
import { Button, Card, Col, Form, Input, Modal, Result, Row, Select, Space, Switch, Typography } from "antd";
import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useParams } from "react-router-dom";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { some } from "lodash";
import axios from "axios";
import AlertComponent from "../../components/alert/alert.component";
import JobCalculateTotals from "../../components/job-calculate-totals/job-calculate-totals.component";
import ScoreboardAddButton from "../../components/job-scoreboard-add-button/job-scoreboard-add-button.component";
@@ -19,13 +22,26 @@ import JobsAdminRemoveAR from "../../components/jobs-admin-remove-ar/jobs-admin-
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component";
import NotFound from "../../components/not-found/not-found.component";
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
import { GET_JOB_BY_PK } from "../../graphql/jobs.queries";
import RREarlyROModal from "../../components/dms-post-form/rr-early-ro-modal";
import { GET_JOB_BY_PK, CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket";
import { useNotification } from "../../contexts/Notifications/notificationContext";
import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
setSelectedHeader: (key) => dispatch(setSelectedHeader(key)),
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
});
const colSpan = {
@@ -39,14 +55,36 @@ const cardStyle = {
height: "100%"
};
export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop, insertAuditTrail }) {
const { jobId } = useParams();
const { loading, error, data } = useQuery(GET_JOB_BY_PK, {
const { loading, error, data, refetch } = useQuery(GET_JOB_BY_PK, {
variables: { id: jobId },
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
const { t } = useTranslation();
const { socket } = useSocket(); // Extract socket from context
const notification = useNotification();
const [showEarlyROModal, setShowEarlyROModal] = useState(false);
const [showConvertModal, setShowConvertModal] = useState(false);
const [convertLoading, setConvertLoading] = useState(false);
const [form] = Form.useForm();
const [mutationConvertJob] = useMutation(CONVERT_JOB_TO_RO);
const allFormValues = Form.useWatch([], form);
// Get Fortellis treatment for proper DMS mode detection
const {
treatments: { Fortellis }
} = useTreatmentsWithConfig({
attributes: {},
names: ["Fortellis"],
splitKey: bodyshop?.imexshopid
});
// Check if bodyshop has Reynolds integration using the proper getDmsMode function
const dmsMode = getDmsMode(bodyshop, Fortellis.treatment);
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
const job = data?.jobs_by_pk;
useEffect(() => {
setSelectedHeader("activejobs");
document.title = t("titles.jobs-admin", {
@@ -75,6 +113,55 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
]);
}, [setBreadcrumbs, t, jobId, data, setSelectedHeader]);
const handleEarlyROSuccess = (result) => {
notification.success({
title: t("jobs.successes.early_ro_created"),
description: `RO Number: ${result.roNumber || "N/A"}`
});
setShowEarlyROModal(false);
refetch?.();
};
const handleConvert = async ({ employee_csr, category, ...values }) => {
if (!job?.id) return;
setConvertLoading(true);
const res = await mutationConvertJob({
variables: {
jobId: job.id,
job: {
converted: true,
...(bodyshop?.enforce_conversion_csr ? { employee_csr } : {}),
...(bodyshop?.enforce_conversion_category ? { category } : {}),
...values
}
}
});
if (values.ca_gst_registrant) {
await axios.post("/job/totalsssu", {
id: job.id
});
}
if (!res.errors) {
refetch();
notification.success({
title: t("jobs.successes.converted")
});
insertAuditTrail({
jobid: job.id,
operation: AuditTrailMapping.jobconverted(res.data.update_jobs.returning[0].ro_number),
type: "jobconverted"
});
setShowConvertModal(false);
}
setConvertLoading(false);
};
const submitDisabled = useCallback(() => some(allFormValues, (v) => v === undefined), [allFormValues]);
if (loading) return <LoadingSpinner />;
if (error) return <AlertComponent title={error.message} type="error" />;
if (!data.jobs_by_pk) return <NotFound />;
@@ -99,6 +186,16 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
<JobsAdminUnvoid job={data ? data.jobs_by_pk : {}} />
<JobsAdminStatus job={data ? data.jobs_by_pk : {}} />
<JobsAdminRemoveAR job={data ? data.jobs_by_pk : {}} />
{isReynoldsMode && job?.converted && !job?.dms_id && !job?.dms_customer_id && !job?.dms_advisor_id && (
<Button className="ant-btn ant-btn-default" onClick={() => setShowEarlyROModal(true)}>
{t("jobs.actions.dms.createearlyro", "Create RR RO")}
</Button>
)}
{isReynoldsMode && !job?.converted && !job?.dms_id && (
<Button type="primary" danger onClick={() => setShowConvertModal(true)}>
{t("jobs.actions.convertwithoutearlyro", "Convert without Early RO")}
</Button>
)}
</Space>
</Card>
</Col>
@@ -124,8 +221,173 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
</Card>
</Col>
</Row>
{/* Early RO Modal */}
<RREarlyROModal
open={showEarlyROModal}
onClose={() => setShowEarlyROModal(false)}
onSuccess={handleEarlyROSuccess}
bodyshop={bodyshop}
socket={socket}
job={job}
/>
{/* Convert without Early RO Modal */}
<Modal
open={showConvertModal}
onCancel={() => setShowConvertModal(false)}
title={t("jobs.actions.convertwithoutearlyro", "Convert without Early RO")}
footer={null}
width={700}
destroyOnHidden
>
<Form
layout="vertical"
form={form}
onFinish={handleConvert}
initialValues={{
driveable: true,
towin: job?.towin,
ca_gst_registrant: job?.ca_gst_registrant,
employee_csr: job?.employee_csr,
category: job?.category,
referral_source: job?.referral_source,
referral_source_extra: job?.referral_source_extra ?? ""
}}
>
<Form.Item
name={["ins_co_nm"]}
label={t("jobs.fields.ins_co_nm")}
rules={[
{
required: true
}
]}
>
<Select showSearch>
{bodyshop?.md_ins_cos?.map((s, i) => (
<Select.Option key={i} value={s.name}>
{s.name}
</Select.Option>
))}
</Select>
</Form.Item>
{bodyshop?.enforce_class && (
<Form.Item
name={"class"}
label={t("jobs.fields.class")}
rules={[
{
required: bodyshop.enforce_class
}
]}
>
<Select>
{bodyshop?.md_classes?.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop?.enforce_referral && (
<>
<Form.Item
name={"referral_source"}
label={t("jobs.fields.referralsource")}
rules={[
{
required: bodyshop.enforce_referral
}
]}
>
<Select>
{bodyshop?.md_referral_sources?.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
<Input />
</Form.Item>
</>
)}
{bodyshop?.enforce_conversion_csr && (
<Form.Item
name={"employee_csr"}
label={t(
InstanceRenderManager({
imex: "jobs.fields.employee_csr",
rome: "jobs.fields.employee_csr_writer"
})
)}
rules={[
{
required: bodyshop.enforce_conversion_csr
}
]}
>
<Select
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}}
style={{ width: 200 }}
>
{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>
</Form.Item>
)}
{bodyshop?.enforce_conversion_category && (
<Form.Item
name={"category"}
label={t("jobs.fields.category")}
rules={[
{
required: bodyshop.enforce_conversion_category
}
]}
>
<Select allowClear>
{bodyshop?.md_categories?.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop?.region_config?.toLowerCase().startsWith("ca") && (
<Form.Item label={t("jobs.fields.ca_gst_registrant")} name="ca_gst_registrant" valuePropName="checked">
<Switch />
</Form.Item>
)}
<Form.Item label={t("jobs.fields.driveable")} name="driveable" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t("jobs.fields.towin")} name="towin" valuePropName="checked">
<Switch />
</Form.Item>
<Space wrap style={{ marginTop: 16 }}>
<Button disabled={submitDisabled()} type="primary" danger onClick={() => form.submit()} loading={convertLoading}>
{t("jobs.actions.convert")}
</Button>
<Button onClick={() => setShowConvertModal(false)}>{t("general.actions.close")}</Button>
</Space>
</Form>
</Modal>
</RbacWrapper>
);
}
export default connect(null, mapDispatchToProps)(JobsCloseContainer);
export default connect(mapStateToProps, mapDispatchToProps)(JobsCloseContainer);

View File

@@ -9,6 +9,7 @@ import {
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Select,
@@ -42,7 +43,7 @@ import { setModalContext } from "../../redux/modals/modals.actions.js";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import dayjs from "../../utils/day";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -71,6 +72,11 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
const notification = useNotification();
const hasDMSKey = bodyshopHasDmsKey(bodyshop);
const dmsMode = getDmsMode(bodyshop, "off");
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
const hasEarlyRO = !!(job?.dms_id && job?.dms_customer_id && job?.dms_advisor_id);
const canSendToDMS = !isReynoldsMode || hasEarlyRO;
const [showEarlyROModal, setShowEarlyROModal] = useState(false);
const {
treatments: { Qb_Multi_Ar, ClosingPeriod }
@@ -82,18 +88,18 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
const handleFinish = async ({ removefromproduction, ...values }) => {
setLoading(true);
// Validate that all joblines have valid IDs
const joblinesWithIds = values.joblines.filter(jl => jl && jl.id);
const joblinesWithIds = values.joblines.filter((jl) => jl && jl.id);
if (joblinesWithIds.length !== values.joblines.length) {
notification.error({
title: t("jobs.errors.invalidjoblines"),
message: t("jobs.errors.missingjoblineids")
description: t("jobs.errors.missingjoblineids")
});
setLoading(false);
return;
}
const result = await client.mutate({
mutation: generateJobLinesUpdatesForInvoicing(values.joblines)
});
@@ -208,9 +214,17 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
</Button>
</Popconfirm>
{bodyshopHasDmsKey(bodyshop) && (
<Link to={`/manage/dms?jobId=${job.id}`}>
<Button disabled={job.date_exported || !jobRO}>{t("jobs.actions.sendtodms")}</Button>
</Link>
<>
{canSendToDMS ? (
<Link to={`/manage/dms?jobId=${job.id}`}>
<Button disabled={job.date_exported || !jobRO}>{t("jobs.actions.sendtodms")}</Button>
</Link>
) : (
<Button disabled={job.date_exported || !jobRO} onClick={() => setShowEarlyROModal(true)}>
{t("jobs.actions.sendtodms")}
</Button>
)}
</>
)}
<Button
onClick={() => {
@@ -426,13 +440,15 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
}
]}
>
<Select style={{ minWidth: "12rem" }} disabled={jobRO}>
{bodyshop.md_ins_cos.map((s) => (
<Select.Option key={s.name} value={s.name}>
{s.name}
</Select.Option>
))}
</Select>
<Select
style={{ minWidth: "12rem" }}
disabled={jobRO}
options={bodyshop.md_ins_cos.map((s) => ({
key: s.name,
value: s.name,
label: s.name
}))}
/>
</Form.Item>
<Form.Item
@@ -527,6 +543,30 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
<Divider />
<JobsCloseLines job={job} />
</Form>
{/* Early RO Required Modal */}
<Modal
open={showEarlyROModal}
onCancel={() => setShowEarlyROModal(false)}
footer={null}
title={
<Space>
<Typography.Text type="warning" style={{ fontSize: "1.2em" }}>
</Typography.Text>
<span>{t("dms.errors.earlyrorequired")}</span>
</Space>
}
>
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<Typography.Paragraph>{t("dms.errors.earlyrorequired.message")}</Typography.Paragraph>
<Link to={`/manage/jobs/${job.id}/admin`}>
<Button type="primary" block onClick={() => setShowEarlyROModal(false)}>
{t("general.actions.gotoadmin")}
</Button>
</Link>
</Space>
</Modal>
</div>
);
}

View File

@@ -1047,7 +1047,9 @@
},
"dms": {
"errors": {
"alreadyexported": "This job has already been sent to the DMS. If you need to resend it, please use admin permissions to mark the job for re-export."
"alreadyexported": "This job has already been sent to the DMS. If you need to resend it, please use admin permissions to mark the job for re-export.",
"earlyrorequired": "Early RO Required",
"earlyrorequired.message": "This job requires an early Repair Order to be created before posting to Reynolds. Please use the admin panel to create the early RO first."
},
"labels": {
"refreshallocations": "Refresh to see DMS Allocations."
@@ -1244,6 +1246,7 @@
"deselectall": "Deselect All",
"download": "Download",
"edit": "Edit",
"gotoadmin": "Go to Admin Panel",
"login": "Login",
"next": "Next",
"ok": "Ok",
@@ -1622,11 +1625,13 @@
"changestatus": "Change Status",
"changestimator": "Change Estimator",
"convert": "Convert",
"convertwithoutearlyro": "Convert without Early RO",
"createiou": "Create IOU",
"deliver": "Deliver",
"deliver_quick": "Quick Deliver",
"dms": {
"addpayer": "Add Payer",
"createearlyro": "Create RR RO",
"createnewcustomer": "Create New Customer",
"findmakemodelcode": "Find Make/Model Code",
"getmakes": "Get Makes",
@@ -1635,6 +1640,7 @@
},
"post": "Post",
"refetchmakesmodels": "Refetch Make and Model Codes",
"update_ro": "Update RO",
"usegeneric": "Use Generic Customer",
"useselected": "Use Selected Customer"
},
@@ -1794,6 +1800,7 @@
},
"cost": "Cost",
"cost_dms_acctnumber": "Cost DMS Acct #",
"customer": "Customer #",
"dms_make": "DMS Make",
"dms_model": "DMS Model",
"dms_model_override": "Override DMS Make/Model",
@@ -1818,7 +1825,11 @@
"sale": "Sale",
"sale_dms_acctnumber": "Sale DMS Acct #",
"story": "Story",
"vinowner": "VIN Owner"
"vinowner": "VIN Owner",
"rr_opcode": "RR OpCode",
"rr_opcode_prefix": "Prefix",
"rr_opcode_suffix": "Suffix",
"rr_opcode_base": "Base"
},
"dms_allocation": "DMS Allocation",
"driveable": "Driveable",
@@ -2103,6 +2114,11 @@
"damageto": "Damage to $t(jobs.fields.area_of_damage_impact.{{area_of_damage}}).",
"defaultstory": "B/S RO: {{ro_number}}. Owner: {{ownr_nm}}. Insurance Co: {{ins_co_nm}}. Claim/PO #: {{clm_po}}",
"disablebillwip": "Cost and WIP for bills has been ignored per shop configuration.",
"earlyro": {
"created": "Early RO Created:",
"fields": "Required fields:",
"willupdate": "This will update the existing RO with full job data."
},
"invoicedatefuture": "Invoice date must be today or in the future for CDK posting.",
"kmoutnotgreaterthankmin": "Mileage out must be greater than mileage in.",
"logs": "Logs",
@@ -2260,6 +2276,7 @@
"delete": "Job deleted successfully.",
"deleted": "Job deleted successfully.",
"duplicated": "Job duplicated successfully. ",
"early_ro_created": "Early RO Created",
"exported": "Job(s) exported successfully. ",
"invoiced": "Job closed and invoiced successfully.",
"ioucreated": "IOU created successfully. Click to see.",

View File

@@ -1047,7 +1047,9 @@
},
"dms": {
"errors": {
"alreadyexported": ""
"alreadyexported": "",
"earlyrorequired": "",
"earlyrorequired.message": ""
},
"labels": {
"refreshallocations": ""
@@ -1244,6 +1246,7 @@
"deselectall": "",
"download": "",
"edit": "Editar",
"gotoadmin": "",
"login": "",
"next": "",
"ok": "",
@@ -1622,11 +1625,13 @@
"changestatus": "Cambiar Estado",
"changestimator": "",
"convert": "Convertir",
"convertwithoutearlyro": "",
"createiou": "",
"deliver": "",
"deliver_quick": "",
"dms": {
"addpayer": "",
"createearlyro": "",
"createnewcustomer": "",
"findmakemodelcode": "",
"getmakes": "",
@@ -1635,6 +1640,7 @@
},
"post": "",
"refetchmakesmodels": "",
"update_ro": "",
"usegeneric": "",
"useselected": ""
},
@@ -1794,6 +1800,7 @@
},
"cost": "",
"cost_dms_acctnumber": "",
"customer": "",
"dms_make": "",
"dms_model": "",
"dms_model_override": "",
@@ -1818,7 +1825,11 @@
"sale": "",
"sale_dms_acctnumber": "",
"story": "",
"vinowner": ""
"vinowner": "",
"rr_opcode": "",
"rr_opcode_prefix": "",
"rr_opcode_suffix": "",
"rr_opcode_base": ""
},
"dms_allocation": "",
"driveable": "",
@@ -2103,6 +2114,11 @@
"damageto": "",
"defaultstory": "",
"disablebillwip": "",
"earlyro": {
"created": "",
"fields": "",
"willupdate": ""
},
"invoicedatefuture": "",
"kmoutnotgreaterthankmin": "",
"logs": "",
@@ -2260,6 +2276,7 @@
"delete": "",
"deleted": "Trabajo eliminado con éxito.",
"duplicated": "",
"early_ro_created": "",
"exported": "",
"invoiced": "",
"ioucreated": "",

View File

@@ -1047,7 +1047,9 @@
},
"dms": {
"errors": {
"alreadyexported": ""
"alreadyexported": "",
"earlyrorequired": "",
"earlyrorequired.message": ""
},
"labels": {
"refreshallocations": ""
@@ -1244,6 +1246,7 @@
"deselectall": "",
"download": "",
"edit": "modifier",
"gotoadmin": "",
"login": "",
"next": "",
"ok": "",
@@ -1622,11 +1625,13 @@
"changestatus": "Changer le statut",
"changestimator": "",
"convert": "Convertir",
"convertwithoutearlyro": "",
"createiou": "",
"deliver": "",
"deliver_quick": "",
"dms": {
"addpayer": "",
"createearlyro": "",
"createnewcustomer": "",
"findmakemodelcode": "",
"getmakes": "",
@@ -1635,6 +1640,7 @@
},
"post": "",
"refetchmakesmodels": "",
"update_ro": "",
"usegeneric": "",
"useselected": ""
},
@@ -1794,6 +1800,7 @@
},
"cost": "",
"cost_dms_acctnumber": "",
"customer": "",
"dms_make": "",
"dms_model": "",
"dms_model_override": "",
@@ -1818,7 +1825,11 @@
"sale": "",
"sale_dms_acctnumber": "",
"story": "",
"vinowner": ""
"vinowner": "",
"rr_opcode": "",
"rr_opcode_prefix": "",
"rr_opcode_suffix": "",
"rr_opcode_base": ""
},
"dms_allocation": "",
"driveable": "",
@@ -2103,6 +2114,11 @@
"damageto": "",
"defaultstory": "",
"disablebillwip": "",
"earlyro": {
"created": "",
"fields": "",
"willupdate": ""
},
"invoicedatefuture": "",
"kmoutnotgreaterthankmin": "",
"logs": "",
@@ -2260,6 +2276,7 @@
"delete": "",
"deleted": "Le travail a bien été supprimé.",
"duplicated": "",
"early_ro_created": "",
"exported": "",
"invoiced": "",
"ioucreated": "",

View File

@@ -1,44 +1,43 @@
import { Select } from "antd";
import i18n from "../translations/i18n";
export default function CiecaSelect(parts = true, labor = true) {
return (
<>
{labor && (
<>
<Select.Option value="LAA">{i18n.t("joblines.fields.lbr_types.LAA")}</Select.Option>
<Select.Option value="LAB">{i18n.t("joblines.fields.lbr_types.LAB")}</Select.Option>
<Select.Option value="LAD">{i18n.t("joblines.fields.lbr_types.LAD")}</Select.Option>
<Select.Option value="LAE">{i18n.t("joblines.fields.lbr_types.LAE")}</Select.Option>
<Select.Option value="LAF">{i18n.t("joblines.fields.lbr_types.LAF")}</Select.Option>
<Select.Option value="LAG">{i18n.t("joblines.fields.lbr_types.LAG")}</Select.Option>
<Select.Option value="LAM">{i18n.t("joblines.fields.lbr_types.LAM")}</Select.Option>
<Select.Option value="LAR">{i18n.t("joblines.fields.lbr_types.LAR")}</Select.Option>
<Select.Option value="LAS">{i18n.t("joblines.fields.lbr_types.LAS")}</Select.Option>
<Select.Option value="LAU">{i18n.t("joblines.fields.lbr_types.LAU")}</Select.Option>
<Select.Option value="LA1">{i18n.t("joblines.fields.lbr_types.LA1")}</Select.Option>
<Select.Option value="LA2">{i18n.t("joblines.fields.lbr_types.LA2")}</Select.Option>
<Select.Option value="LA3">{i18n.t("joblines.fields.lbr_types.LA3")}</Select.Option>
<Select.Option value="LA4">{i18n.t("joblines.fields.lbr_types.LA4")}</Select.Option>
</>
)}
{parts && (
<>
<Select.Option value="PAA">{i18n.t("joblines.fields.part_types.PAA")}</Select.Option>
<Select.Option value="PAC">{i18n.t("joblines.fields.part_types.PAC")}</Select.Option>
<Select.Option value="PAL">{i18n.t("joblines.fields.part_types.PAL")}</Select.Option>
<Select.Option value="PAG">{i18n.t("joblines.fields.part_types.PAG")}</Select.Option>
<Select.Option value="PAM">{i18n.t("joblines.fields.part_types.PAM")}</Select.Option>
<Select.Option value="PAP">{i18n.t("joblines.fields.part_types.PAP")}</Select.Option>
<Select.Option value="PAN">{i18n.t("joblines.fields.part_types.PAN")}</Select.Option>
<Select.Option value="PAO">{i18n.t("joblines.fields.part_types.PAO")}</Select.Option>
<Select.Option value="PAR">{i18n.t("joblines.fields.part_types.PAR")}</Select.Option>
<Select.Option value="PAS">{i18n.t("joblines.fields.part_types.PAS")}</Select.Option>
</>
)}
</>
);
const options = [];
if (labor) {
options.push(
{ value: "LAA", label: i18n.t("joblines.fields.lbr_types.LAA") },
{ value: "LAB", label: i18n.t("joblines.fields.lbr_types.LAB") },
{ value: "LAD", label: i18n.t("joblines.fields.lbr_types.LAD") },
{ value: "LAE", label: i18n.t("joblines.fields.lbr_types.LAE") },
{ value: "LAF", label: i18n.t("joblines.fields.lbr_types.LAF") },
{ value: "LAG", label: i18n.t("joblines.fields.lbr_types.LAG") },
{ value: "LAM", label: i18n.t("joblines.fields.lbr_types.LAM") },
{ value: "LAR", label: i18n.t("joblines.fields.lbr_types.LAR") },
{ value: "LAS", label: i18n.t("joblines.fields.lbr_types.LAS") },
{ value: "LAU", label: i18n.t("joblines.fields.lbr_types.LAU") },
{ value: "LA1", label: i18n.t("joblines.fields.lbr_types.LA1") },
{ value: "LA2", label: i18n.t("joblines.fields.lbr_types.LA2") },
{ value: "LA3", label: i18n.t("joblines.fields.lbr_types.LA3") },
{ value: "LA4", label: i18n.t("joblines.fields.lbr_types.LA4") }
);
}
if (parts) {
options.push(
{ value: "PAA", label: i18n.t("joblines.fields.part_types.PAA") },
{ value: "PAC", label: i18n.t("joblines.fields.part_types.PAC") },
{ value: "PAL", label: i18n.t("joblines.fields.part_types.PAL") },
{ value: "PAG", label: i18n.t("joblines.fields.part_types.PAG") },
{ value: "PAM", label: i18n.t("joblines.fields.part_types.PAM") },
{ value: "PAP", label: i18n.t("joblines.fields.part_types.PAP") },
{ value: "PAN", label: i18n.t("joblines.fields.part_types.PAN") },
{ value: "PAO", label: i18n.t("joblines.fields.part_types.PAO") },
{ value: "PAR", label: i18n.t("joblines.fields.part_types.PAR") },
{ value: "PAS", label: i18n.t("joblines.fields.part_types.PAS") }
);
}
return options;
}
export function GetPartTypeName(part_type) {

View File

@@ -146,7 +146,8 @@ export async function generateTemplate(
if (templateQueryToExecute) {
const { data } = await client.query({
query: gql(finalQuery),
variables: { ...templateObject.variables }
variables: { ...templateObject.variables },
fetchPolicy: "no-cache"
});
contextData = data;
}

View File

@@ -38,8 +38,6 @@ services:
condition: service_healthy
localstack:
condition: service_healthy
aws-cli:
condition: service_completed_successfully
ports:
- "4001:4000" # Different external port for local access
volumes:
@@ -65,8 +63,6 @@ services:
condition: service_healthy
localstack:
condition: service_healthy
aws-cli:
condition: service_completed_successfully
ports:
- "4002:4000" # Different external port for local access
volumes:
@@ -92,8 +88,6 @@ services:
condition: service_healthy
localstack:
condition: service_healthy
aws-cli:
condition: service_completed_successfully
ports:
- "4003:4000" # Different external port for local access
volumes:
@@ -156,23 +150,18 @@ services:
# LocalStack
localstack:
image: localstack/localstack
image: localstack/localstack:4.13.1
container_name: localstack
hostname: localstack
networks:
- redis-cluster-net
restart: unless-stopped
volumes:
- ./certs:/tmp/certs:ro # only if your script reads /tmp/certs/...
- ./localstack/init:/etc/localstack/init/ready.d:ro
- /var/run/docker.sock:/var/run/docker.sock
environment:
- SERVICES=s3,ses,secretsmanager,cloudwatch,logs
- DEBUG=0
- AWS_ACCESS_KEY_ID=test
- AWS_SECRET_ACCESS_KEY=test
- AWS_DEFAULT_REGION=ca-central-1
- EXTRA_CORS_ALLOWED_HEADERS=Authorization,Content-Type
- EXTRA_CORS_ALLOWED_ORIGINS=*
- EXTRA_CORS_EXPOSE_HEADERS=Authorization,Content-Type
env_file:
- .env.localstack.docker
ports:
- "4566:4566"
healthcheck:
@@ -182,36 +171,6 @@ services:
retries: 5
start_period: 20s
# AWS-CLI
aws-cli:
image: amazon/aws-cli
container_name: aws-cli
hostname: aws-cli
networks:
- redis-cluster-net
depends_on:
localstack:
condition: service_healthy
volumes:
- './localstack:/tmp/localstack'
- './certs:/tmp/certs'
environment:
- AWS_ACCESS_KEY_ID=test
- AWS_SECRET_ACCESS_KEY=test
- AWS_DEFAULT_REGION=ca-central-1
entrypoint: /bin/sh -c
command: >
"
aws --endpoint-url=http://localstack:4566 ses verify-domain-identity --domain imex.online --region ca-central-1
aws --endpoint-url=http://localstack:4566 ses verify-email-identity --email-address noreply@imex.online --region ca-central-1
aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key
aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rome-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rps-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
"
networks:
redis-cluster-net:
driver: bridge

View File

@@ -68,23 +68,18 @@ services:
# LocalStack: Used to emulate AWS services locally, currently setup for SES
# Notes: Set the ENV Debug to 1 for additional logging
localstack:
image: localstack/localstack
image: localstack/localstack:4.13.1
container_name: localstack
hostname: localstack
networks:
- redis-cluster-net
restart: unless-stopped
volumes:
- ./certs:/tmp/certs:ro # only if your script reads /tmp/certs/...
- ./localstack/init:/etc/localstack/init/ready.d:ro
- /var/run/docker.sock:/var/run/docker.sock
environment:
- SERVICES=s3,ses,secretsmanager,cloudwatch,logs
- DEBUG=0
- AWS_ACCESS_KEY_ID=test
- AWS_SECRET_ACCESS_KEY=test
- AWS_DEFAULT_REGION=ca-central-1
- EXTRA_CORS_ALLOWED_HEADERS=Authorization,Content-Type
- EXTRA_CORS_ALLOWED_ORIGINS=*
- EXTRA_CORS_EXPOSE_HEADERS=Authorization,Content-Type
env_file:
- .env.localstack.docker
ports:
- "4566:4566"
healthcheck:
@@ -94,38 +89,6 @@ services:
retries: 5
start_period: 20s
# AWS-CLI - Used in conjunction with LocalStack to set required permission to send emails
aws-cli:
image: amazon/aws-cli
container_name: aws-cli
hostname: aws-cli
networks:
- redis-cluster-net
depends_on:
localstack:
condition: service_healthy
volumes:
- './localstack:/tmp/localstack'
- './certs:/tmp/certs'
environment:
- AWS_ACCESS_KEY_ID=test
- AWS_SECRET_ACCESS_KEY=test
- AWS_DEFAULT_REGION=ca-central-1
entrypoint: /bin/sh -c
command: >
"
aws --endpoint-url=http://localstack:4566 ses verify-domain-identity --domain imex.online --region ca-central-1
aws --endpoint-url=http://localstack:4566 ses verify-email-identity --email-address noreply@imex.online --region ca-central-1
aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key
aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-job-totals --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket parts-estimates --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rome-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rps-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
"
# Node App: The Main IMEX API
node-app:
build:
@@ -145,8 +108,7 @@ services:
condition: service_healthy
localstack:
condition: service_healthy
aws-cli:
condition: service_completed_successfully
ports:
- "4000:4000"
- "9229:9229"

View File

@@ -947,6 +947,7 @@
- carfax_exclude
- cdk_configuration
- cdk_dealerid
- chatter_company_id
- chatterid
- city
- claimscorpid
@@ -1063,6 +1064,7 @@
- bill_allow_post_to_closed
- bill_tax_rates
- cdk_configuration
- chatter_company_id
- city
- country
- created_at
@@ -3702,7 +3704,9 @@
- ded_status
- deliverchecklist
- depreciation_taxes
- dms_advisor_id
- dms_allocation
- dms_customer_id
- dms_id
- driveable
- employee_body
@@ -3983,7 +3987,9 @@
- ded_status
- deliverchecklist
- depreciation_taxes
- dms_advisor_id
- dms_allocation
- dms_customer_id
- dms_id
- driveable
- employee_body
@@ -4276,7 +4282,9 @@
- ded_status
- deliverchecklist
- depreciation_taxes
- dms_advisor_id
- dms_allocation
- dms_customer_id
- dms_id
- driveable
- employee_body

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."bodyshops" add column "chatter_company_id" text
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" add column "chatter_company_id" text
null;

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."jobs" add column "dms_customer_id" text
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."jobs" add column "dms_customer_id" text
null;

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."jobs" add column "dms_advisor_id" text
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."jobs" add column "dms_advisor_id" text
null;

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
echo "Running LocalStack bootstrap script: 10-bootstrap.sh"
set -euo pipefail
REGION="${AWS_DEFAULT_REGION:-ca-central-1}"
# awslocal is the LocalStack wrapper so you don't need --endpoint-url
# (it targets the LocalStack gateway automatically)
# Docs: https://docs.localstack.cloud/.../aws-cli/
ensure_bucket() {
local b="$1"
if ! awslocal s3api head-bucket --bucket "$b" >/dev/null 2>&1; then
awslocal s3api create-bucket \
--bucket "$b" \
--create-bucket-configuration LocationConstraint="$REGION" \
--region "$REGION" >/dev/null
fi
}
ensure_log_group() {
local lg="$1"
awslocal logs create-log-group --log-group-name "$lg" --region "$REGION" >/dev/null 2>&1 || true
}
ensure_secret_string() {
local name="$1"
local value="$2"
if awslocal secretsmanager describe-secret --secret-id "$name" >/dev/null 2>&1; then
awslocal secretsmanager update-secret --secret-id "$name" --secret-string "$value" >/dev/null
else
awslocal secretsmanager create-secret --name "$name" --secret-string "$value" >/dev/null
fi
}
ensure_secret_file() {
local name="$1"
local filepath="$2"
if awslocal secretsmanager describe-secret --secret-id "$name" >/dev/null 2>&1; then
awslocal secretsmanager update-secret --secret-id "$name" --secret-string "file://$filepath" >/dev/null
else
awslocal secretsmanager create-secret --name "$name" --secret-string "file://$filepath" >/dev/null
fi
}
# SES identities (idempotent-ish; ignoring if it already exists)
awslocal ses verify-domain-identity --domain imex.online --region "$REGION" >/dev/null || true
awslocal ses verify-email-identity --email-address noreply@imex.online --region "$REGION" >/dev/null || true
# Secrets
ensure_secret_file "CHATTER_PRIVATE_KEY" "/tmp/certs/io-ftp-test.key"
ensure_secret_string "CHATTER_COMPANY_KEY_6713" "${CHATTER_COMPANY_KEY_6713:-REPLACE_ME}"
# Logs
ensure_log_group "development"
# Buckets
ensure_bucket "imex-job-totals"
ensure_bucket "parts-estimate"
ensure_bucket "imex-large-log"
ensure_bucket "imex-carfax-uploads"
ensure_bucket "rome-carfax-uploads"
ensure_bucket "rps-carfax-uploads"

1062
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,23 +18,23 @@
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
},
"dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.978.0",
"@aws-sdk/client-elasticache": "^3.978.0",
"@aws-sdk/client-s3": "^3.978.0",
"@aws-sdk/client-secrets-manager": "^3.978.0",
"@aws-sdk/client-ses": "^3.978.0",
"@aws-sdk/credential-provider-node": "^3.972.3",
"@aws-sdk/lib-storage": "^3.978.0",
"@aws-sdk/s3-request-presigner": "^3.978.0",
"@aws-sdk/client-cloudwatch-logs": "^3.992.0",
"@aws-sdk/client-elasticache": "^3.992.0",
"@aws-sdk/client-s3": "^3.992.0",
"@aws-sdk/client-secrets-manager": "^3.992.0",
"@aws-sdk/client-ses": "^3.992.0",
"@aws-sdk/credential-provider-node": "^3.972.9",
"@aws-sdk/lib-storage": "^3.992.0",
"@aws-sdk/s3-request-presigner": "^3.992.0",
"@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0",
"archiver": "^7.0.1",
"aws4": "^1.13.2",
"axios": "^1.13.4",
"axios": "^1.13.5",
"axios-curlirize": "^2.0.0",
"better-queue": "^3.8.12",
"bullmq": "^5.67.2",
"bullmq": "^5.69.3",
"chart.js": "^4.5.1",
"cloudinary": "^2.9.0",
"compression": "^1.8.1",
@@ -42,17 +42,17 @@
"cors": "^2.8.6",
"crisp-status-reporter": "^1.2.2",
"dinero.js": "^1.9.1",
"dotenv": "^17.2.3",
"dotenv": "^17.3.1",
"express": "^4.21.1",
"fast-xml-parser": "^5.3.4",
"firebase-admin": "^13.6.0",
"fast-xml-parser": "^5.3.6",
"firebase-admin": "^13.6.1",
"graphql": "^16.12.0",
"graphql-request": "^6.1.0",
"intuit-oauth": "^4.2.2",
"ioredis": "^5.9.2",
"ioredis": "^5.9.3",
"json-2-csv": "^5.5.10",
"jsonwebtoken": "^9.0.3",
"juice": "^11.1.0",
"juice": "^11.1.1",
"lodash": "^4.17.23",
"moment": "^2.30.1",
"moment-timezone": "^0.6.0",
@@ -63,13 +63,13 @@
"phone": "^3.1.70",
"query-string": "7.1.3",
"recursive-diff": "^1.0.9",
"rimraf": "^6.1.2",
"rimraf": "^6.1.3",
"skia-canvas": "^3.0.8",
"soap": "^1.6.4",
"soap": "^1.7.1",
"socket.io": "^4.8.3",
"socket.io-adapter": "^2.5.6",
"ssh2-sftp-client": "^11.0.0",
"twilio": "^5.12.0",
"twilio": "^5.12.2",
"uuid": "^11.1.0",
"winston": "^3.19.0",
"winston-cloudwatch": "^6.3.0",
@@ -82,7 +82,7 @@
"@eslint/js": "^9.39.2",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.2.0",
"globals": "^17.3.0",
"mock-require": "^3.0.3",
"p-limit": "^3.1.0",
"prettier": "^3.8.1",

View File

@@ -40,6 +40,8 @@ const { loadEmailQueue } = require("./server/notifications/queues/emailQueue");
const { loadAppQueue } = require("./server/notifications/queues/appQueue");
const { SetLegacyWebsocketHandlers } = require("./server/web-sockets/web-socket");
const { loadFcmQueue } = require("./server/notifications/queues/fcmQueue");
const { loadChatterApiQueue } = require("./server/data/queues/chatterApiQueue");
const { processChatterApiJob } = require("./server/data/chatter-api");
const CLUSTER_RETRY_BASE_DELAY = 100;
const CLUSTER_RETRY_MAX_DELAY = 5000;
@@ -125,6 +127,7 @@ const applyRoutes = ({ app }) => {
app.use("/payroll", require("./server/routes/payrollRoutes"));
app.use("/sso", require("./server/routes/ssoRoutes"));
app.use("/integrations", require("./server/routes/intergrationRoutes"));
app.use("/chatter", require("./server/routes/chatterRoutes"));
// Default route for forbidden access
app.get("/", (req, res) => {
@@ -390,6 +393,15 @@ const applySocketIO = async ({ server, app }) => {
const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
const queueSettings = { pubClient, logger, redisHelpers, ioRedis };
// Load chatterApi queue with processJob function and redis helpers
const chatterApiQueue = await loadChatterApiQueue({
pubClient,
logger,
processJob: processChatterApiJob,
getChatterToken: redisHelpers.getChatterToken,
setChatterToken: redisHelpers.setChatterToken
});
// Assuming loadEmailQueue and loadAppQueue return Promises
const [notificationsEmailsQueue, notificationsAppQueue, notificationsFcmQueue] = await Promise.all([
loadEmailQueue(queueSettings),
@@ -409,6 +421,10 @@ const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
notificationsFcmQueue.on("error", (error) => {
logger.log(`Error in notificationsFCMQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message });
});
chatterApiQueue.on("error", (error) => {
logger.log(`Error in chatterApiQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message });
});
};
/**

View File

@@ -0,0 +1,139 @@
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
const { defaultProvider } = require("@aws-sdk/credential-provider-node");
const { isString, isEmpty } = require("lodash");
const CHATTER_BASE_URL = process.env.CHATTER_API_BASE_URL || "https://api.chatterresearch.com";
const AWS_REGION = process.env.AWS_REGION || "ca-central-1";
// Configure SecretsManager client with localstack support
const secretsClientOptions = {
region: AWS_REGION,
credentials: defaultProvider()
};
const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME);
if (isLocal) {
secretsClientOptions.endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:4566`;
}
const secretsClient = new SecretsManagerClient(secretsClientOptions);
/**
* Chatter API Client for making requests to the Chatter API
*/
class ChatterApiClient {
constructor({ baseUrl, apiToken }) {
if (!apiToken) throw new Error("ChatterApiClient requires apiToken");
this.baseUrl = String(baseUrl || "").replace(/\/+$/, "");
this.apiToken = apiToken;
}
async createLocation(companyId, payload) {
return this.request(`/api/v1/companies/${companyId}/locations`, {
method: "POST",
body: payload
});
}
async postInteraction(companyId, payload) {
return this.request(`/api/v1/companies/${companyId}/solicitation/interaction`, {
method: "POST",
body: payload
});
}
async request(path, { method = "GET", body } = {}) {
const res = await fetch(this.baseUrl + path, {
method,
headers: {
"Api-Token": this.apiToken,
Accept: "application/json",
...(body ? { "Content-Type": "application/json" } : {})
},
body: body ? JSON.stringify(body) : undefined
});
const text = await res.text();
const data = text ? safeJson(text) : null;
if (!res.ok) {
const err = new Error(`Chatter API error ${res.status} | ${data?.message}`);
err.status = res.status;
err.data = data;
const retryAfterMs = parseRetryAfterMs(res.headers.get("retry-after"));
if (retryAfterMs != null) err.retryAfterMs = retryAfterMs;
throw err;
}
return data;
}
}
/**
* Safely parse JSON, returning original text if parsing fails
*/
function safeJson(text) {
try {
return JSON.parse(text);
} catch {
return text;
}
}
function parseRetryAfterMs(value) {
if (!value) return null;
const sec = Number(value);
if (Number.isFinite(sec) && sec >= 0) return Math.ceil(sec * 1000);
const dateMs = Date.parse(value);
if (!Number.isFinite(dateMs)) return null;
return Math.max(0, dateMs - Date.now());
}
/**
* Fetches Chatter API token from AWS Secrets Manager
* SecretId format: CHATTER_COMPANY_KEY_<companyId>
*
* @param {string|number} companyId - The company ID
* @returns {Promise<string>} The API token
*/
async function getChatterApiToken(companyId) {
const key = String(companyId ?? "").trim();
if (!key) throw new Error("getChatterApiToken: companyId is required");
// Optional override for development/testing
if (process.env.CHATTER_API_TOKEN) return process.env.CHATTER_API_TOKEN;
const secretId = `CHATTER_COMPANY_KEY_${key}`;
const command = new GetSecretValueCommand({ SecretId: secretId });
const { SecretString, SecretBinary } = await secretsClient.send(command);
const token =
(SecretString && SecretString.trim()) ||
(SecretBinary && Buffer.from(SecretBinary, "base64").toString("ascii").trim()) ||
"";
if (!token) throw new Error(`Chatter API token secret is empty: ${secretId}`);
return token;
}
/**
* Creates a Chatter API client instance
*
* @param {string|number} companyId - The company ID
* @param {string} [baseUrl] - Optional base URL override
* @returns {Promise<ChatterApiClient>} Configured API client
*/
async function createChatterClient(companyId, baseUrl = CHATTER_BASE_URL) {
const apiToken = await getChatterApiToken(companyId);
return new ChatterApiClient({ baseUrl, apiToken });
}
module.exports = {
ChatterApiClient,
getChatterApiToken,
createChatterClient,
safeJson,
CHATTER_BASE_URL
};

View File

@@ -0,0 +1,123 @@
const DEFAULT_COMPANY_ID = process.env.CHATTER_DEFAULT_COMPANY_ID;
const client = require("../graphql-client/graphql-client").client;
const { createChatterClient } = require("./chatter-client");
const InstanceManager = require("../utils/instanceMgr").default;
const GET_BODYSHOP_FOR_CHATTER = `
query GET_BODYSHOP_FOR_CHATTER($id: uuid!) {
bodyshops_by_pk(id: $id) {
id
shopname
address1
city
state
zip_post
imexshopid
chatterid
chatter_company_id
}
}
`;
const UPDATE_BODYSHOP_CHATTER_FIELDS = `
mutation UPDATE_BODYSHOP_CHATTER_FIELDS($id: uuid!, $chatter_company_id: String!, $chatterid: String!) {
update_bodyshops_by_pk(pk_columns: {id: $id}, _set: {chatter_company_id: $chatter_company_id, chatterid: $chatterid}) {
id
chatter_company_id
chatterid
}
}
`;
const createLocation = async (req, res) => {
const { logger } = req;
const { bodyshopID, googlePlaceID } = req.body;
console.dir({ body: req.body });
if (!DEFAULT_COMPANY_ID) {
logger.log("chatter-create-location-no-default-company", "warn", null, null, { bodyshopID });
return res.json({ success: false, message: "No default company set" });
}
if (!googlePlaceID) {
logger.log("chatter-create-location-no-google-place-id", "warn", null, null, { bodyshopID });
return res.json({ success: false, message: "No google place id provided" });
}
if (!bodyshopID) {
logger.log("chatter-create-location-invalid-bodyshop", "warn", null, null, { bodyshopID });
return res.json({ success: false, message: "No bodyshop id" });
}
try {
const { bodyshops_by_pk: bodyshop } = await client.request(GET_BODYSHOP_FOR_CHATTER, { id: bodyshopID });
if (!bodyshop) {
logger.log("chatter-create-location-bodyshop-not-found", "warn", null, null, { bodyshopID });
return res.json({ success: false, message: "Bodyshop not found" });
}
if (bodyshop.chatter_company_id && bodyshop.chatterid) {
logger.log("chatter-create-location-already-exists", "warn", null, null, {
bodyshopID
});
return res.json({ success: false, message: "This Bodyshop already has a location associated with it" });
}
const chatterApi = await createChatterClient(DEFAULT_COMPANY_ID);
const locationIdentifier = `${DEFAULT_COMPANY_ID}-${bodyshop.id}`;
const locationPayload = {
name: bodyshop.shopname,
locationIdentifier: locationIdentifier,
address: bodyshop.address1,
postalCode: bodyshop.zip_post,
state: bodyshop.state,
city: bodyshop.city,
country: InstanceManager({ imex: "Canada", rome: "US" }),
googlePlaceId: googlePlaceID,
status: "active"
};
logger.log("chatter-create-location-calling-api", "info", null, null, { bodyshopID, locationIdentifier });
const response = await chatterApi.createLocation(DEFAULT_COMPANY_ID, locationPayload);
if (!response.location?.id) {
logger.log("chatter-create-location-no-location-id", "error", null, null, { bodyshopID, response });
return res.json({ success: false, message: "No location ID in response", data: response });
}
await client.request(UPDATE_BODYSHOP_CHATTER_FIELDS, {
id: bodyshopID,
chatter_company_id: DEFAULT_COMPANY_ID,
chatterid: String(response.location.id)
});
logger.log("chatter-create-location-success", "info", null, null, {
bodyshopID,
chatter_company_id: DEFAULT_COMPANY_ID,
chatterid: response.location.id,
locationIdentifier
});
return res.json({ success: true, data: response });
} catch (error) {
logger.log("chatter-create-location-error", "error", null, null, {
bodyshopID,
error: error.message,
status: error.status,
data: error.data
});
return res.json({
success: false,
message: error.message || "Failed to create location",
error: error.data
});
}
};
module.exports = createLocation;

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 {

554
server/data/chatter-api.js Normal file
View File

@@ -0,0 +1,554 @@
/**
* Environment variables used by this file
* Chatter integration
* - CHATTER_API_CONCURRENCY
* - Maximum number of jobs/interactions posted concurrently *per shop* (within a single shop's batch).
* - Default: 5
* - Used by: createConcurrencyLimit(MAX_CONCURRENCY)
*
* - CHATTER_API_REQUESTS_PER_SECOND
* - Per-company outbound request rate (token bucket refill rate).
* - Default: 3
* - Must be a positive number; otherwise falls back to default.
* - Used by: createTokenBucketRateLimiter({ refillPerSecond })
*
* - CHATTER_API_BURST_CAPACITY
* - Per-company token bucket capacity (maximum burst size).
* - Default: equals CHATTER_API_REQUESTS_PER_SECOND (i.e., 3 unless overridden)
* - Must be a positive number; otherwise falls back to default.
* - Used by: createTokenBucketRateLimiter({ capacity })
*
* - CHATTER_API_MAX_RETRIES
* - Maximum number of attempts for posting an interaction before giving up.
* - Default: 6
* - Must be a positive integer; otherwise falls back to default.
* - Used by: postInteractionWithPolicy()
*
* - CHATTER_API_TOKEN
* - Optional override token for emergency/dev scenarios.
* - If set, bypasses Secrets Manager/Redis token retrieval and uses this value for all companies.
* - Used by: getChatterApiTokenCached()
*
* Notes
* - Per-company API tokens are otherwise fetched via getChatterApiToken(companyId) (Secrets Manager)
* and may be cached via `sessionUtils.getChatterToken/setChatterToken` (Redis-backed).
*/
const queries = require("../graphql-client/queries");
const moment = require("moment-timezone");
const logger = require("../utils/logger");
const { ChatterApiClient, getChatterApiToken, CHATTER_BASE_URL } = require("../chatter/chatter-client");
const client = require("../graphql-client/graphql-client").client;
const CHATTER_EVENT = process.env.NODE_ENV === "production" ? "delivery" : "TEST_INTEGRATION";
const MAX_CONCURRENCY = Number(process.env.CHATTER_API_CONCURRENCY || 5);
const CHATTER_REQUESTS_PER_SECOND = getPositiveNumber(process.env.CHATTER_API_REQUESTS_PER_SECOND, 3);
const CHATTER_BURST_CAPACITY = getPositiveNumber(process.env.CHATTER_API_BURST_CAPACITY, CHATTER_REQUESTS_PER_SECOND);
const CHATTER_MAX_RETRIES = getPositiveInteger(process.env.CHATTER_API_MAX_RETRIES, 6);
// Client caching (in-memory) - tokens are now cached in Redis
const clientCache = new Map(); // companyId -> ChatterApiClient
const tokenInFlight = new Map(); // companyId -> Promise<string> (for in-flight deduplication)
const companyRateLimiters = new Map(); // companyId -> rate limiter
/**
* Core processing function for Chatter API jobs.
* This can be called by the HTTP handler or the BullMQ worker.
*
* @param {Object} options - Processing options
* @param {string} options.start - Start date for the delivery window
* @param {string} options.end - End date for the delivery window
* @param {Array<string>} options.bodyshopIds - Optional specific shops to process
* @param {boolean} options.skipUpload - Dry-run flag
* @param {Object} options.sessionUtils - Optional session utils for token caching
* @returns {Promise<Object>} Result with totals, allShopSummaries, and allErrors
*/
async function processChatterApiJob({ start, end, bodyshopIds, skipUpload, sessionUtils }) {
logger.log("chatter-api-start", "DEBUG", "api", null, null);
const allErrors = [];
const allShopSummaries = [];
// Shops that DO have chatter_company_id
const { bodyshops } = await client.request(queries.GET_CHATTER_SHOPS_WITH_COMPANY);
const shopsToProcess =
bodyshopIds?.length > 0 ? bodyshops.filter((shop) => bodyshopIds.includes(shop.id)) : bodyshops;
logger.log("chatter-api-shopsToProcess-generated", "DEBUG", "api", null, { count: shopsToProcess.length });
if (shopsToProcess.length === 0) {
logger.log("chatter-api-shopsToProcess-empty", "DEBUG", "api", null, null);
return {
totals: { shops: 0, jobs: 0, sent: 0, duplicates: 0, failed: 0 },
allShopSummaries: [],
allErrors: []
};
}
await processBatchApi({
shopsToProcess,
start,
end,
skipUpload,
allShopSummaries,
allErrors,
sessionUtils
});
const totals = allShopSummaries.reduce(
(acc, s) => {
acc.shops += 1;
acc.jobs += s.jobs || 0;
acc.sent += s.sent || 0;
acc.duplicates += s.duplicates || 0;
acc.failed += s.failed || 0;
return acc;
},
{ shops: 0, jobs: 0, sent: 0, duplicates: 0, failed: 0 }
);
logger.log("chatter-api-end", "DEBUG", "api", null, totals);
return { totals, allShopSummaries, allErrors };
}
exports.default = async (req, res) => {
if (process.env.NODE_ENV !== "production") return res.sendStatus(403);
if (req.headers["x-imex-auth"] !== process.env.AUTOHOUSE_AUTH_TOKEN) return res.sendStatus(401);
res.status(202).json({
success: true,
message: "Chatter API job queued for processing",
timestamp: new Date().toISOString()
});
try {
const { dispatchChatterApiJob } = require("./queues/chatterApiQueue");
const { start, end, bodyshopIds, skipUpload } = req.body;
await dispatchChatterApiJob({
start,
end,
bodyshopIds,
skipUpload
});
} catch (error) {
logger.log("chatter-api-queue-dispatch-error", "ERROR", "api", null, {
error: error.message,
stack: error.stack
});
}
};
exports.processChatterApiJob = processChatterApiJob;
async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShopSummaries, allErrors, sessionUtils }) {
for (const bodyshop of shopsToProcess) {
const summary = {
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
shopname: bodyshop.shopname,
chatter_company_id: bodyshop.chatter_company_id,
chatterid: bodyshop.chatterid,
jobs: 0,
sent: 0,
duplicates: 0,
failed: 0,
ok: true
};
try {
logger.log("chatter-api-start-shop", "DEBUG", "api", bodyshop.id, { shopname: bodyshop.shopname });
const companyId = parseCompanyId(bodyshop.chatter_company_id);
if (!companyId) {
summary.ok = false;
summary.failed = 0;
allErrors.push({
...pickShop(bodyshop),
fatal: true,
errors: [`Invalid chatter_company_id: "${bodyshop.chatter_company_id}"`]
});
allShopSummaries.push(summary);
continue;
}
const chatterApi = await getChatterApiClient(companyId, sessionUtils);
const { jobs } = await client.request(queries.CHATTER_QUERY, {
bodyshopid: bodyshop.id,
start: start ? moment(start).startOf("day") : moment().subtract(1, "days").startOf("day"),
...(end && { end: moment(end).endOf("day") })
});
summary.jobs = jobs.length;
// concurrency-limited posting
const limit = createConcurrencyLimit(MAX_CONCURRENCY);
const results = await Promise.all(
jobs.map((j) =>
limit(async () => {
const payload = buildInteractionPayload(bodyshop, j);
// keep legacy flag name: skipUpload == dry-run
if (skipUpload) return { ok: true, dryRun: true };
const r = await postInteractionWithPolicy(chatterApi, companyId, payload);
return r;
})
)
);
for (const r of results) {
if (r?.dryRun) continue;
if (r?.ok && r?.duplicate) summary.duplicates += 1;
else if (r?.ok) summary.sent += 1;
else summary.failed += 1;
}
// record failures with some detail (cap to avoid huge emails)
const failures = results
.filter((r) => r && r.ok === false)
.slice(0, 25)
.map((r) => ({
status: r.status,
error: r.error,
context: r.context
}));
if (failures.length) {
summary.ok = false;
allErrors.push({
...pickShop(bodyshop),
fatal: false,
errors: failures
});
}
logger.log("chatter-api-end-shop", "DEBUG", "api", bodyshop.id, summary);
} catch (error) {
summary.ok = false;
logger.log("chatter-api-error-shop", "ERROR", "api", bodyshop.id, {
error: error.message,
stack: error.stack
});
allErrors.push({
...pickShop(bodyshop),
fatal: true,
errors: [error.toString()]
});
} finally {
allShopSummaries.push(summary);
}
}
}
function buildInteractionPayload(bodyshop, j) {
const isCompany = Boolean(j.ownr_co_nm);
const locationIdentifier = `${bodyshop.chatter_company_id}-${bodyshop.id}`;
const timestamp = formatChatterTimestamp(j.actual_delivery, bodyshop.timezone);
if (j.actual_delivery && !timestamp) {
logger.log("chatter-api-invalid-delivery-timestamp", "WARN", "api", bodyshop.id, {
bodyshopId: bodyshop.id,
jobId: j.id,
timezone: bodyshop.timezone,
actualDelivery: j.actual_delivery
});
}
return {
locationIdentifier: locationIdentifier,
event: CHATTER_EVENT,
consent: "true",
transactionId: j.ro_number != null ? String(j.ro_number) : undefined,
timestamp,
firstName: isCompany ? null : j.ownr_fn || null,
lastName: isCompany ? j.ownr_co_nm : j.ownr_ln || null,
emailAddress: j.ownr_ea || undefined,
phoneNumber: j.ownr_ph1 || undefined,
metadata: {
imexShopId: bodyshop.imexshopid,
bodyshopId: bodyshop.id,
jobId: j.id
}
};
}
async function postInteractionWithPolicy(chatterApi, companyId, payload) {
const limiter = getCompanyRateLimiter(companyId);
const requestContext = {
companyId,
locationIdentifier: payload?.locationIdentifier,
transactionId: payload?.transactionId,
timestamp: payload?.timestamp ?? null,
bodyshopId: payload?.metadata?.bodyshopId ?? null,
jobId: payload?.metadata?.jobId ?? null
};
for (let attempt = 0; attempt < CHATTER_MAX_RETRIES; attempt++) {
await limiter.acquire();
try {
await chatterApi.postInteraction(companyId, payload);
return { ok: true };
} catch (e) {
// duplicate -> treat as successful idempotency outcome
if (e.status === 409) return { ok: true, duplicate: true, error: e.data };
// rate limited -> backoff + retry
if (e.status === 429) {
const retryDelayMs = retryDelayMsForError(e, attempt);
limiter.pause(retryDelayMs);
logger.log("chatter-api-request-rate-limited", "WARN", "api", requestContext.bodyshopId, {
...requestContext,
attempt: attempt + 1,
maxAttempts: CHATTER_MAX_RETRIES,
status: e.status,
retryAfterMs: e.retryAfterMs,
retryDelayMs,
error: e.data ?? e.message
});
await sleep(retryDelayMs);
continue;
}
logger.log("chatter-api-request-failed", "ERROR", "api", requestContext.bodyshopId, {
...requestContext,
attempt: attempt + 1,
maxAttempts: CHATTER_MAX_RETRIES,
status: e.status,
error: e.data ?? e.message
});
return { ok: false, status: e.status, error: e.data ?? e.message, context: requestContext };
}
}
logger.log("chatter-api-request-failed", "ERROR", "api", requestContext.bodyshopId, {
...requestContext,
maxAttempts: CHATTER_MAX_RETRIES,
status: 429,
error: "rate limit retry exhausted"
});
return { ok: false, status: 429, error: "rate limit retry exhausted", context: requestContext };
}
function parseCompanyId(val) {
const s = String(val ?? "").trim();
if (!s) return null;
const n = Number(s);
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) return null;
return n;
}
function pickShop(bodyshop) {
return {
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
shopname: bodyshop.shopname,
chatter_company_id: bodyshop.chatter_company_id,
chatterid: bodyshop.chatterid
};
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function backoffMs(attempt) {
const base = Math.min(30_000, 500 * 2 ** attempt);
const jitter = Math.floor(Math.random() * 250);
return base + jitter;
}
function retryDelayMsForError(error, attempt) {
const retryAfterMs = Number(error?.retryAfterMs);
if (Number.isFinite(retryAfterMs) && retryAfterMs > 0) {
const jitter = Math.floor(Math.random() * 250);
return Math.min(60_000, retryAfterMs + jitter);
}
return backoffMs(attempt);
}
function formatChatterTimestamp(value, timezone) {
if (!value) return undefined;
const hasValidTimezone = Boolean(timezone && moment.tz.zone(timezone));
const parsed = hasValidTimezone ? moment(value).tz(timezone) : moment(value);
if (!parsed.isValid()) return undefined;
// Keep a strict, Chatter-friendly timestamp without fractional seconds.
return parsed.utc().format("YYYY-MM-DD HH:mm:ss[Z]");
}
function createConcurrencyLimit(max) {
let active = 0;
const queue = [];
const next = () => {
if (active >= max) return;
const fn = queue.shift();
if (!fn) return;
active++;
fn()
.catch(() => {})
.finally(() => {
active--;
next();
});
};
return (fn) =>
new Promise((resolve, reject) => {
queue.push(async () => {
try {
resolve(await fn());
} catch (e) {
reject(e);
}
});
next();
});
}
function getCompanyRateLimiter(companyId) {
const key = String(companyId);
const existing = companyRateLimiters.get(key);
if (existing) return existing;
const limiter = createTokenBucketRateLimiter({
refillPerSecond: CHATTER_REQUESTS_PER_SECOND,
capacity: CHATTER_BURST_CAPACITY
});
companyRateLimiters.set(key, limiter);
return limiter;
}
function createTokenBucketRateLimiter({ refillPerSecond, capacity }) {
let tokens = capacity;
let lastRefillAt = Date.now();
let pauseUntil = 0;
let chain = Promise.resolve();
const refill = () => {
const now = Date.now();
const elapsedSec = (now - lastRefillAt) / 1000;
if (elapsedSec <= 0) return;
tokens = Math.min(capacity, tokens + elapsedSec * refillPerSecond);
lastRefillAt = now;
};
const waitForPermit = async () => {
for (;;) {
const now = Date.now();
if (pauseUntil > now) {
await sleep(pauseUntil - now);
continue;
}
refill();
if (tokens >= 1) {
tokens -= 1;
return;
}
const missing = 1 - tokens;
const waitMs = Math.max(25, Math.ceil((missing / refillPerSecond) * 1000));
await sleep(waitMs);
}
};
return {
acquire() {
chain = chain.then(waitForPermit, waitForPermit);
return chain;
},
pause(ms) {
const n = Number(ms);
if (!Number.isFinite(n) || n <= 0) return;
pauseUntil = Math.max(pauseUntil, Date.now() + n);
}
};
}
function getPositiveNumber(value, fallback) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
function getPositiveInteger(value, fallback) {
const n = Number(value);
return Number.isInteger(n) && n > 0 ? n : fallback;
}
/**
* Returns a per-company Chatter API client, caching both the token and the client.
*/
async function getChatterApiClient(companyId, sessionUtils) {
const key = String(companyId);
const existing = clientCache.get(key);
if (existing) return existing;
const apiToken = await getChatterApiTokenCached(companyId, sessionUtils);
const chatterApi = new ChatterApiClient({ baseUrl: CHATTER_BASE_URL, apiToken });
clientCache.set(key, chatterApi);
return chatterApi;
}
/**
* Fetches the per-company token from AWS Secrets Manager with Redis caching
* SecretId: CHATTER_COMPANY_KEY_<companyId>
*
* Uses Redis caching + in-flight dedupe to avoid hammering Secrets Manager.
*/
async function getChatterApiTokenCached(companyId, sessionUtils) {
const key = String(companyId ?? "").trim();
if (!key) throw new Error("getChatterApiToken: companyId is required");
// Optional override for emergency/dev
if (process.env.CHATTER_API_TOKEN) return process.env.CHATTER_API_TOKEN;
// Check Redis cache if sessionUtils is available
if (sessionUtils?.getChatterToken) {
const cachedToken = await sessionUtils.getChatterToken(key);
if (cachedToken) {
logger.log("chatter-api-get-token-cache-hit", "DEBUG", "api", null, { companyId: key });
return cachedToken;
}
}
// Check for in-flight requests
const inflight = tokenInFlight.get(key);
if (inflight) return inflight;
const p = (async () => {
logger.log("chatter-api-get-token-cache-miss", "DEBUG", "api", null, { companyId: key });
// Fetch token from Secrets Manager using shared function
const token = await getChatterApiToken(companyId);
// Store in Redis cache if sessionUtils is available
if (sessionUtils?.setChatterToken) {
await sessionUtils.setChatterToken(key, token);
}
return token;
})();
tokenInFlight.set(key, p);
try {
return await p;
} finally {
tokenInFlight.delete(key);
}
}

View File

@@ -4,6 +4,8 @@ const converter = require("json-2-csv");
const logger = require("../utils/logger");
const fs = require("fs");
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
const { defaultProvider } = require("@aws-sdk/credential-provider-node");
const { isString, isEmpty } = require("lodash");
let Client = require("ssh2-sftp-client");
const client = require("../graphql-client/graphql-client").client;
@@ -144,7 +146,18 @@ async function processBatch(shopsToProcess, start, end, allChatterObjects, allEr
async function getPrivateKey() {
// Connect to AWS Secrets Manager
const client = new SecretsManagerClient({ region: "ca-central-1" });
const secretsClientOptions = {
region: "ca-central-1",
credentials: defaultProvider()
};
const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME);
if (isLocal) {
secretsClientOptions.endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:4566`;
}
const client = new SecretsManagerClient(secretsClientOptions);
const command = new GetSecretValueCommand({ SecretId: "CHATTER_PRIVATE_KEY" });
logger.log("chatter-get-private-key", "DEBUG", "api", null, null);

View File

@@ -9,4 +9,5 @@ exports.emsUpload = require("./emsUpload").default;
exports.carfax = require("./carfax").default;
exports.carfaxRps = require("./carfax-rps").default;
exports.vehicletype = require("./vehicletype/vehicletype").default;
exports.documentAnalytics = require("./analytics/documents").default;
exports.documentAnalytics = require("./analytics/documents").default;
exports.chatterApi = require("./chatter-api").default;

View File

@@ -0,0 +1,178 @@
const { Queue, Worker } = require("bullmq");
const { registerCleanupTask } = require("../../utils/cleanupManager");
const getBullMQPrefix = require("../../utils/getBullMQPrefix");
const devDebugLogger = require("../../utils/devDebugLogger");
const moment = require("moment-timezone");
const { sendServerEmail } = require("../../email/sendemail");
let chatterApiQueue;
let chatterApiWorker;
/**
* Initializes the Chatter API queue and worker.
*
* @param {Object} options - Configuration options for queue initialization.
* @param {Object} options.pubClient - Redis client instance for queue communication.
* @param {Object} options.logger - Logger instance for logging events and debugging.
* @param {Function} options.processJob - Function to process the Chatter API job.
* @param {Function} options.getChatterToken - Function to get Chatter token from Redis.
* @param {Function} options.setChatterToken - Function to set Chatter token in Redis.
* @returns {Queue} The initialized `chatterApiQueue` instance.
*/
const loadChatterApiQueue = async ({ pubClient, logger, processJob, getChatterToken, setChatterToken }) => {
if (!chatterApiQueue) {
const prefix = getBullMQPrefix();
devDebugLogger(`Initializing Chatter API Queue with prefix: ${prefix}`);
chatterApiQueue = new Queue("chatterApi", {
prefix,
connection: pubClient,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: false,
attempts: 3,
backoff: {
type: "exponential",
delay: 60000 // 1 minute base delay
}
}
});
chatterApiWorker = new Worker(
"chatterApi",
async (job) => {
const { start, end, bodyshopIds, skipUpload } = job.data;
logger.log("chatter-api-queue-job-start", "INFO", "api", null, {
jobId: job.id,
start,
end,
bodyshopIds,
skipUpload
});
try {
// Provide sessionUtils-like object with token caching functions
const sessionUtils = {
getChatterToken,
setChatterToken
};
const result = await processJob({
start,
end,
bodyshopIds,
skipUpload,
sessionUtils
});
logger.log("chatter-api-queue-job-complete", "INFO", "api", null, {
jobId: job.id,
totals: result.totals
});
// Send email summary
await sendServerEmail({
subject: `Chatter API Report ${moment().format("MM-DD-YY")}`,
text:
`Totals:\n${JSON.stringify(result.totals, null, 2)}\n\n` +
`Shop summaries:\n${JSON.stringify(result.allShopSummaries, null, 2)}\n\n` +
`Errors:\n${JSON.stringify(result.allErrors, null, 2)}\n`
});
return result;
} catch (error) {
logger.log("chatter-api-queue-job-error", "ERROR", "api", null, {
jobId: job.id,
error: error.message,
stack: error.stack
});
// Send error email
await sendServerEmail({
subject: `Chatter API Error ${moment().format("MM-DD-YY")}`,
text: `Job failed:\n${error.message}\n\n${error.stack}`
});
throw error;
}
},
{
prefix,
connection: pubClient,
concurrency: 1, // Process one job at a time
lockDuration: 14400000 // 4 hours - allow long-running jobs
}
);
// Event handlers
chatterApiWorker.on("completed", (job) => {
devDebugLogger(`Chatter API job ${job.id} completed`);
});
chatterApiWorker.on("failed", (job, err) => {
logger.log("chatter-api-queue-job-failed", "ERROR", "api", null, {
jobId: job?.id,
message: err?.message,
stack: err?.stack
});
});
chatterApiWorker.on("progress", (job, progress) => {
devDebugLogger(`Chatter API job ${job.id} progress: ${progress}%`);
});
// Register cleanup task
const shutdown = async () => {
devDebugLogger("Closing Chatter API queue worker...");
await chatterApiWorker.close();
devDebugLogger("Chatter API queue worker closed");
};
registerCleanupTask(shutdown);
}
return chatterApiQueue;
};
/**
* Retrieves the initialized `chatterApiQueue` instance.
*
* @returns {Queue} The `chatterApiQueue` instance.
* @throws {Error} If `chatterApiQueue` is not initialized.
*/
const getQueue = () => {
if (!chatterApiQueue) {
throw new Error("Chatter API queue not initialized. Ensure loadChatterApiQueue is called during bootstrap.");
}
return chatterApiQueue;
};
/**
* Dispatches a Chatter API job to the queue.
*
* @param {Object} options - Options for the job.
* @param {string} options.start - Start date for the delivery window.
* @param {string} options.end - End date for the delivery window.
* @param {Array<string>} options.bodyshopIds - Optional specific shops to process.
* @param {boolean} options.skipUpload - Dry-run flag.
* @returns {Promise<void>} Resolves when the job is added to the queue.
*/
const dispatchChatterApiJob = async ({ start, end, bodyshopIds, skipUpload }) => {
const queue = getQueue();
const jobData = {
start: start || moment().subtract(1, "days").startOf("day").toISOString(),
end: end || moment().endOf("day").toISOString(),
bodyshopIds: bodyshopIds || [],
skipUpload: skipUpload || false
};
await queue.add("process-chatter-api", jobData, {
jobId: `chatter-api-${moment().format("YYYY-MM-DD-HHmmss")}`
});
devDebugLogger(`Added Chatter API job to queue: ${JSON.stringify(jobData)}`);
};
module.exports = { loadChatterApiQueue, getQueue, dispatchChatterApiJob };

View File

@@ -432,8 +432,8 @@ async function QueryDmsCustomerByName({ socket, redisHelpers, JobData }) {
//? [["firstName", JobData.ownr_co_nm.replace(replaceSpecialRegex, "").toUpperCase()]] // Commented out until we receive direction.
? [["phone", JobData.ownr_ph1?.replace(replaceSpecialRegex, "")]]
: [
["firstName", JobData.ownr_fn?.replace(replaceSpecialRegex, "").toUpperCase()],
["lastName", JobData.ownr_ln?.replace(replaceSpecialRegex, "").toUpperCase()]
["firstName", JobData.ownr_fn?.replace(/[^a-zA-Z-]/g, "").toUpperCase()],
["lastName", JobData.ownr_ln?.replace(/[^a-zA-Z-]/g, "").toUpperCase()]
];
try {
const result = await MakeFortellisCall({
@@ -474,9 +474,9 @@ async function InsertDmsCustomer({ socket, redisHelpers, JobData }) {
} : {
customerName: {
//"suffix": "Mr.",
firstName: JobData.ownr_fn && JobData.ownr_fn.replace(replaceSpecialRegex, "").toUpperCase(),
firstName: JobData.ownr_fn && JobData.ownr_fn.replace(/[^a-zA-Z-]/g, "").toUpperCase(),
//"middleName": "",
lastName: JobData.ownr_ln && JobData.ownr_ln.replace(replaceSpecialRegex, "").toUpperCase()
lastName: JobData.ownr_ln && JobData.ownr_ln.replace(/[^a-zA-Z-]/g, "").toUpperCase()
//"title": "",
//"nickName": ""
}

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 {
@@ -1612,6 +1620,9 @@ exports.GET_JOB_BY_PK = `query GET_JOB_BY_PK($id: uuid!) {
rate_ats
flat_rate_ats
rate_ats_flat
dms_id
dms_customer_id
dms_advisor_id
joblines(where: { removed: { _eq: false } }){
id
line_no
@@ -1911,10 +1922,35 @@ exports.GET_AUTOHOUSE_SHOPS = `query GET_AUTOHOUSE_SHOPS {
}`;
exports.GET_CHATTER_SHOPS = `query GET_CHATTER_SHOPS {
bodyshops(where: {chatterid: {_is_null: false}, _or: {chatterid: {_neq: ""}}}){
bodyshops(
where: {
chatterid: { _is_null: false, _neq: "" }
_or: [
{ chatter_company_id: { _is_null: true } }
{ chatter_company_id: { _eq: "" } }
]
}
) {
id
shopname
chatterid
chatter_company_id
imexshopid
timezone
}
}`;
exports.GET_CHATTER_SHOPS_WITH_COMPANY = `query GET_CHATTER_SHOPS_WITH_COMPANY {
bodyshops(
where: {
chatterid: { _is_null: false, _neq: "" }
chatter_company_id: { _is_null: false, _neq: "" }
}
) {
id
shopname
chatterid
chatter_company_id
imexshopid
timezone
}
@@ -3203,9 +3239,12 @@ exports.UPDATE_USER_FCM_TOKENS_BY_EMAIL = /* GraphQL */ `
}
`;
exports.SET_JOB_DMS_ID = `mutation SetJobDmsId($id: uuid!, $dms_id: String!) {
update_jobs_by_pk(pk_columns: { id: $id }, _set: { dms_id: $dms_id }) {
exports.SET_JOB_DMS_ID = `mutation SetJobDmsId($id: uuid!, $dms_id: String!, $dms_customer_id: String, $dms_advisor_id: String, $kmin: Int) {
update_jobs_by_pk(pk_columns: { id: $id }, _set: { dms_id: $dms_id, dms_customer_id: $dms_customer_id, dms_advisor_id: $dms_advisor_id, kmin: $kmin }) {
id
dms_id
dms_customer_id
dms_advisor_id
kmin
}
}`;

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");

View File

@@ -0,0 +1,12 @@
const express = require("express");
const createLocation = require("../chatter/createLocation");
const router = express.Router();
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
const validateAdminMiddleware = require("../middleware/validateAdminMiddleware");
router.use(validateFirebaseIdTokenMiddleware);
router.use(validateAdminMiddleware);
router.post("/create-location", createLocation);
module.exports = router;

Some files were not shown because too many files have changed in this diff Show More