Compare commits
49 Commits
feature/IO
...
feature/IO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34fe0cc3bf | ||
|
|
188a7b47b1 | ||
|
|
d08bfc61cd | ||
|
|
503c217c99 | ||
|
|
b444639fca | ||
|
|
6ee7e56b9b | ||
|
|
0340ca5fcc | ||
|
|
1b2fc8b114 | ||
|
|
3745d7a414 | ||
|
|
56559dd3ff | ||
|
|
fde137d7f7 | ||
|
|
b797bf7dc9 | ||
|
|
b87d1a65fe | ||
|
|
019b3cf4da | ||
|
|
ad520ab23e | ||
|
|
05ae0801e5 | ||
|
|
332ade96e5 | ||
|
|
3acec55c0e | ||
|
|
da0462f14c | ||
|
|
2cc9fa961e | ||
|
|
1b6fe4d18e | ||
|
|
22aae0a7f1 | ||
|
|
db1b701a96 | ||
|
|
2746421c09 | ||
|
|
5217120994 | ||
|
|
77f72a2a12 | ||
|
|
a84ad4ee32 | ||
|
|
2cacd75822 | ||
|
|
217a0b84ac | ||
|
|
f53ed8c427 | ||
|
|
f8b7588a04 | ||
|
|
ee3cb4456d | ||
|
|
ae05692c46 | ||
|
|
e01a2af5a4 | ||
|
|
b9f398cf2d | ||
|
|
1e44d4fe42 | ||
|
|
0f42875d1b | ||
|
|
a0f1299006 | ||
|
|
87d8a5d746 | ||
|
|
268851902a | ||
|
|
68bb7d2529 | ||
|
|
d50db12330 | ||
|
|
1438986c18 | ||
|
|
c047699fbb | ||
|
|
e5b7fcb919 | ||
|
|
55023ceaca | ||
|
|
45e143578c | ||
|
|
28a41f7637 | ||
|
|
2a2edeadb9 |
@@ -13,4 +13,5 @@
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
bodyshop_translations.babel
|
||||
.env.localstack.docker
|
||||
bodyshop_translations.babel
|
||||
|
||||
106
client/package-lock.json
generated
106
client/package-lock.json
generated
@@ -12,6 +12,10 @@
|
||||
"@amplitude/analytics-browser": "^2.34.0",
|
||||
"@ant-design/pro-layout": "^7.22.6",
|
||||
"@apollo/client": "^4.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||
"@firebase/analytics": "^0.10.19",
|
||||
@@ -60,7 +64,6 @@
|
||||
"react-color": "^2.19.3",
|
||||
"react-cookie": "^8.0.1",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-drag-listview": "^2.0.0",
|
||||
"react-grid-gallery": "^1.0.1",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-i18next": "^16.5.4",
|
||||
@@ -2494,6 +2497,73 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/modifiers": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
|
||||
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dotenvx/dotenvx": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz",
|
||||
@@ -8396,16 +8466,6 @@
|
||||
"@babel/types": "^7.26.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-runtime": {
|
||||
"version": "6.26.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
|
||||
"integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^2.4.0",
|
||||
"regenerator-runtime": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bail": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
|
||||
@@ -9192,14 +9252,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "2.6.12",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
|
||||
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
|
||||
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.47.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz",
|
||||
@@ -15376,16 +15428,6 @@
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-drag-listview": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-drag-listview/-/react-drag-listview-2.0.0.tgz",
|
||||
"integrity": "sha512-7Apx/1Xt4qu+JHHP0rH6aLgZgS7c2MX8ocHVGCi03KfeIWEu0t14MhT3boQKM33l5eJrE/IWfExFTvoYq22fsg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"babel-runtime": "^6.26.0",
|
||||
"prop-types": "^15.5.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-draggable": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz",
|
||||
@@ -15972,12 +16014,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
|
||||
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"@amplitude/analytics-browser": "^2.34.0",
|
||||
"@ant-design/pro-layout": "^7.22.6",
|
||||
"@apollo/client": "^4.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||
"@firebase/analytics": "^0.10.19",
|
||||
@@ -59,7 +63,6 @@
|
||||
"react-color": "^2.19.3",
|
||||
"react-cookie": "^8.0.1",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-drag-listview": "^2.0.0",
|
||||
"react-grid-gallery": "^1.0.1",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-i18next": "^16.5.4",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ export default function BillCmdReturnsTableComponent({ form, returnLoading, retu
|
||||
{fields.map((field, index) => (
|
||||
<tr key={field.key}>
|
||||
<td>
|
||||
<Form.Item hidden key={`${index}id`} name={[field.name, "id"]}>
|
||||
<ReadOnlyFormItemComponent />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
// label={t("joblines.fields.line_desc")}
|
||||
key={`${index}line_desc`}
|
||||
|
||||
@@ -373,9 +373,11 @@ export function BillFormComponent({
|
||||
"local_tax_rate"
|
||||
]);
|
||||
let totals;
|
||||
if (!!values.total && !!values.billlines && values.billlines.length > 0)
|
||||
if (!!values.total && !!values.billlines && values.billlines.length > 0) {
|
||||
totals = CalculateBillTotal(values);
|
||||
if (totals)
|
||||
}
|
||||
|
||||
if (totals) {
|
||||
return (
|
||||
// TODO: Align is not correct
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
@@ -414,7 +416,7 @@ export function BillFormComponent({
|
||||
<Statistic
|
||||
title={t("bills.labels.discrepancy")}
|
||||
styles={{
|
||||
value: {
|
||||
content: {
|
||||
color: totals.discrepancy.getAmount() === 0 ? "green" : "red"
|
||||
}
|
||||
}}
|
||||
@@ -427,6 +429,7 @@ export function BillFormComponent({
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DeleteFilled, DollarCircleFilled } from "@ant-design/icons";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { Button, Checkbox, Form, Input, InputNumber, Select, Space, Switch, Table, Tooltip } from "antd";
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -32,6 +33,7 @@ export function BillEnterModalLinesComponent({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { setFieldsValue, getFieldsValue, getFieldValue } = form;
|
||||
const firstFieldRefs = useRef({});
|
||||
|
||||
const CONTROL_HEIGHT = 32;
|
||||
|
||||
@@ -90,6 +92,7 @@ export function BillEnterModalLinesComponent({
|
||||
});
|
||||
};
|
||||
|
||||
// Only fill actual_cost when the user forward-tabs out of Retail (actual_price)
|
||||
const autofillActualCost = (index) => {
|
||||
Promise.resolve().then(() => {
|
||||
const retailRaw = form.getFieldValue(["billlines", index, "actual_price"]);
|
||||
@@ -154,6 +157,9 @@ export function BillEnterModalLinesComponent({
|
||||
),
|
||||
formInput: (record, index) => (
|
||||
<BillLineSearchSelect
|
||||
ref={(el) => {
|
||||
firstFieldRefs.current[index] = el;
|
||||
}}
|
||||
disabled={disabled}
|
||||
options={lineData}
|
||||
style={{
|
||||
@@ -164,10 +170,9 @@ export function BillEnterModalLinesComponent({
|
||||
}}
|
||||
allowRemoved={form.getFieldValue("is_credit_memo") || false}
|
||||
onSelect={(value, opt) => {
|
||||
const d = normalizeDiscount(discount);
|
||||
const retail = Number(opt.cost);
|
||||
const computedActual = Number.isFinite(retail) ? round2(retail * (1 - d)) : null;
|
||||
|
||||
// IMPORTANT:
|
||||
// Do NOT autofill actual_cost here. It should only fill when the user forward-tabs
|
||||
// from Retail (actual_price) -> Actual Cost (actual_cost).
|
||||
setFieldsValue({
|
||||
billlines: (getFieldValue("billlines") || []).map((item, idx) => {
|
||||
if (idx !== index) return item;
|
||||
@@ -178,7 +183,7 @@ export function BillEnterModalLinesComponent({
|
||||
quantity: opt.part_qty || 1,
|
||||
actual_price: opt.cost,
|
||||
original_actual_price: opt.cost,
|
||||
actual_cost: isBlank(item.actual_cost) ? computedActual : item.actual_cost,
|
||||
// actual_cost intentionally untouched here
|
||||
cost_center: opt.part_type
|
||||
? bodyshopHasDmsKey(bodyshop)
|
||||
? opt.part_type !== "PAE"
|
||||
@@ -205,7 +210,7 @@ export function BillEnterModalLinesComponent({
|
||||
label: t("billlines.fields.line_desc"),
|
||||
rules: [{ required: true }]
|
||||
}),
|
||||
formInput: () => <Input.TextArea disabled={disabled} autoSize />
|
||||
formInput: () => <Input.TextArea disabled={disabled} autoSize tabIndex={0} />
|
||||
},
|
||||
{
|
||||
title: t("billlines.fields.quantity"),
|
||||
@@ -234,7 +239,7 @@ export function BillEnterModalLinesComponent({
|
||||
})
|
||||
]
|
||||
}),
|
||||
formInput: () => <InputNumber precision={0} min={1} disabled={disabled} />
|
||||
formInput: () => <InputNumber precision={0} min={1} disabled={disabled} tabIndex={0} />
|
||||
},
|
||||
{
|
||||
title: t("billlines.fields.actual_price"),
|
||||
@@ -251,9 +256,10 @@ export function BillEnterModalLinesComponent({
|
||||
<CurrencyInput
|
||||
min={0}
|
||||
disabled={disabled}
|
||||
onBlur={() => autofillActualCost(index)}
|
||||
tabIndex={0}
|
||||
// NOTE: Autofill should only happen on forward Tab out of Retail
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Tab") autofillActualCost(index);
|
||||
if (e.key === "Tab" && !e.shiftKey) autofillActualCost(index);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
@@ -328,8 +334,9 @@ export function BillEnterModalLinesComponent({
|
||||
min={0}
|
||||
disabled={disabled}
|
||||
controls={false}
|
||||
tabIndex={0}
|
||||
style={{ width: "100%", height: CONTROL_HEIGHT }}
|
||||
onFocus={() => autofillActualCost(index)}
|
||||
// NOTE: No auto-fill on focus/blur; only triggered from Retail on Tab
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
@@ -392,7 +399,7 @@ export function BillEnterModalLinesComponent({
|
||||
rules: [{ required: true }]
|
||||
}),
|
||||
formInput: () => (
|
||||
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled}>
|
||||
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled} tabIndex={0}>
|
||||
{bodyshopHasDmsKey(bodyshop)
|
||||
? CiecaSelect(true, false)
|
||||
: responsibilityCenters.costs.map((item) => <Select.Option key={item.name}>{item.name}</Select.Option>)}
|
||||
@@ -412,7 +419,7 @@ export function BillEnterModalLinesComponent({
|
||||
name: [field.name, "location"]
|
||||
}),
|
||||
formInput: () => (
|
||||
<Select disabled={disabled}>
|
||||
<Select disabled={disabled} tabIndex={0}>
|
||||
{bodyshop.md_parts_locations.map((loc, idx) => (
|
||||
<Select.Option key={idx} value={loc}>
|
||||
{loc}
|
||||
@@ -432,7 +439,7 @@ export function BillEnterModalLinesComponent({
|
||||
key: `${field.name}deductedfromlbr`,
|
||||
name: [field.name, "deductedfromlbr"]
|
||||
}),
|
||||
formInput: () => <Switch disabled={disabled} />,
|
||||
formInput: () => <Switch disabled={disabled} tabIndex={0} />,
|
||||
additional: (record, index) => (
|
||||
<Form.Item shouldUpdate noStyle style={{ display: "inline-block" }}>
|
||||
{() => {
|
||||
@@ -517,9 +524,13 @@ export function BillEnterModalLinesComponent({
|
||||
formItemProps: (field) => ({
|
||||
key: `${field.name}fedtax`,
|
||||
valuePropName: "checked",
|
||||
name: [field.name, "applicable_taxes", "federal"]
|
||||
name: [field.name, "applicable_taxes", "federal"],
|
||||
initialValue: InstanceRenderManager({
|
||||
imex: true,
|
||||
rome: false
|
||||
})
|
||||
}),
|
||||
formInput: () => <Switch disabled={disabled} />
|
||||
formInput: () => <Switch disabled={disabled} tabIndex={0} />
|
||||
}
|
||||
]
|
||||
}),
|
||||
@@ -534,7 +545,7 @@ export function BillEnterModalLinesComponent({
|
||||
valuePropName: "checked",
|
||||
name: [field.name, "applicable_taxes", "state"]
|
||||
}),
|
||||
formInput: () => <Switch disabled={disabled} />
|
||||
formInput: () => <Switch disabled={disabled} tabIndex={0} />
|
||||
},
|
||||
|
||||
...InstanceRenderManager({
|
||||
@@ -550,7 +561,7 @@ export function BillEnterModalLinesComponent({
|
||||
valuePropName: "checked",
|
||||
name: [field.name, "applicable_taxes", "local"]
|
||||
}),
|
||||
formInput: () => <Switch disabled={disabled} />
|
||||
formInput: () => <Switch disabled={disabled} tabIndex={0} />
|
||||
}
|
||||
]
|
||||
}),
|
||||
@@ -570,6 +581,7 @@ export function BillEnterModalLinesComponent({
|
||||
icon={<DeleteFilled />}
|
||||
disabled={disabled || invLen > 0}
|
||||
onClick={() => remove(record.name)}
|
||||
tabIndex={0}
|
||||
/>
|
||||
|
||||
{Simple_Inventory.treatment === "on" && (
|
||||
@@ -641,12 +653,19 @@ export function BillEnterModalLinesComponent({
|
||||
<Button
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const newIndex = fields.length;
|
||||
add(
|
||||
InstanceRenderManager({
|
||||
imex: { applicable_taxes: { federal: true } },
|
||||
rome: { applicable_taxes: { federal: false } }
|
||||
})
|
||||
);
|
||||
setTimeout(() => {
|
||||
const firstField = firstFieldRefs.current[newIndex];
|
||||
if (firstField?.focus) {
|
||||
firstField.focus();
|
||||
}
|
||||
}, 100);
|
||||
}}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
|
||||
@@ -57,7 +57,6 @@ const CardPaymentModalComponent = ({
|
||||
QUERY_RO_AND_OWNER_BY_JOB_PKS,
|
||||
{
|
||||
fetchPolicy: "network-only",
|
||||
notifyOnNetworkStatusChange: true
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
|
||||
const [getConversations, { loading, data, refetch, called }] = useLazyQuery(CONVERSATION_LIST_QUERY, {
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only",
|
||||
notifyOnNetworkStatusChange: true,
|
||||
...(pollInterval > 0 ? { pollInterval } : {})
|
||||
});
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export function DashboardTotalProductionHours({ bodyshop, data, ...cardProps })
|
||||
<Statistic
|
||||
title={t("dashboard.labels.prodhrs")}
|
||||
value={hours.total.toFixed(1)}
|
||||
styles={{ value: { color: aboveTargetHours ? "green" : "red" } }}
|
||||
styles={{ content: { color: aboveTargetHours ? "green" : "red" } }}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
@@ -404,7 +404,7 @@ export default function CdkLikePostForm({ bodyshop, socket, job, logsRef, mode,
|
||||
<Typography.Title>=</Typography.Title>
|
||||
<Statistic
|
||||
title={t("jobs.labels.dms.notallocated")}
|
||||
styles={{ value: { color: discrep.getAmount() === 0 ? "green" : "red" } }}
|
||||
styles={{ content: { color: discrep.getAmount() === 0 ? "green" : "red" } }}
|
||||
value={discrep.toFormat()}
|
||||
/>
|
||||
<Button disabled={disablePost} htmlType="submit">
|
||||
|
||||
@@ -14,8 +14,11 @@ export default function GlobalSearch() {
|
||||
const [callSearch, { loading, error, data }] = useLazyQuery(GLOBAL_SEARCH_QUERY);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const executeSearch = (v) => {
|
||||
if (v && v.variables.search && v.variables.search !== "" && v.variables.search.length >= 3) callSearch(v);
|
||||
const executeSearch = (variables) => {
|
||||
if (variables?.search !== "" && variables?.search?.length >= 3)
|
||||
callSearch({
|
||||
variables
|
||||
});
|
||||
};
|
||||
const debouncedExecuteSearch = _.debounce(executeSearch, 750);
|
||||
|
||||
@@ -157,7 +160,9 @@ export default function GlobalSearch() {
|
||||
return (
|
||||
<AutoComplete
|
||||
options={options}
|
||||
onSearch={handleSearch}
|
||||
showSearch={{
|
||||
onSearch: handleSearch
|
||||
}}
|
||||
defaultActiveFirstOption
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
|
||||
@@ -97,7 +97,7 @@ export function Jobd3RdPartyModal({ bodyshop, jobId, job, technician }) {
|
||||
return (
|
||||
<>
|
||||
<Button onClick={showModal}>{t("printcenter.jobs.3rdpartypayer")}</Button>
|
||||
<Modal open={isModalVisible} onOk={handleOk} onCancel={handleCancel}>
|
||||
<Modal open={isModalVisible} onOk={handleOk} onCancel={handleCancel} getContainer={() => document.body}>
|
||||
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
|
||||
<Form.Item label={t("bills.fields.vendor")} name="vendorid">
|
||||
<VendorSearchSelect options={VendorAutoCompleteData?.vendors} onSelect={handleVendorSelect} />
|
||||
|
||||
@@ -157,7 +157,6 @@ export function JobsDetailHeaderActions({
|
||||
variables: watcherVars,
|
||||
skip: !jobId,
|
||||
fetchPolicy: "cache-first",
|
||||
notifyOnNetworkStatusChange: true
|
||||
});
|
||||
|
||||
const jobWatchersCount = jobWatchersData?.job_watchers?.length ?? job?.job_watchers?.length ?? 0;
|
||||
|
||||
@@ -132,7 +132,13 @@ export function LaborAllocationsAdjustmentEdit({
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(vis) => setOpen(vis)} content={overlay} trigger="click">
|
||||
<Popover
|
||||
getPopupContainer={(trigger) => trigger?.parentElement || document.body}
|
||||
open={open}
|
||||
onOpenChange={(vis) => setOpen(vis)}
|
||||
content={overlay}
|
||||
trigger="click"
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -56,7 +56,6 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount,
|
||||
where: whereClause
|
||||
},
|
||||
fetchPolicy: "cache-and-network",
|
||||
notifyOnNetworkStatusChange: true,
|
||||
errorPolicy: "all",
|
||||
pollInterval: isConnected ? 0 : day.duration(NOTIFICATION_POLL_INTERVAL_SECONDS, "seconds").asMilliseconds(),
|
||||
skip: skipQuery
|
||||
|
||||
@@ -16,9 +16,10 @@ const OwnerSearchSelect = ({ value, onChange, onBlur, disabled, ref }) => {
|
||||
SEARCH_OWNERS_BY_ID_FOR_AUTOCOMPLETE
|
||||
);
|
||||
|
||||
const executeSearch = (v) => {
|
||||
if (v && v.variables?.search !== "" && v.variables.search.length >= 2) callSearch({ variables: v.variables });
|
||||
const executeSearch = (variables) => {
|
||||
if (variables?.search !== "" && variables?.search?.length >= 2) callSearch({ variables });
|
||||
};
|
||||
|
||||
const debouncedExecuteSearch = _.debounce(executeSearch, 500);
|
||||
|
||||
const handleSearch = (value) => {
|
||||
|
||||
@@ -1,94 +1,121 @@
|
||||
import { DownOutlined } from "@ant-design/icons";
|
||||
import { Dropdown, InputNumber, Space } from "antd";
|
||||
import { Button, Divider, Dropdown, InputNumber, Space, theme } from "antd";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
|
||||
const DISCOUNT_PRESETS = [5, 10, 15, 20, 25, 40];
|
||||
|
||||
export default function PartsOrderModalPriceChange({ form, field }) {
|
||||
const { t } = useTranslation();
|
||||
const menu = {
|
||||
items: [
|
||||
{
|
||||
key: "5",
|
||||
label: t("parts_orders.labels.discount", { percent: "5%" })
|
||||
},
|
||||
{
|
||||
key: "10",
|
||||
label: t("parts_orders.labels.discount", { percent: "10%" })
|
||||
},
|
||||
{
|
||||
key: "15",
|
||||
label: t("parts_orders.labels.discount", { percent: "15%" })
|
||||
},
|
||||
{
|
||||
key: "20",
|
||||
label: t("parts_orders.labels.discount", { percent: "20%" })
|
||||
},
|
||||
{
|
||||
key: "25",
|
||||
label: t("parts_orders.labels.discount", { percent: "25%" })
|
||||
},
|
||||
{
|
||||
key: "40",
|
||||
label: t("parts_orders.labels.discount", { percent: "40%" })
|
||||
},
|
||||
{
|
||||
key: "custom",
|
||||
label: (
|
||||
<Space.Compact>
|
||||
<InputNumber
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const values = form.getFieldsValue();
|
||||
const { parts_order_lines } = values;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
form.setFieldsValue({
|
||||
parts_order_lines: {
|
||||
data: parts_order_lines.data.map((p, idx) => {
|
||||
if (idx !== field.name) return p;
|
||||
console.log(p, e.target.value, (p.act_price || 0) * ((100 - (e.target.value || 0)) / 100));
|
||||
return {
|
||||
...p,
|
||||
act_price: (p.act_price || 0) * ((100 - (e.target.value || 0)) / 100)
|
||||
};
|
||||
})
|
||||
}
|
||||
});
|
||||
e.target.value = 0;
|
||||
}
|
||||
}}
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
<span style={{ padding: "0 11px", backgroundColor: "#fafafa", border: "1px solid #d9d9d9", borderLeft: 0 }}>%</span>
|
||||
</Space.Compact>
|
||||
)
|
||||
const [open, setOpen] = useState(false);
|
||||
const [customPercent, setCustomPercent] = useState(0);
|
||||
|
||||
const applyDiscountPercent = (percent) => {
|
||||
const pct = Number(percent) || 0;
|
||||
|
||||
const values = form.getFieldsValue();
|
||||
const parts_order_lines = values?.parts_order_lines;
|
||||
const data = Array.isArray(parts_order_lines?.data) ? parts_order_lines.data : [];
|
||||
if (!data.length) return;
|
||||
|
||||
form.setFieldsValue({
|
||||
parts_order_lines: {
|
||||
data: data.map((p, idx) => {
|
||||
if (idx !== field.name) return p;
|
||||
return {
|
||||
...p,
|
||||
act_price: (p.act_price || 0) * ((100 - pct) / 100)
|
||||
};
|
||||
})
|
||||
}
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const applyCustom = () => {
|
||||
logImEXEvent("parts_order_manual_discount", {});
|
||||
applyDiscountPercent(customPercent);
|
||||
setCustomPercent(0);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const menu = {
|
||||
// Kill the menu “card” styling so our wrapper becomes the single card.
|
||||
style: {
|
||||
background: "transparent",
|
||||
boxShadow: "none"
|
||||
},
|
||||
items: DISCOUNT_PRESETS.map((pct) => ({
|
||||
key: String(pct),
|
||||
label: t("parts_orders.labels.discount", { percent: `${pct}%` })
|
||||
})),
|
||||
onClick: ({ key }) => {
|
||||
logImEXEvent("parts_order_manual_discount", {});
|
||||
if (key === "custom") return;
|
||||
const values = form.getFieldsValue();
|
||||
const { parts_order_lines } = values;
|
||||
form.setFieldsValue({
|
||||
parts_order_lines: {
|
||||
data: parts_order_lines.data.map((p, idx) => {
|
||||
if (idx !== field.name) return p;
|
||||
return {
|
||||
...p,
|
||||
act_price: (p.act_price || 0) * ((100 - key) / 100)
|
||||
};
|
||||
})
|
||||
}
|
||||
});
|
||||
applyDiscountPercent(key);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown menu={menu} trigger="click">
|
||||
<Dropdown
|
||||
menu={menu}
|
||||
trigger={["click"]}
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => setOpen(nextOpen)}
|
||||
getPopupContainer={(triggerNode) => triggerNode?.parentElement ?? document.body}
|
||||
popupRender={(menus) => (
|
||||
<div
|
||||
// This makes the whole dropdown (menu + footer) look like one panel in both light/dark.
|
||||
style={{
|
||||
background: token.colorBgElevated,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
overflow: "hidden",
|
||||
minWidth: 180
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{menus}
|
||||
|
||||
<Divider style={{ margin: 0 }} />
|
||||
|
||||
<div style={{ padding: token.paddingXS }}>
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<InputNumber
|
||||
value={customPercent}
|
||||
min={0}
|
||||
max={100}
|
||||
precision={0}
|
||||
controls={false}
|
||||
style={{ width: "100%" }}
|
||||
formatter={(v) => (v === null || v === undefined ? "" : `${v}%`)}
|
||||
parser={(v) =>
|
||||
String(v ?? "")
|
||||
.replace("%", "")
|
||||
.trim()
|
||||
}
|
||||
onChange={(v) => setCustomPercent(v ?? 0)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
applyCustom();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="primary" onClick={applyCustom}>
|
||||
{t("general.labels.apply")}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Space>
|
||||
%
|
||||
<DownOutlined />
|
||||
% <DownOutlined />
|
||||
</Space>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,6 @@ import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
||||
import { GenerateDocument } from "../../utils/RenderTemplate";
|
||||
import { TemplateList } from "../../utils/TemplateConstants";
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||
import PartsOrderModalComponent from "./parts-order-modal.component";
|
||||
import axios from "axios";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
@@ -66,7 +65,7 @@ export function PartsOrderModalContainer({
|
||||
const sendTypeState = useState("e");
|
||||
const sendType = sendTypeState[0];
|
||||
|
||||
const { loading, error, data } = useQuery(QUERY_ALL_VENDORS_FOR_ORDER, {
|
||||
const { error, data } = useQuery(QUERY_ALL_VENDORS_FOR_ORDER, {
|
||||
skip: !open,
|
||||
variables: { jobId: jobId },
|
||||
fetchPolicy: "network-only",
|
||||
@@ -94,16 +93,6 @@ export function PartsOrderModalContainer({
|
||||
};
|
||||
});
|
||||
|
||||
const missingIdx = forcedLines.findIndex((l) => !l?.job_line_id);
|
||||
if (missingIdx !== -1) {
|
||||
notification.error({
|
||||
title: t("parts_orders.errors.creating"),
|
||||
description: `Missing job_line_id for parts line #${missingIdx + 1}`
|
||||
});
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let insertResult;
|
||||
try {
|
||||
insertResult = await insertPartOrder({
|
||||
@@ -372,6 +361,7 @@ export function PartsOrderModalContainer({
|
||||
}
|
||||
}, [open, linesToOrder, form]);
|
||||
|
||||
//This used to have a loading component spinner for the vendor data. With Apollo 4, the NetworkState isn't emitting correctly, so loading just gets set to true the second time, and no longer works as expected.
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -390,18 +380,14 @@ export function PartsOrderModalContainer({
|
||||
>
|
||||
{error ? <AlertComponent title={error.message} type="error" /> : null}
|
||||
<Form form={form} layout="vertical" autoComplete="no" onFinish={handleFinish} initialValues={initialValues}>
|
||||
{loading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<PartsOrderModalComponent
|
||||
form={form}
|
||||
vendorList={data?.vendors || []}
|
||||
sendTypeState={sendTypeState}
|
||||
isReturn={isReturn}
|
||||
preferredMake={data && data.jobs[0] && data.jobs[0].v_make_desc}
|
||||
job={job}
|
||||
/>
|
||||
)}
|
||||
<PartsOrderModalComponent
|
||||
form={form}
|
||||
vendorList={data?.vendors || []}
|
||||
sendTypeState={sendTypeState}
|
||||
isReturn={isReturn}
|
||||
preferredMake={data && data.jobs[0] && data.jobs[0].v_make_desc}
|
||||
job={job}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function PaymentFormTotalPayments({ jobid }) {
|
||||
{balance && (
|
||||
<Statistic
|
||||
title={t("payments.labels.balance")}
|
||||
styles={{ value: { color: balance.getAmount() !== 0 ? "red" : "green" } }}
|
||||
styles={{ content: { color: balance.getAmount() !== 0 ? "red" : "green" } }}
|
||||
value={(balance && balance.toFormat()) || ""}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -108,7 +108,7 @@ export function PrintCenterJobsLabels({ jobId }) {
|
||||
</Card>
|
||||
);
|
||||
return (
|
||||
<Popover content={content} open={isModalVisible}>
|
||||
<Popover content={content} open={isModalVisible} getPopupContainer={(trigger) => trigger.parentElement}>
|
||||
<Button onClick={() => setIsModalVisible(true)}>{t("printcenter.jobs.labels.labels")}</Button>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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, don’t regenerate columns
|
||||
if (isResizing) return;
|
||||
|
||||
// NEW: bail early BEFORE expensive ProductionListColumns(...) call
|
||||
if (!_.isEqual(initialColumnsRef.current, columns)) return;
|
||||
|
||||
const newColumns =
|
||||
matchingColumnConfig?.columns.columnKeys.map((k) => {
|
||||
return {
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,9 +18,10 @@ const VehicleSearchSelect = ({ value, onChange, onBlur, disabled, ref }) => {
|
||||
SEARCH_VEHICLES_BY_ID_FOR_AUTOCOMPLETE
|
||||
);
|
||||
|
||||
const executeSearch = (v) => {
|
||||
if (v && v.variables?.search !== "" && v.variables.search.length >= 2) callSearch({ variables: v.variables });
|
||||
const executeSearch = (variables) => {
|
||||
if (variables?.search !== "" && variables?.search?.length >= 2) callSearch({ variables });
|
||||
};
|
||||
|
||||
const debouncedExecuteSearch = _.debounce(executeSearch, 500);
|
||||
|
||||
const handleSearch = (value) => {
|
||||
|
||||
@@ -510,7 +510,7 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
|
||||
<Statistic
|
||||
title={t("jobs.labels.pimraryamountpayable")}
|
||||
styles={{
|
||||
value: {
|
||||
content: {
|
||||
color: discrep.getAmount() >= 0 ? "green" : "red"
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -17,7 +17,10 @@ import "./tech.page.styles.scss";
|
||||
import UpsellComponent, { upsellEnum } from "../../components/upsell/upsell.component.jsx";
|
||||
import { lazyDev } from "../../utils/lazyWithPreload.jsx";
|
||||
|
||||
const TimeTicketModalContainer = lazyDev(() => import("../../components/time-ticket-modal/time-ticket-modal.container"));
|
||||
const NoteUpsertModal = lazyDev(() => import("../../components/note-upsert-modal/note-upsert-modal.container.jsx"));
|
||||
const TimeTicketModalContainer = lazyDev(
|
||||
() => import("../../components/time-ticket-modal/time-ticket-modal.container")
|
||||
);
|
||||
const EmailOverlayContainer = lazyDev(() => import("../../components/email-overlay/email-overlay.container.jsx"));
|
||||
const PrintCenterModalContainer = lazyDev(
|
||||
() => import("../../components/print-center-modal/print-center-modal.container")
|
||||
@@ -34,7 +37,9 @@ const TimeTicketModalTask = lazyDev(
|
||||
const TechAssignedProdJobs = lazyDev(() => import("../tech-assigned-prod-jobs/tech-assigned-prod-jobs.component"));
|
||||
const TechDispatchedParts = lazyDev(() => import("../tech-dispatched-parts/tech-dispatched-parts.page"));
|
||||
|
||||
const TaskUpsertModalContainer = lazyDev(() => import("../../components/task-upsert-modal/task-upsert-modal.container"));
|
||||
const TaskUpsertModalContainer = lazyDev(
|
||||
() => import("../../components/task-upsert-modal/task-upsert-modal.container")
|
||||
);
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
@@ -70,6 +75,8 @@ export function TechPage({ technician }) {
|
||||
<TechHeader />
|
||||
|
||||
<TaskUpsertModalContainer />
|
||||
<NoteUpsertModal />
|
||||
|
||||
<Content className="tech-content-container">
|
||||
<ErrorBoundary>
|
||||
<Suspense
|
||||
|
||||
@@ -1288,6 +1288,7 @@
|
||||
"vehicle": "Vehicle"
|
||||
},
|
||||
"labels": {
|
||||
"apply": "Apply",
|
||||
"actions": "Actions",
|
||||
"areyousure": "Are you sure?",
|
||||
"barcode": "Barcode",
|
||||
|
||||
@@ -1288,6 +1288,7 @@
|
||||
"vehicle": ""
|
||||
},
|
||||
"labels": {
|
||||
"apply": "",
|
||||
"actions": "Comportamiento",
|
||||
"areyousure": "",
|
||||
"barcode": "código de barras",
|
||||
|
||||
@@ -1288,6 +1288,7 @@
|
||||
"vehicle": ""
|
||||
},
|
||||
"labels": {
|
||||
"apply": "",
|
||||
"actions": "actes",
|
||||
"areyousure": "",
|
||||
"barcode": "code à barre",
|
||||
|
||||
@@ -248,7 +248,8 @@ const client = new ApolloClient({
|
||||
watchQuery: {
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only",
|
||||
errorPolicy: "ignore"
|
||||
errorPolicy: "ignore",
|
||||
notifyOnNetworkStatusChange: false
|
||||
},
|
||||
query: {
|
||||
fetchPolicy: "network-only",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."bodyshops" add column "chatter_company_id" text
|
||||
null;
|
||||
65
localstack/init/10-bootstrap.sh
Normal file
65
localstack/init/10-bootstrap.sh
Normal 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"
|
||||
16
server.js
16
server.js
@@ -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 });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
139
server/chatter/chatter-client.js
Normal file
139
server/chatter/chatter-client.js
Normal 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
|
||||
};
|
||||
123
server/chatter/createLocation.js
Normal file
123
server/chatter/createLocation.js
Normal 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;
|
||||
554
server/data/chatter-api.js
Normal file
554
server/data/chatter-api.js
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
178
server/data/queues/chatterApiQueue.js
Normal file
178
server/data/queues/chatterApiQueue.js
Normal 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 };
|
||||
@@ -1911,10 +1911,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
|
||||
}
|
||||
|
||||
12
server/routes/chatterRoutes.js
Normal file
12
server/routes/chatterRoutes.js
Normal 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;
|
||||
@@ -1,10 +1,21 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const { autohouse, claimscorp, chatter, kaizen, usageReport, podium, carfax, carfaxRps } = require("../data/data");
|
||||
const {
|
||||
autohouse,
|
||||
claimscorp,
|
||||
chatter,
|
||||
kaizen,
|
||||
usageReport,
|
||||
podium,
|
||||
carfax,
|
||||
carfaxRps,
|
||||
chatterApi
|
||||
} = require("../data/data");
|
||||
|
||||
router.post("/ah", autohouse);
|
||||
router.post("/cc", claimscorp);
|
||||
router.post("/chatter", chatter);
|
||||
router.post("/chatter-api", chatterApi);
|
||||
router.post("/kaizen", kaizen);
|
||||
router.post("/usagereport", usageReport);
|
||||
router.post("/podium", podium);
|
||||
|
||||
@@ -8,6 +8,12 @@ const client = require("../graphql-client/graphql-client").client;
|
||||
*/
|
||||
const BODYSHOP_CACHE_TTL = 3600; // 1 hour
|
||||
|
||||
/**
|
||||
* Chatter API token cache TTL in seconds
|
||||
* @type {number}
|
||||
*/
|
||||
const CHATTER_TOKEN_CACHE_TTL = 3600; // 1 hour
|
||||
|
||||
/**
|
||||
* Generate a cache key for a bodyshop
|
||||
* @param bodyshopId
|
||||
@@ -15,6 +21,13 @@ const BODYSHOP_CACHE_TTL = 3600; // 1 hour
|
||||
*/
|
||||
const getBodyshopCacheKey = (bodyshopId) => `bodyshop-cache:${bodyshopId}`;
|
||||
|
||||
/**
|
||||
* Generate a cache key for a Chatter API token
|
||||
* @param companyId
|
||||
* @returns {`chatter-token:${string}`}
|
||||
*/
|
||||
const getChatterTokenCacheKey = (companyId) => `chatter-token:${companyId}`;
|
||||
|
||||
/**
|
||||
* Generate a cache key for a user socket mapping
|
||||
* @param email
|
||||
@@ -373,9 +386,53 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
|
||||
*/
|
||||
const getProviderCache = (ns, field) => getSessionData(`${ns}:provider`, field);
|
||||
|
||||
/**
|
||||
* Get Chatter API token from Redis cache
|
||||
* @param companyId
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
const getChatterToken = async (companyId) => {
|
||||
const key = getChatterTokenCacheKey(companyId);
|
||||
try {
|
||||
const token = await pubClient.get(key);
|
||||
return token;
|
||||
} catch (error) {
|
||||
logger.log("get-chatter-token-from-redis", "ERROR", "redis", null, {
|
||||
companyId,
|
||||
error: error.message
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set Chatter API token in Redis cache
|
||||
* @param companyId
|
||||
* @param token
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const setChatterToken = async (companyId, token) => {
|
||||
const key = getChatterTokenCacheKey(companyId);
|
||||
try {
|
||||
await pubClient.set(key, token);
|
||||
await pubClient.expire(key, CHATTER_TOKEN_CACHE_TTL);
|
||||
devDebugLogger("chatter-token-cache-set", {
|
||||
companyId,
|
||||
action: "Token cached"
|
||||
});
|
||||
} catch (error) {
|
||||
logger.log("set-chatter-token-in-redis", "ERROR", "redis", null, {
|
||||
companyId,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const api = {
|
||||
getUserSocketMappingKey,
|
||||
getBodyshopCacheKey,
|
||||
getChatterTokenCacheKey,
|
||||
setSessionData,
|
||||
getSessionData,
|
||||
clearSessionData,
|
||||
@@ -390,7 +447,9 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
|
||||
getSessionTransactionData,
|
||||
clearSessionTransactionData,
|
||||
setProviderCache,
|
||||
getProviderCache
|
||||
getProviderCache,
|
||||
getChatterToken,
|
||||
setChatterToken
|
||||
};
|
||||
|
||||
Object.assign(module.exports, api);
|
||||
|
||||
Reference in New Issue
Block a user