Compare commits

..

47 Commits

Author SHA1 Message Date
Dave
f849ea9d0a feature/IO-3679-Tech-Console-Null-Error - fix 2026-05-07 10:41:19 -04:00
Dave Richer
de6038038a Merged in hotfix/2026-04-28 (pull request #3206)
hotfix/2026-04-28 - Add Label, fix exported
2026-04-28 18:03:13 +00:00
Dave
1f8836d9d8 hotfix/2026-04-28 - Add Label, fix exported 2026-04-28 13:51:46 -04:00
Dave Richer
a267d65425 Merged in hotfix/2026-04-21 (pull request #3203)
Hotfix/2026 04 21
2026-04-22 16:44:49 +00:00
Dave
cacda3805a hotfix/2026-04-21 - fix Parts order comments 2026-04-22 12:42:49 -04:00
Dave
af757ee71e hotfix/2026-04-21 - Fix save dirty state on employees causing prompt, add 'save and new' 2026-04-21 10:28:26 -04:00
Dave Richer
eb666f2ca1 Merged in hotfix/2026-04-20 (pull request #3195)
hotfix/2026-04-20 - Remove item from Cost centers
2026-04-20 15:57:38 +00:00
Dave
2b8990950b hotfix/2026-04-20 - Remove item from Cost centers 2026-04-20 11:40:19 -04:00
Dave Richer
3f2e05befc Merged in release/2026-04-17 (pull request #3192)
Release/2026-04-17 into master-AIO - IO-1366, IO-3624, IO-3638
2026-04-18 02:12:37 +00:00
Dave Richer
06bfdeb449 Merged in feature/IO-3647-Reynolds-Integration-Phase-2 (pull request #3191)
feature/IO-3647-Reynolds-Integration-Phase-2 - Enhance early RO with meaningful amounts.
2026-04-13 15:00:38 +00:00
Dave Richer
66df286ddb Merged in feature/IO-3647-Reynolds-Integration-Phase-2 (pull request #3189)
feature/IO-3647-Reynolds-Integration-Phase-2 - Enhance early RO with meaningful amounts.
2026-04-13 14:40:15 +00:00
Dave Richer
1b2f9fc027 Merged in hotfix/2026-04-10 (pull request #3188)
hotfix/2026-04-10 - Fix Location Identifier in chatter-api
2026-04-10 15:42:43 +00:00
Dave Richer
1287c7ec36 Merged in hotfix/2026-04-10 (pull request #3186)
hotfix/2026-04-10 - Fix Location Identifier in chatter-api
2026-04-10 15:40:04 +00:00
Dave
fb29fa2caa hotfix/2026-04-10 - Fix Location Identifier in chatter-api 2026-04-10 11:38:56 -04:00
Dave
6bda497d8c feature/IO-3647-Reynolds-Integration-Phase-2 - Enhance early RO with meaningful amounts. 2026-04-09 13:54:48 -04:00
Dave Richer
a018b6dc5a Merged in feature/IO-3638-Reynolds-OpenSearch (pull request #3184)
feature/IO-3638-Reynolds-OpenSearch - Add Search on DMS id in Reynolds shops
2026-04-09 15:16:26 +00:00
Dave
8a4679f86c feature/IO-3638-Reynolds-OpenSearch - Add Search on DMS id in Reynolds shops 2026-04-09 11:14:17 -04:00
Dave Richer
4d558da46a Merged in feature/IO-1366-Re-export-Bill-Audit-Log-codex (pull request #3182)
Feature/IO-1366 Re export Bill Audit Log codex audit
2026-04-08 18:02:39 +00:00
Dave Richer
90789e743f Merged in feature/IO-3624-Shop-Config-UX-Refresh (pull request #3180)
Feature/IO-3624 Shop Config UX Refresh
2026-04-03 01:55:24 +00:00
Dave Richer
a4dbc5250e Merged in release/2026-04-03 (pull request #3179)
Release/2026-04-03 - IO-1366, IO-3356, IO-3515, IO-3587, IO-3599, IO-3609, IO-3616, IO-3622, IO-3623, IO-3627, IO-3629, IO-3637
2026-04-03 01:46:11 +00:00
Dave
704543d823 IO-1366 Refine audit trail detail logging 2026-04-02 21:40:59 -04:00
Dave Richer
d8924d6cf3 Merged in release/2026-04-03 (pull request #3177)
IO-3637 DMS ID Production Board Column
2026-04-03 01:37:20 +00:00
Allan Carr
fe848b5de4 IO-1366 Extend Audit Log
Extend for Time Ticket Changes, Manual Lines, Manual Job, Bill Edits

Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-04-02 11:06:14 -04:00
Dave
a287601f27 Merge branch 'release/2026-04-03' into feature/IO-3624-Shop-Config-UX-Refresh 2026-04-01 14:40:16 -04:00
Dave Richer
2cc6774334 Merged in release/2026-04-03 (pull request #3172)
IO-1366 Bill Reexport Audit Log
2026-03-31 20:19:28 +00:00
Dave
d2dd276ce7 feature/.feature/IO-3624-Shop-Config-UX-Refresh - Bump Deps 2026-03-31 16:16:14 -04:00
Dave
6947ad54a7 Merge branch 'release/2026-04-03' into feature/IO-3624-Shop-Config-UX-Refresh 2026-03-30 17:25:18 -04:00
Dave Richer
db52bf0e94 Merged in release/2026-04-03 (pull request #3169)
Release/2026 04 03
2026-03-30 19:09:17 +00:00
Dave
5d95275c0b feature/IO-3624-Shop-Config-UX-Refresh - bump deps 2026-03-30 15:07:57 -04:00
Dave Richer
ed0693fc5b Merged in release/2026-04-03 (pull request #3165)
IO-3629 PostBatchWip Rtn != 0 error
2026-03-30 15:08:05 +00:00
Dave Richer
e9e189d032 Merged in release/2026-04-03 (pull request #3161)
Release/2026 04 03
2026-03-27 18:49:31 +00:00
Dave
0b470e3c31 Bump deps 2026-03-27 13:46:28 -04:00
Dave Richer
ab8b44bee4 Merged in release/2026-04-03 (pull request #3158)
Release/2026 04 03
2026-03-25 22:37:21 +00:00
Dave
d497ec9f7d feature/IO-3624-Shop-Config-UX-Refresh -Final Push! 2026-03-25 15:58:51 -04:00
Dave
e49500887d IO-3624 Finalize admin config UX and validation polish 2026-03-25 15:25:59 -04:00
Dave
b8246e03c1 IO-3624 Polish config empty states and admin cards 2026-03-25 10:16:48 -04:00
Dave
3aa19ec09f IO-3624 Add drag reorder to job statuses 2026-03-24 21:21:03 -04:00
Dave
866e9581c2 IO-3624 Extract shared title-row UI and polish config forms 2026-03-24 20:56:30 -04:00
Dave
1102670e66 feature/IO-3624-Shop-Config-UX-Refresh - DMS Sections 2026-03-24 17:45:42 -04:00
Dave
591439b79c feature/IO-3624-Shop-Config-UX-Refresh - Add Quick Select Jump 2026-03-24 17:06:09 -04:00
Dave
2de605e520 IO-3624 Refine Rome responsibility center tax layout 2026-03-24 16:37:03 -04:00
Dave
2690e09626 Merge remote-tracking branch 'origin/release/2026-04-03' into feature/IO-3624-Shop-Config-UX-Refresh
# Conflicts:
#	client/src/components/shop-employees/shop-employees-form.component.jsx
2026-03-24 13:55:37 -04:00
Dave
dd306e1a7b feature/IO-3624-Shop-Config-UX-Refresh - Add missing es/fr keys to translations 2026-03-24 13:50:57 -04:00
Dave
fd712da4a3 IO-3624 Polish employee and team config layouts 2026-03-24 12:50:11 -04:00
Dave
bcb693f03c feature/IO-3624-Shop-Config-UX-Refresh - Add missing es/fr keys to translations 2026-03-24 11:55:33 -04:00
Dave
c33a3118bc IO-3624 Polish remaining shop config section cards 2026-03-24 11:51:55 -04:00
Dave
d23a182650 IO-3624 Refresh shop config list rows and color UX 2026-03-24 10:54:42 -04:00
80 changed files with 11791 additions and 6380 deletions

586
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@
"private": true,
"proxy": "http://localhost:4000",
"dependencies": {
"@amplitude/analytics-browser": "^2.37.0",
"@amplitude/analytics-browser": "^2.38.0",
"@ant-design/pro-layout": "^7.22.6",
"@apollo/client": "^4.1.6",
"@dnd-kit/core": "^6.3.1",
@@ -24,29 +24,29 @@
"@firebase/messaging": "^0.12.25",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.11.2",
"@sentry/cli": "^3.3.3",
"@sentry/react": "^10.45.0",
"@sentry/cli": "^3.3.5",
"@sentry/react": "^10.47.0",
"@sentry/vite-plugin": "^4.9.1",
"@splitsoftware/splitio-react": "^2.6.1",
"@tanem/react-nprogress": "^5.0.63",
"antd": "^6.3.3",
"antd": "^6.3.5",
"apollo-link-logger": "^3.0.0",
"autosize": "^6.0.1",
"axios": "^1.13.6",
"axios": "^1.14.0",
"classnames": "^2.5.1",
"css-box-model": "^1.2.1",
"dayjs": "^1.11.20",
"dayjs-business-days2": "^1.3.2",
"dayjs-business-days2": "^1.3.3",
"dinero.js": "^1.9.1",
"dotenv": "^17.3.1",
"env-cmd": "^11.0.0",
"exifr": "^7.1.3",
"graphql": "^16.13.1",
"graphql-ws": "^6.0.7",
"i18next": "^25.10.5",
"graphql": "^16.13.2",
"graphql-ws": "^6.0.8",
"i18next": "^25.10.10",
"i18next-browser-languagedetector": "^8.2.1",
"immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.40",
"libphonenumber-js": "^1.12.41",
"lightningcss": "^1.32.0",
"logrocket": "^12.1.0",
"markerjs2": "^2.32.7",
@@ -54,18 +54,18 @@
"normalize-url": "^8.1.1",
"object-hash": "^3.0.0",
"phone": "^3.1.71",
"posthog-js": "^1.363.2",
"posthog-js": "^1.364.4",
"prop-types": "^15.8.1",
"query-string": "^9.3.1",
"raf-schd": "^4.0.3",
"react": "^19.2.4",
"react-big-calendar": "^1.19.4",
"react-color": "^2.19.3",
"react-cookie": "^8.0.1",
"react-cookie": "^8.1.0",
"react-dom": "^19.2.4",
"react-grid-gallery": "^1.0.1",
"react-grid-layout": "^2.2.2",
"react-i18next": "^16.6.2",
"react-grid-layout": "^2.2.3",
"react-i18next": "^16.6.6",
"react-icons": "^5.6.0",
"react-image-lightbox": "^5.1.4",
"react-markdown": "^10.1.0",
@@ -77,7 +77,7 @@
"react-router-dom": "^7.13.2",
"react-sticky": "^6.0.3",
"react-virtuoso": "^4.18.3",
"recharts": "^3.8.0",
"recharts": "^3.8.1",
"redux": "^5.0.1",
"redux-actions": "^3.0.3",
"redux-persist": "^6.0.0",
@@ -89,7 +89,7 @@
"socket.io-client": "^4.8.3",
"styled-components": "^6.3.12",
"vite-plugin-ejs": "^1.7.0",
"web-vitals": "^5.1.0"
"web-vitals": "^5.2.0"
},
"scripts": {
"postinstall": "echo 'when updating react-big-calendar, remember to check to localizer in the calendar wrapper'",
@@ -137,10 +137,10 @@
"@rollup/rollup-linux-x64-gnu": "4.6.1"
},
"devDependencies": {
"@ant-design/icons": "^6.1.0",
"@ant-design/icons": "^6.1.1",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.28.5",
"@dotenvx/dotenvx": "^1.57.2",
"@dotenvx/dotenvx": "^1.59.1",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.39.2",
@@ -150,7 +150,7 @@
"@testing-library/react": "^16.3.2",
"@vitejs/plugin-react": "^5.1.4",
"babel-plugin-react-compiler": "^1.0.0",
"browserslist": "^4.28.1",
"browserslist": "^4.28.2",
"browserslist-to-esbuild": "^2.1.1",
"chalk": "^5.6.2",
"eslint": "^9.39.2",
@@ -167,10 +167,10 @@
"vite": "^7.3.1",
"vite-plugin-babel": "^1.6.0",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-node-polyfills": "^0.25.0",
"vite-plugin-node-polyfills": "^0.26.0",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-style-import": "^2.0.0",
"vitest": "^4.1.0",
"vitest": "^4.1.2",
"workbox-window": "^7.4.0"
}
}

View File

@@ -1,5 +1,5 @@
import { Alert } from "antd";
export default function AlertComponent(props) {
return <Alert {...props} />;
export default function AlertComponent({ title, message, ...props }) {
return <Alert {...props} title={title ?? message} />;
}

View File

@@ -13,6 +13,7 @@ import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import dayjs from "../../utils/day";
import { buildBillUpdateAuditDetails } from "../../utils/auditTrailDetails";
import AlertComponent from "../alert/alert.component";
import BillFormContainer from "../bill-form/bill-form.container";
import BillMarkExportedButton from "../bill-mark-exported-button/bill-mark-exported-button.component";
@@ -134,10 +135,16 @@ export function BillDetailEditcontainer({ insertAuditTrail, bodyshop }) {
await Promise.all(updates);
const details = buildBillUpdateAuditDetails({
originalBill: data?.bills_by_pk,
bill,
billlines
});
insertAuditTrail({
jobid: bill.jobid,
jobid: bill.jobid ?? data?.bills_by_pk?.jobid,
billid: search.billid,
operation: AuditTrailMapping.billupdated(bill.invoice_number),
operation: AuditTrailMapping.billupdated(bill.invoice_number, details),
type: "billupdated"
});

View File

@@ -64,7 +64,7 @@ function normalizeJobAllocations(ack) {
* RR-specific DMS Allocations Summary
* Focused on what we actually send to RR:
* - ROGOG (split by taxable / non-taxable segments)
* - ROLABOR shell
* - ROLABOR labor rows with bill hours / rates
*
* The heavy lifting (ROGOG/ROLABOR split, cost allocation, tax flags)
* is now done on the backend via buildRogogFromAllocations/buildRolaborFromRogog.
@@ -181,21 +181,30 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
const rolaborRows = useMemo(() => {
if (!rolaborPreview || !Array.isArray(rolaborPreview.ops)) return [];
return rolaborPreview.ops.map((op, idx) => {
const rowOpCode = opCode || op.opCode;
return rolaborPreview.ops
.filter((op) =>
[op.bill?.jobTotalHrs, op.bill?.billTime, op.bill?.billRate, op.amount?.custPrice, op.amount?.totalAmt]
.map((value) => Number.parseFloat(value ?? "0"))
.some((value) => !Number.isNaN(value) && value !== 0)
)
.map((op, idx) => {
const rowOpCode = opCode || op.opCode;
return {
key: `${op.jobNo}-${idx}`,
opCode: rowOpCode,
jobNo: op.jobNo,
custPayTypeFlag: op.custPayTypeFlag,
custTxblNtxblFlag: op.custTxblNtxblFlag,
payType: op.bill?.payType,
amtType: op.amount?.amtType,
custPrice: op.amount?.custPrice,
totalAmt: op.amount?.totalAmt
};
});
return {
key: `${op.jobNo}-${idx}`,
opCode: rowOpCode,
jobNo: op.jobNo,
custPayTypeFlag: op.custPayTypeFlag,
custTxblNtxblFlag: op.custTxblNtxblFlag,
payType: op.bill?.payType,
jobTotalHrs: op.bill?.jobTotalHrs,
billTime: op.bill?.billTime,
billRate: op.bill?.billRate,
amtType: op.amount?.amtType,
custPrice: op.amount?.custPrice,
totalAmt: op.amount?.totalAmt
};
});
}, [rolaborPreview, opCode]);
// Totals for ROGOG (sum custPrice + dlrCost over all lines)
@@ -245,6 +254,9 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
{ title: "CustPayType", dataIndex: "custPayTypeFlag", key: "custPayTypeFlag" },
{ title: "CustTxblFlag", dataIndex: "custTxblNtxblFlag", key: "custTxblNtxblFlag" },
{ title: "PayType", dataIndex: "payType", key: "payType" },
{ title: "JobTotalHrs", dataIndex: "jobTotalHrs", key: "jobTotalHrs" },
{ title: "BillTime", dataIndex: "billTime", key: "billTime" },
{ title: "BillRate", dataIndex: "billRate", key: "billRate" },
{ title: "AmtType", dataIndex: "amtType", key: "amtType" },
{ title: "CustPrice", dataIndex: "custPrice", key: "custPrice" },
{ title: "TotalAmt", dataIndex: "totalAmt", key: "totalAmt" }
@@ -317,12 +329,13 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
children: (
<>
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
This mirrors the shell that would be sent for ROLABOR when all financials are carried in GOG.
This mirrors the labor rows RR will receive, including weighted bill hours and rates derived from the
job&apos;s labor lines.
</Typography.Paragraph>
<ResponsiveTable
pagination={false}
columns={rolaborColumns}
mobileColumnKeys={["jobNo", "opCode", "breakOut", "itemType"]}
mobileColumnKeys={["jobNo", "opCode", "billRate", "custPrice"]}
rowKey="key"
dataSource={rolaborRows}
locale={{ emptyText: "No ROLABOR lines would be generated." }}

View File

@@ -4,20 +4,203 @@ import AlertComponent from "../alert/alert.component";
import "./form-fields-changed.styles.scss";
import Prompt from "../../utils/prompt";
export default function FormsFieldChanged({ form, skipPrompt }) {
export default function FormsFieldChanged({ form, skipPrompt, onErrorNavigate, onReset, onDirtyChange }) {
const { t } = useTranslation();
const normalizeNamePath = (namePath) => (Array.isArray(namePath) ? namePath.filter((part) => part !== undefined) : [namePath]);
const getFieldIdCandidates = (namePath) => {
const normalizedNamePath = normalizeNamePath(namePath).map((part) => String(part));
const underscoreId = normalizedNamePath.join("_");
const dashId = normalizedNamePath.join("-");
const dotName = normalizedNamePath.join(".");
return [underscoreId, dashId, dotName].filter(Boolean);
};
const clearFormMeta = () => {
const fieldMeta = form.getFieldsError().map(({ name }) => ({
name,
touched: false,
validating: false,
errors: [],
warnings: []
}));
if (fieldMeta.length > 0) {
form.setFields(fieldMeta);
}
onDirtyChange?.(false);
};
const handleReset = () => {
form.resetFields();
if (onReset) {
onReset();
} else {
form.resetFields();
}
window.requestAnimationFrame(() => {
clearFormMeta();
});
};
const getFieldDomNode = (namePath) => {
const fieldInstance = form.getFieldInstance?.(namePath);
const fieldIdCandidates = getFieldIdCandidates(namePath);
const domCandidates = [
fieldInstance?.nativeElement,
fieldInstance?.input,
fieldInstance?.resizableTextArea?.textArea,
fieldInstance
];
fieldIdCandidates.forEach((fieldId) => {
const escapedFieldId = CSS.escape(fieldId);
const directNode = document.getElementById(fieldId) || document.querySelector(`#${escapedFieldId}`);
const labelNode = document.querySelector(`label[for="${escapedFieldId}"]`);
const namedNode = document.querySelector(`[name="${escapedFieldId}"]`);
const formItemNode =
directNode?.closest?.(".ant-form-item") ||
labelNode?.closest?.(".ant-form-item") ||
namedNode?.closest?.(".ant-form-item");
domCandidates.push(directNode);
domCandidates.push(namedNode);
domCandidates.push(formItemNode);
domCandidates.push(formItemNode?.querySelector?.("input, textarea, select, .ant-select-selector"));
});
return domCandidates.find((candidate) => candidate instanceof HTMLElement) ?? null;
};
const waitForAnimationFrames = (frameCount = 1) =>
new Promise((resolve) => {
let remainingFrames = frameCount;
const nextFrame = () => {
if (remainingFrames <= 0) {
resolve();
return;
}
remainingFrames -= 1;
window.requestAnimationFrame(nextFrame);
};
window.requestAnimationFrame(nextFrame);
});
const getFieldOwningTabMeta = (namePath) => {
const fieldDomNode = getFieldDomNode(namePath);
const owningTabPane = fieldDomNode?.closest?.(".ant-tabs-tabpane");
const paneId = owningTabPane?.getAttribute?.("id") || null;
const owningTabButton = paneId
? document.querySelector(`[role="tab"][aria-controls="${paneId.replace(/"/g, '\\"')}"]`)
: null;
const tabLabel = owningTabButton?.textContent?.trim() || null;
return {
owningTabPane,
owningTabButton,
tabLabel
};
};
const openFieldOwningTab = async (namePath) => {
const { owningTabPane, owningTabButton } = getFieldOwningTabMeta(namePath);
if (!owningTabPane || owningTabPane.classList.contains("ant-tabs-tabpane-active")) return false;
if (!(owningTabButton instanceof HTMLElement)) return false;
owningTabButton.click();
for (let index = 0; index < 24; index += 1) {
await waitForAnimationFrames();
if (owningTabPane.classList.contains("ant-tabs-tabpane-active")) return true;
}
return owningTabPane.classList.contains("ant-tabs-tabpane-active");
};
const scrollToErrorField = (namePath) => {
const normalizedNamePath = normalizeNamePath(namePath);
if (!normalizedNamePath.length) return;
try {
form.scrollToField(normalizedNamePath, {
behavior: "smooth",
block: "center",
focus: true
});
window.requestAnimationFrame(() => {
const fallbackNode = getFieldDomNode(normalizedNamePath);
fallbackNode?.focus?.();
});
return;
} catch {
const fallbackTarget = document.getElementById(normalizedNamePath[0]?.toString?.() ?? "");
fallbackTarget?.scrollIntoView({
behavior: "smooth",
block: "center"
});
}
};
const handleErrorClick = async (namePath) => {
const normalizedNamePath = normalizeNamePath(namePath);
if (!normalizedNamePath.length) return;
const switchedTab = await openFieldOwningTab(normalizedNamePath);
if (!switchedTab) {
const navigationDelayMs = onErrorNavigate?.(normalizedNamePath) ?? 0;
if (navigationDelayMs > 0) {
window.setTimeout(() => {
window.requestAnimationFrame(() => {
scrollToErrorField(normalizedNamePath);
});
}, navigationDelayMs);
return;
}
}
await waitForAnimationFrames(switchedTab ? 2 : 1);
scrollToErrorField(normalizedNamePath);
};
//if (!form.isFieldsTouched()) return <></>;
return (
<Form.Item className="form-fields-changed" shouldUpdate style={{ margin: 0, padding: 0, minHeight: "unset" }}>
{() => {
const errors = form.getFieldsError().filter((e) => e.errors.length > 0);
const errors = form
.getFieldsError()
.filter((fieldError) => fieldError.errors.length > 0)
.flatMap((fieldError) => {
const tabMeta = getFieldOwningTabMeta(fieldError.name);
return fieldError.errors.map((errorMessage, errorIndex) => ({
key: `${(fieldError.name || []).join(".")}-${errorIndex}-${errorMessage}`,
message: errorMessage,
namePath: fieldError.name,
tabLabel: tabMeta.tabLabel
}));
});
const groupedErrors = errors.reduce((groups, error) => {
const groupKey = error.tabLabel || "__ungrouped__";
if (!groups[groupKey]) {
groups[groupKey] = {
key: groupKey,
label: error.tabLabel,
errors: []
};
}
groups[groupKey].errors.push(error);
return groups;
}, {});
const errorGroups = Object.values(groupedErrors);
const hasTabbedErrorGroups = errorGroups.some((group) => Boolean(group.label));
if (form.isFieldsTouched())
return (
<Space orientation="vertical" style={{ width: "100%" }}>
<Space orientation="vertical" style={{ width: "100%", marginBottom: 10 }}>
<Prompt when={!skipPrompt} beforeUnload={true} message={t("general.messages.unsavedchangespopup")} />
<AlertComponent
type="warning"
@@ -39,10 +222,35 @@ export default function FormsFieldChanged({ form, skipPrompt }) {
{errors.length > 0 && (
<AlertComponent
type="error"
message={t("general.labels.validationerror")}
title={t("general.labels.validationerror")}
description={
<div>
<ul>{errors.map((e, idx) => e.errors.map((e2, idx2) => <li key={`${idx}${idx2}`}>{e2}</li>))}</ul>
<div className="form-fields-changed__error-groups">
{errorGroups.map((group) => (
<div key={group.key} className="form-fields-changed__error-group">
{hasTabbedErrorGroups && group.label ? (
<div className="form-fields-changed__error-group-title">{group.label}</div>
) : null}
<ul className="form-fields-changed__error-list">
{group.errors.map((error) => (
<li key={error.key}>
{Array.isArray(error.namePath) && error.namePath.length > 0 ? (
<button
type="button"
className="form-fields-changed__error-link"
onClick={() => {
handleErrorClick(error.namePath);
}}
>
{error.message}
</button>
) : (
error.message
)}
</li>
))}
</ul>
</div>
))}
</div>
}
showIcon

View File

@@ -4,4 +4,47 @@
min-height: unset !important;
}
}
&__error-list {
margin: 0;
padding-left: 18px;
}
&__error-groups {
display: grid;
gap: 10px;
}
&__error-group {
display: grid;
gap: 4px;
}
&__error-group-title {
font-weight: 600;
}
&__error-link {
display: inline;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
line-height: inherit;
text-align: left;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
color: color-mix(in srgb, var(--ant-color-error) 82%, var(--ant-color-text));
}
&:focus-visible {
outline: 2px solid color-mix(in srgb, var(--ant-color-error) 32%, transparent);
outline-offset: 2px;
border-radius: 4px;
}
}
}

View File

@@ -1,11 +1,88 @@
import { Input } from "antd";
import { PhoneFilled } from "@ant-design/icons";
import { Button, Input, Space } from "antd";
import i18n from "i18next";
import parsePhoneNumber from "libphonenumber-js";
import { forwardRef, useMemo, useState } from "react";
import "./phone-form-item.styles.scss";
function FormItemPhone({ ref, ...props }) {
return <Input ref={ref} {...props} />;
}
/**
* Formats a phone number for display purposes. If the input value is a valid phone number, it will be formatted in a
* national format (e.g., (123) 456-7890 for US/CA). If the input is not a valid phone number, it will be returned as-is.
* @param value
* @returns {*}
*/
const formatPhoneDisplayValue = (value) => {
if (!value) return value;
try {
const parsedPhone = parsePhoneNumber(value, "CA");
return parsedPhone?.isValid() ? parsedPhone.formatNational() : value;
} catch {
return value;
}
};
/**
* Generates a "tel:" URL for a phone number if it's valid. If the input value is a valid phone number, it will return a
* URL in the format "tel:+1234567890". If the input is not a valid phone number, it will attempt to trim whitespace and
* return a "tel:" URL with the raw value, or null if the trimmed value is empty.
* @param value
* @returns {string|null}
*/
const getPhoneActionHref = (value) => {
if (!value) return null;
try {
const parsedPhone = parsePhoneNumber(value, "CA");
if (parsedPhone?.isValid()) return `tel:${parsedPhone.number}`;
} catch {
// Fall back to the raw value below.
}
const trimmedValue = String(value).trim();
return trimmedValue ? `tel:${trimmedValue}` : null;
};
const FormItemPhone = forwardRef(function FormItemPhone(
{ formatDisplayOnly = false, showPhoneAction = false, value, onBlur, onFocus, ...props },
ref
) {
const [isFocused, setIsFocused] = useState(false);
const displayValue = useMemo(() => {
if (!formatDisplayOnly || isFocused) return value;
return formatPhoneDisplayValue(value);
}, [formatDisplayOnly, isFocused, value]);
const phoneActionHref = useMemo(() => (showPhoneAction ? getPhoneActionHref(value) : null), [showPhoneAction, value]);
const input = (
<Input
ref={ref}
{...props}
value={displayValue}
onFocus={(event) => {
setIsFocused(true);
onFocus?.(event);
}}
onBlur={(event) => {
setIsFocused(false);
onBlur?.(event);
}}
/>
);
if (!showPhoneAction) return input;
return (
<Space.Compact style={{ width: "100%" }}>
{input}
{phoneActionHref ? (
<Button icon={<PhoneFilled />} href={phoneActionHref} />
) : (
<Button icon={<PhoneFilled />} disabled />
)}
</Space.Compact>
);
});
export default FormItemPhone;

View File

@@ -0,0 +1,34 @@
import { LinkOutlined } from "@ant-design/icons";
import { Button, Input, Space } from "antd";
import { forwardRef, useMemo } from "react";
const HAS_URL_PROTOCOL_REGEX = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
const LOCALHOST_OR_IP_REGEX = /^(localhost|127(?:\.\d{1,3}){3}|\d{1,3}(?:\.\d{1,3}){3})(:\d+)?(\/.*)?$/i;
const getUrlActionHref = (value) => {
const trimmedValue = String(value ?? "").trim();
if (!trimmedValue) return null;
if (HAS_URL_PROTOCOL_REGEX.test(trimmedValue)) return trimmedValue;
if (trimmedValue.startsWith("//")) return `https:${trimmedValue}`;
if (LOCALHOST_OR_IP_REGEX.test(trimmedValue)) return `http://${trimmedValue}`;
return `https://${trimmedValue}`;
};
const FormItemUrl = forwardRef(function FormItemUrl({ value, defaultValue, ...props }, ref) {
const urlActionHref = useMemo(() => getUrlActionHref(value ?? defaultValue), [defaultValue, value]);
return (
<Space.Compact style={{ width: "100%" }}>
<Input ref={ref} {...props} value={value} defaultValue={defaultValue} />
{urlActionHref ? (
<Button icon={<LinkOutlined />} href={urlActionHref} target="_blank" rel="noopener noreferrer" />
) : (
<Button icon={<LinkOutlined />} disabled />
)}
</Space.Compact>
);
});
export default FormItemUrl;

View File

@@ -0,0 +1,30 @@
/**
* Normalize Form Item List Titles
* @param value
* @returns {*|string}
*/
const normalizeFormListTitleValue = (value) => {
if (value === null || value === undefined) return "";
if (Array.isArray(value)) {
return value
.map((item) => normalizeFormListTitleValue(item))
.filter(Boolean)
.join(", ");
}
return String(value).trim();
};
/**
* Get Form Listem Item Title
* @param fallbackLabel
* @param index
* @param candidates
* @returns {*|string}
*/
export function getFormListItemTitle(fallbackLabel, index, ...candidates) {
const title = candidates.map((candidate) => normalizeFormListTitleValue(candidate)).find(Boolean);
return title || `${fallbackLabel} ${index + 1}`;
}

View File

@@ -14,16 +14,20 @@ import CriticalPartsScan from "../../utils/criticalPartsScan";
import UndefinedToNull from "../../utils/undefinedtonull";
import JobLinesUpdsertModal from "./job-lines-upsert-modal.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
import { insertAuditTrail } from "../../redux/application/application.actions.js";
import { buildJobLineInsertAuditDetails, buildJobLineUpdateAuditDetails } from "../../utils/auditTrailDetails.js";
const mapStateToProps = createStructuredSelector({
jobLineEditModal: selectJobLineEditModal,
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("jobLineEdit"))
toggleModalVisible: () => dispatch(toggleModalVisible("jobLineEdit")),
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
});
function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bodyshop }) {
function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bodyshop, insertAuditTrail }) {
const {
treatments: { CriticalPartsScanning }
} = useTreatmentsWithConfig({
@@ -74,6 +78,11 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo
notification.success({
title: t("joblines.successes.created")
});
insertAuditTrail({
jobid: jobLineEditModal.context.jobid,
operation: AuditTrailMapping.jobmanuallineinsert(buildJobLineInsertAuditDetails(values)),
type: "jobmanuallineinsert"
});
} else {
notification.error({
title: t("joblines.errors.creating", {
@@ -103,6 +112,17 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo
notification.success({
title: t("joblines.successes.updated")
});
insertAuditTrail({
jobid: jobLineEditModal.context.jobid,
operation: AuditTrailMapping.joblineupdate(
values.line_desc || jobLineEditModal.context.line_desc || "manual line",
buildJobLineUpdateAuditDetails({
originalLine: jobLineEditModal.context,
values
})
),
type: "joblineupdate"
});
} else {
notification.success({
title: t("joblines.errors.updating", {

View File

@@ -0,0 +1,17 @@
import { Button } from "antd";
import ConfigListEmptyState from "./config-list-empty-state.component.jsx";
export const buildConfigListActionButton = ({ key, label, onClick, id }) => (
<Button key={key} type="primary" block id={id} onClick={onClick}>
{label}
</Button>
);
export const renderConfigListOrEmpty = ({ fields, actionLabel, renderItems }) =>
fields.length === 0 ? <ConfigListEmptyState actionLabel={actionLabel} /> : renderItems();
export const buildSectionActionButton = (key, label, onClick, id) =>
buildConfigListActionButton({ key, label, onClick, id });
export const renderListOrEmpty = (fields, actionLabel, renderItems) =>
renderConfigListOrEmpty({ fields, actionLabel, renderItems });

View File

@@ -0,0 +1,11 @@
import { useTranslation } from "react-i18next";
export default function ConfigListEmptyState({ actionLabel, minHeight = 96 }) {
const { t } = useTranslation();
return (
<div className="imex-form-row-empty-state" style={{ minHeight }}>
{t("general.labels.click_to_begin", { action: actionLabel })}
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { UnorderedListOutlined } from "@ant-design/icons";
export const inlineFormRowTitleStyles = Object.freeze({
input: Object.freeze({
background: "transparent",
border: "none",
borderRadius: 0,
boxShadow: "none",
paddingInline: 0,
paddingBlock: 0,
lineHeight: 1.35,
flex: "1 1 auto",
minWidth: 0,
width: "100%"
}),
row: Object.freeze({
display: "flex",
gap: 6,
flexWrap: "wrap",
alignItems: "center",
width: "100%",
paddingInline: 4
}),
group: Object.freeze({
display: "flex",
alignItems: "center",
gap: 8,
paddingInline: 8,
paddingBlock: 4,
borderRadius: 10,
border: "1px solid var(--imex-form-title-group-border)",
background: "var(--imex-form-title-group-bg)",
minWidth: 0,
flex: "1 1 0"
}),
label: Object.freeze({
color: "var(--ant-color-text-secondary)",
fontSize: 12,
fontWeight: 600,
lineHeight: 1,
whiteSpace: "nowrap",
paddingInline: 6,
paddingBlock: 3,
borderRadius: 999,
border: "1px solid var(--imex-form-title-label-border)",
background: "var(--imex-form-title-label-bg)"
}),
handle: Object.freeze({
color: "var(--ant-color-text-tertiary)",
fontSize: 14,
flex: "0 0 auto",
marginRight: 2
}),
separator: Object.freeze({
width: 1,
height: 16,
background: "color-mix(in srgb, var(--imex-form-surface-border) 58%, transparent)",
borderRadius: 999,
flex: "0 0 auto",
marginInline: 2
}),
text: Object.freeze({
whiteSpace: "nowrap",
fontWeight: 500,
fontSize: "var(--ant-font-size-lg)",
lineHeight: 1.2
})
});
export const INLINE_TITLE_INPUT_STYLE = inlineFormRowTitleStyles.input;
export const INLINE_TITLE_ROW_STYLE = inlineFormRowTitleStyles.row;
export const INLINE_TITLE_GROUP_STYLE = inlineFormRowTitleStyles.group;
export const InlineTitleListIcon = UnorderedListOutlined;
export const INLINE_TITLE_SWITCH_GROUP_STYLE = Object.freeze({
...inlineFormRowTitleStyles.group,
flex: "0 0 auto"
});
export const INLINE_TITLE_LABEL_STYLE = inlineFormRowTitleStyles.label;
export const INLINE_TITLE_HANDLE_STYLE = inlineFormRowTitleStyles.handle;
export const INLINE_TITLE_SEPARATOR_STYLE = inlineFormRowTitleStyles.separator;
export const INLINE_TITLE_TEXT_STYLE = inlineFormRowTitleStyles.text;
export const INLINE_FORM_ROW_WRAP_TITLE_STYLES = Object.freeze({
title: Object.freeze({
whiteSpace: "normal",
overflow: "visible",
textOverflow: "unset"
})
});

View File

@@ -0,0 +1,47 @@
import { Form } from "antd";
import LayoutFormRow from "./layout-form-row.component";
export default function InlineValidatedFormRow({ actions, errorNames = [], extraErrors = [], form, ...layoutFormRowProps }) {
const normalizedErrorNames = Array.isArray(errorNames) ? errorNames : [errorNames];
const normalizedExtraErrors = Array.isArray(extraErrors) ? extraErrors.filter(Boolean) : [extraErrors].filter(Boolean);
return (
<Form.Item noStyle shouldUpdate>
{() => {
const fieldErrors = normalizedErrorNames.flatMap((name) => form?.getFieldError?.(name) || []);
const errors = [...new Set([...fieldErrors, ...normalizedExtraErrors])];
const resolvedClassName = [
layoutFormRowProps.className,
errors.length > 0 ? "imex-form-row--error" : null
]
.filter(Boolean)
.join(" ");
const normalizedActions = Array.isArray(actions) ? actions.filter(Boolean) : [actions].filter(Boolean);
const resolvedActions =
errors.length > 0
? [
<div
key="inline-form-row-footer"
className="imex-inline-form-row-errors"
style={{
display: "flex",
flexDirection: "column",
gap: normalizedActions.length > 0 ? 8 : 0,
width: "100%",
textAlign: "left"
}}
>
<Form.ErrorList errors={errors} />
{normalizedActions.length > 0 ? <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>{normalizedActions}</div> : null}
</div>
]
: normalizedActions.length > 0
? normalizedActions
: undefined;
return <LayoutFormRow {...layoutFormRowProps} className={resolvedClassName} actions={resolvedActions} />;
}}
</Form.Item>
);
}

View File

@@ -1,5 +1,6 @@
import { Card, Col, Row } from "antd";
import { Children, isValidElement } from "react";
import { INLINE_FORM_ROW_WRAP_TITLE_STYLES } from "./inline-form-row-title.utils.js";
import "./layout-form-row.styles.scss";
export default function LayoutFormRow({
@@ -7,32 +8,45 @@ export default function LayoutFormRow({
children,
grow = false,
noDivider = false,
gutter = [16, 16], // Responsive gutter: horizontal, vertical
titleOnly = false,
wrapTitle = false,
gutter,
rowProps,
// Optional overrides if you ever need per-section customization
surface = true,
surfaceBg,
surfaceHeaderBg,
surfaceBorderColor,
...cardProps
}) {
const items = Children.toArray(children).filter(Boolean);
if (items.length === 0) return null;
const isCompactRow = noDivider;
const title = !noDivider && header ? header : undefined;
const resolvedTitle = cardProps.title ?? title;
const isHeaderOnly = titleOnly || items.length === 0;
const hideBody = isHeaderOnly;
if (items.length === 0 && !resolvedTitle) return null;
const resolvedGutter = gutter ?? [16, isCompactRow ? 8 : 16];
const bg = surfaceBg ?? (surface ? "var(--imex-form-surface)" : undefined);
const headBg = surfaceHeaderBg ?? (surface ? "var(--imex-form-surface-head)" : undefined);
const borderColor = surfaceBorderColor ?? (surface ? "var(--imex-form-surface-border)" : undefined);
const mergedStyles = mergeSemanticStyles(
{
...(wrapTitle ? INLINE_FORM_ROW_WRAP_TITLE_STYLES : null),
header: {
paddingInline: 16,
background: headBg
paddingInline: isHeaderOnly ? 8 : isCompactRow ? 12 : 16,
background: headBg,
borderBottomColor: borderColor
},
body: {
padding: 16,
padding: hideBody ? 0 : isCompactRow ? 12 : 16,
display: hideBody ? "none" : undefined,
background: bg
}
},
@@ -40,28 +54,12 @@ export default function LayoutFormRow({
);
const baseCardStyle = {
marginBottom: ".8rem",
marginBottom: isHeaderOnly ? "0" : isCompactRow ? "8px" : ".8rem",
...(bg ? { background: bg } : null), // ensures the “circled area” is tinted
...(borderColor ? { borderColor } : null),
...cardProps.style
};
// single child => just render it
if (items.length === 1) {
return (
<Card
{...cardProps}
title={cardProps.title ?? title}
size={cardProps.size ?? "small"}
variant={cardProps.variant ?? "outlined"}
className={["imex-form-row", cardProps.className].filter(Boolean).join(" ")}
style={baseCardStyle}
styles={mergedStyles}
>
{items[0]}
</Card>
);
}
const count = items.length;
// Modern responsive strategy leveraging Ant Design 6:
@@ -125,20 +123,32 @@ export default function LayoutFormRow({
return (
<Card
{...cardProps}
title={cardProps.title ?? title}
title={resolvedTitle}
size={cardProps.size ?? "small"}
variant={cardProps.variant ?? "outlined"}
className={["imex-form-row", cardProps.className].filter(Boolean).join(" ")}
className={[
"imex-form-row",
isCompactRow ? "imex-form-row--compact" : null,
isHeaderOnly ? "imex-form-row--title-only" : null,
cardProps.className
]
.filter(Boolean)
.join(" ")}
style={baseCardStyle}
styles={mergedStyles}
>
<Row gutter={gutter} wrap {...rowProps}>
{items.map((child, idx) => (
<Col key={child?.key ?? idx} {...getColPropsForChild(child)}>
{child}
</Col>
{!isHeaderOnly &&
(items.length === 1 ? (
items[0]
) : (
<Row gutter={resolvedGutter} wrap {...rowProps}>
{items.map((child, idx) => (
<Col key={child?.key ?? idx} {...getColPropsForChild(child)}>
{child}
</Col>
))}
</Row>
))}
</Row>
</Card>
);
}
@@ -152,6 +162,7 @@ function mergeSemanticStyles(defaults, userStyles) {
return {
...defaults,
...computed,
title: { ...(defaults.title || {}), ...(computed.title || {}) },
header: { ...defaults.header, ...(computed.header || {}) },
body: { ...defaults.body, ...(computed.body || {}) }
};
@@ -161,6 +172,7 @@ function mergeSemanticStyles(defaults, userStyles) {
return {
...defaults,
...userStyles,
title: { ...(defaults.title || {}), ...(userStyles.title || {}) },
header: { ...defaults.header, ...(userStyles.header || {}) },
body: { ...defaults.body, ...(userStyles.body || {}) }
};

View File

@@ -13,6 +13,12 @@
--imex-form-surface: #fafafa; /* subtle contrast vs white page */
--imex-form-surface-head: #f5f5f5; /* header strip */
--imex-form-surface-border: #d9d9d9; /* matches AntD-ish border */
--imex-form-title-input-bg: rgba(255, 255, 255, 0.96);
--imex-form-title-input-border: rgba(0, 0, 0, 0.08);
--imex-form-title-group-bg: rgba(255, 255, 255, 0.72);
--imex-form-title-group-border: rgba(0, 0, 0, 0.08);
--imex-form-title-label-bg: rgba(0, 0, 0, 0.04);
--imex-form-title-label-border: rgba(0, 0, 0, 0.06);
}
/* Pick the selector that matches your app and remove the rest */
@@ -20,6 +26,12 @@ html[data-theme="dark"] {
--imex-form-surface: rgba(255, 255, 255, 0.01); /* subtle lift off page bg */
--imex-form-surface-head: rgba(255, 255, 255, 0.06); /* slightly stronger for header strip */
--imex-form-surface-border: rgba(5, 5, 5, 0.12);
--imex-form-title-input-bg: rgba(255, 255, 255, 0.12);
--imex-form-title-input-border: rgba(255, 255, 255, 0.2);
--imex-form-title-group-bg: rgba(255, 255, 255, 0.08);
--imex-form-title-group-border: rgba(255, 255, 255, 0.16);
--imex-form-title-label-bg: rgba(255, 255, 255, 0.06);
--imex-form-title-label-border: rgba(255, 255, 255, 0.12);
}
.imex-form-row {
@@ -38,18 +50,111 @@ html[data-theme="dark"] {
border-color: var(--imex-form-surface-border);
}
&.imex-form-row--error.ant-card {
border-color: var(--ant-color-error);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ant-color-error) 24%, transparent);
}
.ant-card-head {
background: var(--imex-form-surface-head);
border-bottom-color: var(--imex-form-surface-border);
}
&.imex-form-row--error {
.ant-card-head,
.ant-card-actions {
border-color: color-mix(in srgb, var(--ant-color-error) 34%, var(--imex-form-surface-border));
}
}
&.imex-form-row--compact {
.ant-card-head {
min-height: 40px;
}
.ant-card-head-title,
.ant-card-extra {
padding-block: 2px;
}
.ant-form-item {
margin-bottom: 12px;
}
}
&.imex-form-row--title-only {
.ant-card-head {
min-height: auto;
padding-inline: 6px;
padding-block: 0;
border-radius: inherit;
}
.ant-card-head-wrapper {
gap: 2px;
align-items: center;
}
.ant-card-head-title,
.ant-card-extra {
padding-block: 0;
display: flex;
align-items: center;
}
.ant-card-head-title {
white-space: normal;
overflow: visible;
text-overflow: unset;
font-size: var(--ant-font-size);
line-height: 1.1;
padding-inline: 4px;
}
.ant-card-body {
display: none;
padding: 0;
}
.ant-input,
.ant-input-number,
.ant-input-affix-wrapper,
.ant-select-selector,
.ant-picker {
background: var(--imex-form-title-input-bg);
border-color: var(--imex-form-title-input-border);
}
.ant-input-number-input {
background: transparent;
}
}
.ant-card-body {
background: var(--imex-form-surface);
}
.ant-card-actions {
background: var(--imex-form-surface-head);
border-top-color: var(--imex-form-surface-border);
}
.ant-card-actions > li {
margin: 10px 0;
padding-inline: 12px;
}
.ant-card-actions .ant-btn {
width: 100%;
}
.ant-form-item:last-child {
margin-bottom: 4px;
}
/* Optional: tighter spacing on phones for better space usage */
@media (max-width: 575px) {
.ant-card-head {
&:not(.imex-form-row--title-only) .ant-card-head {
padding-inline: 12px;
padding-block: 12px;
}
@@ -70,6 +175,14 @@ html[data-theme="dark"] {
width: 100%;
}
.ant-form-item:has(.imex-form-row--compact) {
margin-bottom: 8px;
}
.ant-form-item:has(.imex-form-row--title-only) {
margin-bottom: 4px;
}
/* Better form item spacing on mobile */
@media (max-width: 575px) {
.ant-form-item {
@@ -77,3 +190,24 @@ html[data-theme="dark"] {
}
}
}
.imex-form-row-empty-state {
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
text-align: center;
color: var(--ant-color-text-description);
font-size: var(--ant-font-size);
line-height: 1.5;
}
.imex-inline-form-row-errors {
color: var(--ant-color-error);
.ant-form-item-explain,
.ant-form-item-explain-error,
.ant-form-item-additional {
color: var(--ant-color-error);
}
}

View File

@@ -1,12 +1,13 @@
import { DeleteFilled, DownOutlined, WarningFilled } from "@ant-design/icons";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd";
import { Button, Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
import PartsOrderModalPriceChange from "./parts-order-modal-price-change.component";
@@ -50,6 +51,7 @@ export function PartsOrderModalComponent({
});
const { t } = useTranslation();
const partsOrderLines = Form.useWatch(["parts_order_lines", "data"], form) || [];
const handleClick = ({ item }) => {
form.setFieldsValue({ comments: item.props.value });
};
@@ -128,10 +130,38 @@ export function PartsOrderModalComponent({
{(fields, { remove, move }) => {
return (
<div>
{fields.map((field, index) => (
<Form.Item required={false} key={field.key}>
<div style={{ display: "flex" }}>
<LayoutFormRow grow noDivider style={{ flex: 1 }}>
{fields.map((field, index) => {
const partsOrderLine = partsOrderLines[field.name] || {};
return (
<Form.Item required={false} key={field.key}>
<LayoutFormRow
grow
noDivider
title={getFormListItemTitle(
t("parts_orders.fields.line_desc"),
index,
partsOrderLine.line_desc,
partsOrderLine.oem_partno
)}
extra={
<Space align="center" size="small">
<Button
type="text"
icon={<DeleteFilled />}
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
>
<Form.Item
//span={8}
label={t("parts_orders.fields.line_desc")}
@@ -220,20 +250,9 @@ export function PartsOrderModalComponent({
</Form.Item>
)}
</LayoutFormRow>
<Space wrap size="small" align="center">
<div>
<DeleteFilled
style={{ margin: "1rem" }}
onClick={() => {
remove(field.name);
}}
/>
</div>
<FormListMoveArrows move={move} index={index} total={fields.length} />
</Space>
</div>
</Form.Item>
))}
</Form.Item>
);
})}
</div>
);
}}

View File

@@ -1,10 +1,11 @@
import { DeleteFilled } from "@ant-design/icons";
import { Form, Input, InputNumber, Select, Typography } from "antd";
import { Button, Form, Input, InputNumber, Select, Space, Typography } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
const mapStateToProps = createStructuredSelector({
@@ -15,6 +16,7 @@ export default connect(mapStateToProps, null)(PartsReceiveModalComponent);
export function PartsReceiveModalComponent({ bodyshop, form }) {
const { t } = useTranslation();
const partsOrderLines = Form.useWatch(["partsorderlines"], form) || [];
return (
<div>
@@ -42,16 +44,43 @@ export function PartsReceiveModalComponent({ bodyshop, form }) {
{(fields, { remove, move }) => {
return (
<div>
{fields.map((field, index) => (
<Form.Item required={false} key={field.key}>
<div style={{ display: "flex", alignItems: "center" }}>
{fields.map((field, index) => {
const partsOrderLine = partsOrderLines[field.name] || {};
return (
<Form.Item required={false} key={field.key}>
<Form.Item hidden key={`${index}joblineid`} name={[field.name, "joblineid"]}>
<Input />
</Form.Item>
<Form.Item hidden key={`${index}id`} name={[field.name, "id"]}>
<Input />
</Form.Item>
<LayoutFormRow grow style={{ flex: 1 }}>
<LayoutFormRow
grow
title={getFormListItemTitle(
t("parts_orders.fields.line_desc"),
index,
partsOrderLine.line_desc,
partsOrderLine.oem_partno
)}
extra={
<Space align="center" size="small">
<Button
type="text"
icon={<DeleteFilled />}
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
>
<Form.Item
label={t("parts_orders.fields.line_desc")}
key={`${index}line_desc`}
@@ -84,7 +113,7 @@ export function PartsReceiveModalComponent({ bodyshop, form }) {
key={`${index}location`}
name={[field.name, "location"]}
>
<Select
<Select
style={{ width: "10rem" }}
options={bodyshop.md_parts_locations.map((loc, idx) => ({
key: idx,
@@ -101,16 +130,9 @@ export function PartsReceiveModalComponent({ bodyshop, form }) {
<InputNumber min={0} />
</Form.Item>
</LayoutFormRow>
<DeleteFilled
style={{ margin: "1rem" }}
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows move={move} index={index} total={fields.length} />
</div>
</Form.Item>
))}
</Form.Item>
);
})}
</div>
);
}}

View File

@@ -2,10 +2,13 @@ import { DeleteFilled } from "@ant-design/icons";
import { Button, Form, Input, Select, Space } from "antd";
import { useTranslation } from "react-i18next";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
export default function PartsEmailPresetsComponent() {
const { t } = useTranslation();
const form = Form.useFormInstance();
const emailPresets = Form.useWatch(["md_to_emails"], form) || [];
return (
<div>
@@ -14,31 +17,46 @@ export default function PartsEmailPresetsComponent() {
{(fields, { add, remove, move }) => {
return (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<LayoutFormRow noDivider>
<Form.Item label={t("general.labels.label")} key={`${index}label`} name={[field.name, "label"]}>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.labels.md_to_emails_emails")}
key={`${index}emails`}
name={[field.name, "emails"]}
>
<Select mode="tags" tokenSeparators={[",", ";"]} />
</Form.Item>
{fields.map((field, index) => {
const preset = emailPresets[field.name] || {};
<Space>
<DeleteFilled
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows move={move} index={index} total={fields.length} />
</Space>
</LayoutFormRow>
</Form.Item>
))}
return (
<Form.Item key={field.key}>
<LayoutFormRow
noDivider
title={getFormListItemTitle(t("general.labels.label"), index, preset.label, preset.emails)}
extra={
<Space align="center" size="small">
<Button
type="text"
icon={<DeleteFilled />}
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
>
<Form.Item label={t("general.labels.label")} key={`${index}label`} name={[field.name, "label"]}>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.labels.md_to_emails_emails")}
key={`${index}emails`}
name={[field.name, "emails"]}
>
<Select mode="tags" tokenSeparators={[",", ";"]} />
</Form.Item>
</LayoutFormRow>
</Form.Item>
);
})}
<Form.Item>
<Button
type="dashed"

View File

@@ -2,10 +2,13 @@ import { DeleteFilled } from "@ant-design/icons";
import { Button, Form, Input, Space } from "antd";
import { useTranslation } from "react-i18next";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
export default function PartsLocationsComponent() {
const { t } = useTranslation();
const form = Form.useFormInstance();
const partsLocations = Form.useWatch(["md_parts_locations"], form) || [];
return (
<div>
@@ -14,34 +17,49 @@ export default function PartsLocationsComponent() {
{(fields, { add, remove, move }) => {
return (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<LayoutFormRow noDivider>
<Form.Item
className="imex-flex-row__margin"
label={t("bodyshop.fields.partslocation")}
key={`${index}`}
name={[field.name]}
rules={[
{
required: true
}
]}
{fields.map((field, index) => {
const location = partsLocations[field.name];
return (
<Form.Item key={field.key}>
<LayoutFormRow
noDivider
title={getFormListItemTitle(t("bodyshop.fields.partslocation"), index, location)}
extra={
<Space align="center" size="small">
<Button
type="text"
icon={<DeleteFilled />}
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
>
<Input />
</Form.Item>
<Space wrap>
<DeleteFilled
<Form.Item
className="imex-flex-row__margin"
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows move={move} index={index} total={fields.length} />
</Space>
</LayoutFormRow>
</Form.Item>
))}
label={t("bodyshop.fields.partslocation")}
key={`${index}`}
name={[field.name]}
rules={[
{
required: true
}
]}
>
<Input />
</Form.Item>
</LayoutFormRow>
</Form.Item>
);
})}
<Form.Item>
<Button
type="dashed"

View File

@@ -2,10 +2,13 @@ import { DeleteFilled } from "@ant-design/icons";
import { Button, Form, Input, Space } from "antd";
import { useTranslation } from "react-i18next";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
export default function PartsOrderCommentsComponent() {
const { t } = useTranslation();
const form = Form.useFormInstance();
const orderComments = Form.useWatch(["md_parts_order_comment"], form) || [];
return (
<div>
@@ -14,45 +17,65 @@ export default function PartsOrderCommentsComponent() {
{(fields, { add, remove, move }) => {
return (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<LayoutFormRow noDivider>
<Form.Item
label={t("general.labels.label")}
key={`${index}label`}
name={[field.name, "label"]}
rules={[
{
required: true
}
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("parts_orders.fields.comments")}
key={`${index}comment`}
name={[field.name, "comment"]}
rules={[
{
required: true
}
]}
>
<Input.TextArea autoSize />
</Form.Item>
{fields.map((field, index) => {
const comment = orderComments[field.name] || {};
<Space wrap>
<DeleteFilled
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows move={move} index={index} total={fields.length} />
</Space>
</LayoutFormRow>
</Form.Item>
))}
return (
<Form.Item key={field.key}>
<LayoutFormRow
noDivider
title={getFormListItemTitle(
t("parts_orders.fields.comments"),
index,
comment.label,
comment.comment
)}
extra={
<Space align="center" size="small">
<Button
type="text"
icon={<DeleteFilled />}
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
>
<Form.Item
label={t("general.labels.label")}
key={`${index}label`}
name={[field.name, "label"]}
rules={[
{
required: true
}
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("parts_orders.fields.comments")}
key={`${index}comment`}
name={[field.name, "comment"]}
rules={[
{
required: true
}
]}
>
<Input.TextArea autoSize />
</Form.Item>
</LayoutFormRow>
</Form.Item>
);
})}
<Form.Item>
<Button
type="dashed"

View File

@@ -8,7 +8,7 @@ import { INSERT_VACATION } from "../../graphql/employees.queries";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
export default function ShopEmployeeAddVacation({ employee }) {
export default function ShopEmployeeAddVacation({ employee, buttonProps }) {
const { t } = useTranslation();
const [insertVacation] = useMutation(INSERT_VACATION);
@@ -117,7 +117,7 @@ export default function ShopEmployeeAddVacation({ employee }) {
return (
<Popover content={overlay} open={visibility}>
<Button loading={loading} disabled={!employee?.active} onClick={handleClick}>
<Button loading={loading} disabled={!employee?.active} onClick={handleClick} {...buttonProps}>
{t("employees.actions.addvacation")}
</Button>
</Popover>

View File

@@ -1,11 +1,10 @@
import { DeleteFilled } from "@ant-design/icons";
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Card, Form, Input, InputNumber, Select, Switch } from "antd";
import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useForm } from "antd/es/form/Form";
import queryString from "query-string";
import { useEffect } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
@@ -26,9 +25,24 @@ import { DateFormatter } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import AlertComponent from "../alert/alert.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import FormsFieldChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import {
INLINE_TITLE_GROUP_STYLE,
INLINE_TITLE_HANDLE_STYLE,
INLINE_TITLE_INPUT_STYLE,
INLINE_TITLE_LABEL_STYLE,
INLINE_TITLE_ROW_STYLE,
INLINE_TITLE_SEPARATOR_STYLE,
INLINE_TITLE_SWITCH_GROUP_STYLE,
INLINE_TITLE_TEXT_STYLE,
InlineTitleListIcon
} from "../layout-form-row/inline-form-row-title.utils.js";
import ShopEmployeeAddVacation from "./shop-employees-add-vacation.component";
import FormItemEmail from "../form-items-formatted/email-form-item.component.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -37,19 +51,38 @@ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export function ShopEmployeesFormComponent({ bodyshop }) {
export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDirty }) {
const submitActionRef = useRef("save");
const { t } = useTranslation();
const [form] = useForm();
const [internalIsDirty, setInternalIsDirty] = useState(false);
const resolvedIsDirty = typeof isDirty === "boolean" ? isDirty : internalIsDirty;
const employeeNumber = Form.useWatch("employee_number", form);
const firstName = Form.useWatch("first_name", form);
const lastName = Form.useWatch("last_name", form);
const employeeOptionsColProps = {
xs: 24,
sm: 12,
md: 12,
lg: 8,
xl: 8,
xxl: 8
};
const history = useNavigate();
const search = queryString.parse(useLocation().search);
const [deleteVacation] = useMutation(DELETE_VACATION);
const { error, data } = useQuery(QUERY_EMPLOYEE_BY_ID, {
const { error, data, refetch } = useQuery(QUERY_EMPLOYEE_BY_ID, {
variables: { id: search.employeeId },
skip: !search.employeeId || search.employeeId === "new",
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
const notification = useNotification();
const isNewEmployee = search.employeeId === "new";
const currentEmployeeData = data?.employees_by_pk?.id === search.employeeId ? data.employees_by_pk : null;
const employeeTitleName = [firstName, lastName].filter(Boolean).join(" ").trim();
const employeeCardTitle =
[employeeNumber, employeeTitleName].filter(Boolean).join(" - ") ||
(isNewEmployee ? t("employees.actions.new") : t("bodyshop.labels.employees"));
const {
treatments: { Enhanced_Payroll }
@@ -59,56 +92,150 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
splitKey: bodyshop.imexshopid
});
const updateDirtyState = useCallback(
(nextDirtyState) => {
setInternalIsDirty(nextDirtyState);
onDirtyChange?.(nextDirtyState);
},
[onDirtyChange]
);
const client = useApolloClient();
useEffect(() => {
if (data && data.employees_by_pk) form.setFieldsValue(data.employees_by_pk);
else {
form.resetFields();
const clearEmployeeFormMeta = useCallback(() => {
const fieldMeta = form.getFieldsError().map(({ name }) => ({
name,
touched: false,
validating: false,
errors: [],
warnings: []
}));
if (fieldMeta.length > 0) {
form.setFields(fieldMeta);
}
}, [form, data, search.employeeId]);
updateDirtyState(false);
}, [form, updateDirtyState]);
const resetEmployeeFormToCurrentData = useCallback(() => {
form.resetFields();
if (currentEmployeeData) {
form.setFieldsValue(currentEmployeeData);
}
window.requestAnimationFrame(() => {
clearEmployeeFormMeta();
});
}, [clearEmployeeFormMeta, currentEmployeeData, form]);
const syncEmployeeFormToSavedData = useCallback(
(employeeData) => {
if (employeeData) {
form.setFieldsValue(employeeData);
}
window.requestAnimationFrame(() => {
clearEmployeeFormMeta();
});
},
[clearEmployeeFormMeta, form]
);
useEffect(() => {
resetEmployeeFormToCurrentData();
}, [resetEmployeeFormToCurrentData, search.employeeId]);
const [updateEmployee] = useMutation(UPDATE_EMPLOYEE);
const [insertEmployees] = useMutation(INSERT_EMPLOYEES);
const saveAndResetSubmitAction = useCallback(() => {
const submitAction = submitActionRef.current;
submitActionRef.current = "save";
return submitAction;
}, []);
const submitEmployeeForm = useCallback(
(submitAction = "save") => {
submitActionRef.current = submitAction;
form.submit();
},
[form]
);
const navigateToEmployee = useCallback(
(employeeId) => {
history({
search: queryString.stringify({
...search,
employeeId
})
});
},
[history, search]
);
const handleFinish = async (values) => {
const submitAction = saveAndResetSubmitAction();
const normalizedValues = {
...values,
user_email: values.user_email === "" ? null : values.user_email
};
const handleFinish = (values) => {
if (search.employeeId && search.employeeId !== "new") {
//Update a record.
logImEXEvent("shop_employee_update");
updateEmployee({
variables: {
id: search.employeeId,
employee: {
...values,
user_email: values.user_email === "" ? null : values.user_email
try {
const result = await updateEmployee({
variables: {
id: search.employeeId,
employee: normalizedValues
}
}
})
.then(() => {
notification.success({
title: t("employees.successes.save")
});
})
.catch((error) => {
notification.error({
title: t("employees.errors.save", {
message: JSON.stringify(error)
})
});
});
} else {
//New record, insert it.
logImEXEvent("shop_employee_insert");
insertEmployees({
variables: { employees: [{ ...values, shopid: bodyshop.id }] },
refetchQueries: ["QUERY_EMPLOYEES"]
}).then((r) => {
search.employeeId = r.data.insert_employees.returning[0].id;
history({ search: queryString.stringify(search) });
syncEmployeeFormToSavedData(result?.data?.update_employees?.returning?.[0] ?? normalizedValues);
void refetch();
if (submitAction === "saveAndNew") {
navigateToEmployee("new");
}
notification.success({
title: t("employees.successes.save")
});
} catch (error) {
notification.error({
title: t("employees.errors.save", {
message: JSON.stringify(error)
})
});
}
return;
}
//New record, insert it.
logImEXEvent("shop_employee_insert");
try {
const result = await insertEmployees({
variables: { employees: [{ ...normalizedValues, shopid: bodyshop.id }] },
refetchQueries: ["QUERY_EMPLOYEES"]
});
const savedEmployee = result?.data?.insert_employees?.returning?.[0];
syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues);
if (submitAction === "saveAndNew") {
navigateToEmployee("new");
} else if (savedEmployee?.id) {
navigateToEmployee(savedEmployee.id);
}
notification.success({
title: t("employees.successes.save")
});
} catch (error) {
notification.error({
title: t("employees.errors.save", {
message: JSON.stringify(error)
})
});
}
};
@@ -141,6 +268,8 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
key: "actions",
render: (text, record) => (
<Button
type="text"
danger
onClick={async () => {
await deleteVacation({
variables: { id: record.id },
@@ -168,225 +297,365 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
return (
<Card
title={employeeCardTitle}
extra={
<Button type="primary" onClick={() => form.submit()}>
{t("general.actions.save")}
</Button>
<Space wrap>
<Button onClick={() => submitEmployeeForm("saveAndNew")} disabled={!resolvedIsDirty} style={{ minWidth: 170 }}>
{t("general.actions.saveandnew") || "Save and New"}
</Button>
<Button
type="primary"
onClick={() => submitEmployeeForm("save")}
disabled={!resolvedIsDirty}
style={{ minWidth: 170 }}
>
{t("employees.actions.save_employee")}
</Button>
</Space>
}
>
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
<LayoutFormRow>
<Form.Item
name="first_name"
label={t("employees.fields.first_name")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("employees.fields.last_name")}
name="last_name"
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
name="employee_number"
label={t("employees.fields.employee_number")}
validateTrigger="onBlur"
hasFeedback
rules={[
{
required: true
//message: t("general.validation.required"),
},
() => ({
async validator(rule, value) {
if (value) {
const response = await client.query({
query: CHECK_EMPLOYEE_NUMBER,
variables: {
employeenumber: value
}
});
if (response.data.employees_aggregate.aggregate.count === 0) {
return Promise.resolve();
} else if (
response.data.employees_aggregate.nodes.length === 1 &&
response.data.employees_aggregate.nodes[0].id === form.getFieldValue("id")
) {
return Promise.resolve();
}
return Promise.reject(t("employees.validation.unique_employee_number"));
} else {
return Promise.resolve();
<Form
onFinish={handleFinish}
onFinishFailed={saveAndResetSubmitAction}
autoComplete={"off"}
layout="vertical"
form={form}
onValuesChange={() => {
updateDirtyState(form.isFieldsTouched());
}}
>
<FormsFieldChanged form={form} onReset={resetEmployeeFormToCurrentData} onDirtyChange={updateDirtyState} />
<LayoutFormRow
title={
<div
style={{
...INLINE_TITLE_ROW_STYLE,
justifyContent: "space-between"
}}
>
<div
style={{
...INLINE_TITLE_TEXT_STYLE,
marginRight: "auto"
}}
>
{t("bodyshop.labels.employee_options")}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 4,
flexWrap: "wrap",
marginLeft: "auto"
}}
>
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
<div
style={{
...INLINE_TITLE_SWITCH_GROUP_STYLE
}}
>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employees.labels.active")}</div>
<Form.Item noStyle valuePropName="checked" name="active">
<Switch />
</Form.Item>
</div>
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
<div
style={{
...INLINE_TITLE_SWITCH_GROUP_STYLE
}}
>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employees.fields.flat_rate")}</div>
<Form.Item noStyle valuePropName="checked" name="flat_rate">
<Switch />
</Form.Item>
</div>
</div>
</div>
}
wrapTitle
>
<Row gutter={[16, 16]} wrap>
<Col {...employeeOptionsColProps}>
<Form.Item
name="first_name"
label={t("employees.fields.first_name")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
}
})
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("employees.fields.pin")}
name="pin"
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
</LayoutFormRow>
<LayoutFormRow>
<Form.Item label={t("employees.fields.active")} valuePropName="checked" name="active">
<Switch />
</Form.Item>
<Form.Item label={t("employees.fields.flat_rate")} name="flat_rate" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
name="hire_date"
label={t("employees.fields.hire_date")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<DateTimePicker isDateOnly />
</Form.Item>
<Form.Item label={t("employees.fields.termination_date")} name="termination_date">
<DateTimePicker isDateOnly />
</Form.Item>
<Form.Item
label={t("employees.fields.user_email")}
name="user_email"
validateTrigger="onBlur"
rules={[
({ getFieldValue }) => ({
async validator(rule, value) {
const user_email = getFieldValue("user_email");
if (user_email && value) {
const response = await client.query({
query: QUERY_USERS_BY_EMAIL,
variables: {
email: user_email
}
});
if (response.data.users.length === 1) {
return Promise.resolve();
}
return Promise.reject(t("bodyshop.validation.useremailmustexist"));
} else {
return Promise.resolve();
]}
>
<Input />
</Form.Item>
</Col>
<Col {...employeeOptionsColProps}>
<Form.Item
label={t("employees.fields.last_name")}
name="last_name"
rules={[
{
required: true
//message: t("general.validation.required"),
}
}
})
]}
>
<Input />
</Form.Item>
<Form.Item label={t("employees.fields.external_id")} name="external_id">
<Input />
</Form.Item>
]}
>
<Input />
</Form.Item>
</Col>
<Col {...employeeOptionsColProps}>
<Form.Item
name="employee_number"
label={t("employees.fields.employee_number")}
validateTrigger="onBlur"
hasFeedback
rules={[
{
required: true
//message: t("general.validation.required"),
},
() => ({
async validator(rule, value) {
if (value) {
const response = await client.query({
query: CHECK_EMPLOYEE_NUMBER,
variables: {
employeenumber: value
}
});
if (response.data.employees_aggregate.aggregate.count === 0) {
return Promise.resolve();
} else if (
response.data.employees_aggregate.nodes.length === 1 &&
response.data.employees_aggregate.nodes[0].id === form.getFieldValue("id")
) {
return Promise.resolve();
}
return Promise.reject(t("employees.validation.unique_employee_number"));
} else {
return Promise.resolve();
}
}
})
]}
>
<Input />
</Form.Item>
</Col>
<Col {...employeeOptionsColProps}>
<Form.Item
label={t("employees.fields.pin")}
name="pin"
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
</Col>
<Col {...employeeOptionsColProps}>
<Form.Item
name="hire_date"
label={t("employees.fields.hire_date")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<DateTimePicker isDateOnly />
</Form.Item>
</Col>
<Col {...employeeOptionsColProps}>
<Form.Item label={t("employees.fields.termination_date")} name="termination_date">
<DateTimePicker isDateOnly />
</Form.Item>
</Col>
<Col {...employeeOptionsColProps}>
<Form.Item
label={t("employees.fields.user_email")}
name="user_email"
validateTrigger="onBlur"
rules={[
({ getFieldValue }) => ({
async validator(rule, value) {
const user_email = getFieldValue("user_email");
if (user_email && value) {
const response = await client.query({
query: QUERY_USERS_BY_EMAIL,
variables: {
email: user_email
}
});
if (response.data.users.length === 1) {
return Promise.resolve();
}
return Promise.reject(t("bodyshop.validation.useremailmustexist"));
} else {
return Promise.resolve();
}
}
})
]}
>
<FormItemEmail />
</Form.Item>
</Col>
<Col {...employeeOptionsColProps}>
<Form.Item label={t("employees.fields.external_id")} name="external_id">
<Input />
</Form.Item>
</Col>
</Row>
</LayoutFormRow>
<Form.List name={["rates"]}>
{(fields, { add, remove, move }) => {
return (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
<LayoutFormRow grow>
<Form.Item
label={t("employees.fields.cost_center")}
key={`${field.key}-cost_center`}
name={[field.name, "cost_center"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Select
options={[
{ value: "timetickets.labels.shift", label: t("timetickets.labels.shift") },
...(bodyshop.cdk_dealerid ||
bodyshop.pbs_serialnumber ||
bodyshop.rr_dealerid ||
Enhanced_Payroll.treatment === "on"
? CiecaSelect(false, true)
: bodyshop.md_responsibility_centers.costs.map((c) => ({
value: c.name,
label: c.name
})))
]}
/>
</Form.Item>
<Form.Item
label={t("employees.fields.rate")}
key={`${field.key}-rate`}
name={[field.name, "rate"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={0} precision={2} />
</Form.Item>
<DeleteFilled
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows move={move} index={index} total={fields.length} />
</LayoutFormRow>
</Form.Item>
))}
<Form.Item>
<LayoutFormRow
title={t("bodyshop.labels.employee_rates")}
actions={[
<Button
type="dashed"
key="add-rate"
type="primary"
block
onClick={() => {
add();
}}
style={{ width: "100%" }}
id="add-employee-rate-button"
>
<span id="new-employee-rate">{t("employees.actions.newrate")}</span>
<span id="new-employee-rate">{t("employees.actions.addrate")}</span>
</Button>
</Form.Item>
</div>
]}
>
<div>
{fields.length === 0 ? (
<ConfigListEmptyState actionLabel={t("employees.actions.addrate")} />
) : (
fields.map((field, index) => {
return (
<Form.Item noStyle key={field.key}>
<InlineValidatedFormRow
form={form}
errorNames={[["rates", field.name, "cost_center"]]}
noDivider
title={
<div style={INLINE_TITLE_ROW_STYLE}>
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />
<div style={INLINE_TITLE_GROUP_STYLE}>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employees.fields.cost_center")}</div>
<Form.Item
noStyle
name={[field.name, "cost_center"]}
rules={[
{
required: true
}
]}
>
<Select
size="small"
options={[
{ value: "timetickets.labels.shift", label: t("timetickets.labels.shift") },
...(bodyshop.cdk_dealerid ||
bodyshop.pbs_serialnumber ||
bodyshop.rr_dealerid ||
Enhanced_Payroll.treatment === "on"
? CiecaSelect(false, true)
: bodyshop.md_responsibility_centers.costs.map((c) => ({
value: c.name,
label: c.name
})))
]}
style={{ width: "100%" }}
styles={{
selector: INLINE_TITLE_INPUT_STYLE
}}
/>
</Form.Item>
</div>
</div>
}
wrapTitle
extra={
<Space align="center" size="small">
<Button
type="text"
danger
icon={<DeleteFilled />}
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
>
<Form.Item
label={t("employees.fields.rate")}
name={[field.name, "rate"]}
rules={[
{
required: true
}
]}
style={{ marginBottom: 0 }}
>
<InputNumber min={0} precision={2} style={{ width: "100%" }} />
</Form.Item>
</InlineValidatedFormRow>
</Form.Item>
);
})
)}
</div>
</LayoutFormRow>
);
}}
</Form.List>
</Form>
<ResponsiveTable
title={() => <ShopEmployeeAddVacation employee={data && data.employees_by_pk} />}
columns={columns}
mobileColumnKeys={["start", "length", "actions"]}
rowKey={"id"}
dataSource={data?.employees_by_pk?.employee_vacations ?? []}
/>
<LayoutFormRow
title={t("bodyshop.labels.employee_vacation")}
actions={[
<ShopEmployeeAddVacation
key="add-vacation"
employee={data && data.employees_by_pk}
buttonProps={{
type: "primary",
block: true
}}
/>
]}
>
{(data?.employees_by_pk?.employee_vacations ?? []).length === 0 ? (
<ConfigListEmptyState actionLabel={t("employees.actions.addvacation")} />
) : (
<div>
<ResponsiveTable
columns={columns}
mobileColumnKeys={["start", "length", "actions"]}
rowKey={"id"}
dataSource={data?.employees_by_pk?.employee_vacations ?? []}
pagination={false}
/>
</div>
)}
</LayoutFormRow>
</Card>
);
}

View File

@@ -0,0 +1,345 @@
import { useApolloClient } from "@apollo/client/react";
import { Form } from "antd";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useEffect } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DELETE_VACATION, INSERT_EMPLOYEES, QUERY_EMPLOYEE_BY_ID, UPDATE_EMPLOYEE } from "../../graphql/employees.queries";
import { ShopEmployeesFormComponent } from "./shop-employees-form.component.jsx";
const insertEmployeesMock = vi.fn();
const updateEmployeeMock = vi.fn();
const deleteVacationMock = vi.fn();
const useQueryMock = vi.fn();
const useMutationMock = vi.fn();
const navigateMock = vi.fn();
const notification = {
error: vi.fn(),
success: vi.fn()
};
vi.mock("@apollo/client/react", async () => {
const actual = await vi.importActual("@apollo/client/react");
return {
...actual,
useApolloClient: vi.fn(),
useQuery: (...args) => useQueryMock(...args),
useMutation: (...args) => useMutationMock(...args)
};
});
vi.mock("@splitsoftware/splitio-react", () => ({
useTreatmentsWithConfig: () => ({
treatments: {
Enhanced_Payroll: {
treatment: "off"
}
}
})
}));
vi.mock("react-router-dom", () => ({
useLocation: () => ({
search: "?employeeId=new"
}),
useNavigate: () => navigateMock
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key, values = {}) => {
const translations = {
"bodyshop.labels.employee_options": "Employee Options",
"bodyshop.labels.employee_rates": "Employee Rates",
"bodyshop.labels.employee_vacation": "Employee Vacation",
"bodyshop.labels.employees": "Employees",
"employees.actions.addrate": "Add Rate",
"employees.actions.addvacation": "Add Vacation",
"employees.actions.new": "New Employee",
"employees.actions.save_employee": "Save Employee",
"employees.fields.active": "Active",
"employees.fields.employee_number": "Employee Number",
"employees.fields.external_id": "External Id",
"employees.fields.first_name": "First Name",
"employees.fields.flat_rate": "Flat Rate",
"employees.fields.hire_date": "Hire Date",
"employees.fields.last_name": "Last Name",
"employees.fields.pin": "PIN",
"employees.fields.rate": "Rate",
"employees.fields.termination_date": "Termination Date",
"employees.fields.user_email": "User Email",
"employees.labels.active": "Active",
"employees.successes.save": "Saved",
"general.actions.saveandnew": "Save and New",
"general.labels.actions": "Actions"
};
if (key === "employees.errors.save") {
return `Save failed: ${values.message ?? ""}`;
}
if (key === "employees.validation.unique_employee_number") {
return "Employee number must be unique";
}
if (key === "bodyshop.validation.useremailmustexist") {
return "User email must exist";
}
return translations[key] || key;
}
})
}));
vi.mock("../../contexts/Notifications/notificationContext.jsx", () => ({
useNotification: () => notification
}));
vi.mock("../../firebase/firebase.utils", () => ({
logImEXEvent: vi.fn()
}));
vi.mock("../alert/alert.component", () => ({
default: ({ title }) => <div>{title}</div>
}));
vi.mock("../form-fields-changed-alert/form-fields-changed-alert.component.jsx", () => ({
default: () => null
}));
vi.mock("../form-date-time-picker/form-date-time-picker.component.jsx", () => ({
default: ({ id, value, onChange }) => (
<input id={id} type="text" value={value ?? ""} onChange={(event) => onChange?.(event.target.value)} />
)
}));
vi.mock("../form-items-formatted/email-form-item.component.jsx", () => ({
default: ({ id, value, onChange }) => (
<input id={id} type="email" value={value ?? ""} onChange={(event) => onChange?.(event.target.value)} />
)
}));
vi.mock("../layout-form-row/layout-form-row.component", () => ({
default: ({ title, extra, actions, children }) => (
<div>
{title}
{extra}
{children}
{actions}
</div>
)
}));
vi.mock("../layout-form-row/inline-validated-form-row.component.jsx", () => ({
default: ({ title, extra, children }) => (
<div>
{title}
{extra}
{children}
</div>
)
}));
vi.mock("../layout-form-row/config-list-empty-state.component.jsx", () => ({
default: ({ actionLabel }) => <div>{actionLabel}</div>
}));
vi.mock("../form-list-move-arrows/form-list-move-arrows.component", () => ({
default: () => null
}));
vi.mock("../responsive-table/responsive-table.component", () => ({
default: () => null
}));
vi.mock("./shop-employees-add-vacation.component", () => ({
default: () => null
}));
vi.mock("../../utils/Ciecaselect", () => ({
default: () => []
}));
const bodyshop = {
id: "shop-1",
imexshopid: "split-shop-1",
md_responsibility_centers: {
costs: []
}
};
describe("ShopEmployeesFormComponent", () => {
let formInstance;
beforeEach(() => {
vi.clearAllMocks();
useQueryMock.mockImplementation((query) => {
if (query === QUERY_EMPLOYEE_BY_ID) {
return {
error: null,
data: null,
refetch: vi.fn(),
loading: false
};
}
return {
error: null,
data: null,
loading: false
};
});
useMutationMock.mockImplementation((mutation) => {
if (mutation === INSERT_EMPLOYEES) return [insertEmployeesMock];
if (mutation === UPDATE_EMPLOYEE) return [updateEmployeeMock];
if (mutation === DELETE_VACATION) return [deleteVacationMock];
return [vi.fn()];
});
useApolloClient.mockReturnValue({
query: vi.fn().mockResolvedValue({
data: {
employees_aggregate: {
aggregate: {
count: 0
},
nodes: []
},
users: []
}
})
});
insertEmployeesMock.mockResolvedValue({
data: {
insert_employees: {
returning: [
{
id: "employee-123",
first_name: "Jamie",
last_name: "Rivera",
employee_number: "42",
active: true,
termination_date: null,
hire_date: "2026-04-20",
flat_rate: false,
rates: [],
pin: "1234",
user_email: null
}
]
}
}
});
function TestHarness({ onFormReady }) {
const [form] = Form.useForm();
useEffect(() => {
onFormReady(form);
}, [form, onFormReady]);
return <ShopEmployeesFormComponent bodyshop={bodyshop} form={form} />;
}
render(
<TestHarness
onFormReady={(form) => {
formInstance = form;
}}
/>
);
});
it("marks a new employee form clean after save", async () => {
fireEvent.change(screen.getByRole("textbox", { name: "First Name" }), {
target: { value: "Jamie" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Last Name" }), {
target: { value: "Rivera" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Employee Number" }), {
target: { value: "42" }
});
fireEvent.change(screen.getByRole("textbox", { name: "PIN" }), {
target: { value: "1234" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Hire Date" }), {
target: { value: "2026-04-20" }
});
const saveButton = screen.getByRole("button", { name: "Save Employee" });
await waitFor(() => {
expect(saveButton.disabled).toBe(false);
});
fireEvent.click(saveButton);
await waitFor(() => {
expect(insertEmployeesMock).toHaveBeenCalledWith({
variables: {
employees: [
expect.objectContaining({
first_name: "Jamie",
last_name: "Rivera",
employee_number: "42",
pin: "1234",
hire_date: "2026-04-20",
shopid: "shop-1"
})
]
},
refetchQueries: ["QUERY_EMPLOYEES"]
});
});
await waitFor(() => {
expect(formInstance.isFieldsTouched()).toBe(false);
});
expect(notification.success).toHaveBeenCalledWith({
title: "Saved"
});
expect(navigateMock).toHaveBeenCalledWith({
search: "employeeId=employee-123"
});
});
it("saves a new employee and opens a fresh employee form when save and new is clicked", async () => {
fireEvent.change(screen.getByRole("textbox", { name: "First Name" }), {
target: { value: "Jamie" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Last Name" }), {
target: { value: "Rivera" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Employee Number" }), {
target: { value: "42" }
});
fireEvent.change(screen.getByRole("textbox", { name: "PIN" }), {
target: { value: "1234" }
});
fireEvent.change(screen.getByRole("textbox", { name: "Hire Date" }), {
target: { value: "2026-04-20" }
});
fireEvent.click(screen.getByRole("button", { name: "Save and New" }));
await waitFor(() => {
expect(insertEmployeesMock).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(formInstance.isFieldsTouched()).toBe(false);
});
expect(navigateMock).toHaveBeenCalledWith({
search: "employeeId=new"
});
expect(notification.success).toHaveBeenCalledWith({
title: "Saved"
});
});
});

View File

@@ -4,9 +4,16 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { alphaSort } from "../../utils/sorters";
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import ResponsiveTable from "../responsive-table/responsive-table.component";
export default function ShopEmployeesListComponent({ loading, employees }) {
export default function ShopEmployeesListComponent({
loading,
employees,
onRequestEmployeeChange,
selectedEmployeeId
}) {
const { t } = useTranslation();
const history = useNavigate();
const search = queryString.parse(useLocation().search);
@@ -16,13 +23,33 @@ export default function ShopEmployeesListComponent({ loading, employees }) {
filteredInfo: { text: "" }
});
const navigateToEmployee = (employeeId) => {
if (onRequestEmployeeChange) {
onRequestEmployeeChange(employeeId);
return;
}
history({
search: queryString.stringify({
...search,
employeeId
})
});
};
const clearEmployeeSelection = () => {
const { employeeId, ...nextSearch } = search;
void employeeId;
history({
search: queryString.stringify(nextSearch)
});
};
const handleOnRowClick = (record) => {
if (record) {
search.employeeId = record.id;
history({ search: queryString.stringify(search) });
navigateToEmployee(record.id);
} else {
delete search.employeeId;
history({ search: queryString.stringify(search) });
clearEmployeeSelection();
}
};
const handleTableChange = (pagination, filters, sorter) => {
@@ -30,7 +57,7 @@ export default function ShopEmployeesListComponent({ loading, employees }) {
};
const columns = [
{
title: t("employees.fields.employee_number"),
title: t("employees.labels.employee_number_short"),
dataIndex: "employee_number",
key: "employee_number",
sorter: (a, b) => alphaSort(a.employee_number, b.employee_number),
@@ -89,44 +116,39 @@ export default function ShopEmployeesListComponent({ loading, employees }) {
}
];
return (
<div>
<ResponsiveTable
title={() => {
return (
<Button
type="primary"
onClick={() => {
search.employeeId = "new";
history({ search: queryString.stringify(search) });
}}
>
{t("employees.actions.new")}
</Button>
);
}}
loading={loading}
pagination={{ placement: "top" }}
columns={columns}
mobileColumnKeys={["employee_number", "employee_name", "active"]}
rowKey="id"
dataSource={employees}
rowSelection={{
onSelect: (props) => {
search.employeeId = props.id;
history({ search: queryString.stringify(search) });
},
type: "radio",
selectedRowKeys: [search.employeeId]
}}
onChange={handleTableChange}
onRow={(record) => {
return {
onClick: () => {
handleOnRowClick(record);
}
};
}}
/>
</div>
<LayoutFormRow
title={t("bodyshop.labels.employees")}
actions={[
<Button key="new-employee" type="primary" block onClick={() => navigateToEmployee("new")}>
{t("employees.actions.new")}
</Button>
]}
>
{employees.length === 0 ? (
<ConfigListEmptyState actionLabel={t("employees.actions.new")} />
) : (
<ResponsiveTable
loading={loading}
pagination={{ placement: "top" }}
columns={columns}
mobileColumnKeys={["employee_number", "employee_name", "active"]}
rowKey="id"
dataSource={employees}
rowSelection={{
onSelect: (props) => navigateToEmployee(props.id),
type: "radio",
selectedRowKeys: [selectedEmployeeId || search.employeeId]
}}
onChange={handleTableChange}
onRow={(record) => {
return {
onClick: () => {
handleOnRowClick(record);
}
};
}}
/>
)}
</LayoutFormRow>
);
}

View File

@@ -1,29 +1,101 @@
import { Drawer, Form, Grid } from "antd";
import { useQuery } from "@apollo/client/react";
import queryString from "query-string";
import { connect } from "react-redux";
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { QUERY_EMPLOYEES } from "../../graphql/employees.queries";
import useConfirmDirtyFormNavigation from "../../hooks/useConfirmDirtyFormNavigation.jsx";
import AlertComponent from "../alert/alert.component";
import ShopEmployeesFormComponent from "./shop-employees-form.component";
import ShopEmployeesListComponent from "./shop-employees-list.component";
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
import "./shop-employees.styles.scss";
const mapStateToProps = createStructuredSelector({});
function ShopEmployeesContainer() {
const [form] = Form.useForm();
const [isEmployeeFormDirty, setIsEmployeeFormDirty] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const search = queryString.parse(location.search);
const { loading, error, data } = useQuery(QUERY_EMPLOYEES, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
const screens = Grid.useBreakpoint();
const hasSelectedEmployee = Boolean(search.employeeId);
const bpoints = {
xs: "100%",
sm: "100%",
md: "92%",
lg: "80%",
xl: "80%",
xxl: "80%"
};
let drawerPercentage = "100%";
if (screens.xxl) drawerPercentage = bpoints.xxl;
else if (screens.xl) drawerPercentage = bpoints.xl;
else if (screens.lg) drawerPercentage = bpoints.lg;
else if (screens.md) drawerPercentage = bpoints.md;
else if (screens.sm) drawerPercentage = bpoints.sm;
else if (screens.xs) drawerPercentage = bpoints.xs;
const hasDirtyEmployeeForm = Boolean(search.employeeId) && (isEmployeeFormDirty || form.isFieldsTouched());
const confirmCloseDirtyEmployee = useConfirmDirtyFormNavigation(hasDirtyEmployeeForm);
const navigateToEmployee = (employeeId) => {
if (employeeId === search.employeeId) return;
if (!confirmCloseDirtyEmployee()) return;
const nextSearch = { ...search, employeeId };
setIsEmployeeFormDirty(false);
navigate({
search: queryString.stringify(nextSearch)
});
};
const handleDrawerClose = () => {
if (!confirmCloseDirtyEmployee()) return;
const nextSearch = { ...search };
delete nextSearch.employeeId;
setIsEmployeeFormDirty(false);
navigate({
search: queryString.stringify(nextSearch)
});
};
if (error) return <AlertComponent title={error.message} type="error" />;
return (
<div>
<RbacWrapper action="employees:page">
<ShopEmployeesListComponent employees={data ? data.employees : []} loading={loading} />
<ShopEmployeesFormComponent />
</RbacWrapper>
</div>
<RbacWrapper action="employees:page">
<div className="shop-employees-layout">
<div className="shop-employees-layout__list">
<ShopEmployeesListComponent
employees={data ? data.employees : []}
loading={loading}
onRequestEmployeeChange={navigateToEmployee}
selectedEmployeeId={search.employeeId}
/>
</div>
</div>
<Drawer
open={hasSelectedEmployee}
destroyOnHidden
placement="right"
size={drawerPercentage}
onClose={handleDrawerClose}
>
{hasSelectedEmployee ? (
<ShopEmployeesFormComponent form={form} onDirtyChange={setIsEmployeeFormDirty} isDirty={isEmployeeFormDirty} />
) : null}
</Drawer>
</RbacWrapper>
);
}

View File

@@ -0,0 +1,7 @@
.shop-employees-layout {
min-width: 0;
}
.shop-employees-layout__list {
min-width: 0;
}

View File

@@ -0,0 +1,304 @@
/**
* Default translucent card color used for tinting card surfaces when no specific color is provided.
* @type {{r: number, g: number, b: number, a: number}}
*/
export const DEFAULT_TRANSLUCENT_CARD_COLOR = {
r: 22,
g: 119,
b: 255,
a: 0.5
};
/**
* Rounds a color channel value to two decimal places.
* @param value
* @returns {number}
*/
const roundColorChannel = (value) => Math.round(value * 100) / 100;
/**
* Rounds a tint percentage value to two decimal places.
* @param value
* @returns {number}
*/
const roundTintPercentage = (value) => Math.round(value * 100) / 100;
/**
* Clamps an alpha value to the range [0, 1] and rounds it to two decimal places.
* @param value
* @returns {number}
*/
const clampAlpha = (value) => {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) return 1;
if (numericValue <= 0) return 0;
if (numericValue >= 1) return 1;
return numericValue;
};
/**
* Converts an RGB color object to a hexadecimal color string.
* @param param0
* @param param0.r
* @param param0.g
* @param param0.b
* @returns {`#${string}`}
*/
const rgbToHex = ({ r, g, b }) =>
`#${[r, g, b].map((channel) => Math.round(channel).toString(16).padStart(2, "0")).join("")}`;
/**
* Converts an RGB color object to an HSL color object.
* @param param0
* @param param0.r
* @param param0.g
* @param param0.b
* @param param0.a
* @returns {{h: number, s: number, l: number, a: number}|{h: number, s: number, l: number, a: number}}
*/
const rgbToHsl = ({ r, g, b, a = 1 }) => {
const red = r / 255;
const green = g / 255;
const blue = b / 255;
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const delta = max - min;
const lightness = (max + min) / 2;
if (delta === 0) {
return { h: 0, s: 0, l: roundColorChannel(lightness), a };
}
const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
let hue;
switch (max) {
case red:
hue = (green - blue) / delta + (green < blue ? 6 : 0);
break;
case green:
hue = (blue - red) / delta + 2;
break;
default:
hue = (red - green) / delta + 4;
break;
}
return {
h: roundColorChannel(hue * 60),
s: roundColorChannel(saturation),
l: roundColorChannel(lightness),
a
};
};
/**
* Converts an RGB color object to an HSV color object.
* @param param0
* @param param0.r
* @param param0.g
* @param param0.b
* @param param0.a
* @returns {{h: number, s: number, v: number, a: number}}
*/
const rgbToHsv = ({ r, g, b, a = 1 }) => {
const red = r / 255;
const green = g / 255;
const blue = b / 255;
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const delta = max - min;
const saturation = max === 0 ? 0 : delta / max;
let hue = 0;
if (delta !== 0) {
switch (max) {
case red:
hue = (green - blue) / delta + (green < blue ? 6 : 0);
break;
case green:
hue = (blue - red) / delta + 2;
break;
default:
hue = (red - green) / delta + 4;
break;
}
}
return {
h: roundColorChannel(hue * 60),
s: roundColorChannel(saturation),
v: roundColorChannel(max),
a
};
};
/**
* Builds a comprehensive color value object for a color picker component based on an input RGB color object.
* @param rgb
* @returns {{hex: `#${string}`, rgb: *, hsl: {h: number, s: number, l: number, a: number}, hsv: {h: number, s: number, v: number, a: number}, oldHue: number, source: string}}
*/
const buildPickerColorValue = (rgb) => {
const hsl = rgbToHsl(rgb);
return {
hex: rgbToHex(rgb),
rgb: { ...rgb },
hsl,
hsv: rgbToHsv(rgb),
oldHue: hsl.h,
source: "rgb"
};
};
/**
* Default color value object for the color picker component, derived from the default translucent card color.
* @type {{hex: `#${string}`, rgb: *, hsl: {h: number, s: number, l: number, a: number}, hsv: {h: number, s: number, v: number, a: number}, oldHue: number, source: string}}
*/
export const DEFAULT_TRANSLUCENT_PICKER_COLOR = buildPickerColorValue(DEFAULT_TRANSLUCENT_CARD_COLOR);
/**
* Parses a color string that may be a JSON representation of a color object. If the string is valid JSON and represents
* a color, it returns the parsed object; otherwise, it returns the original string.
* @param color
* @returns {*|string}
*/
const parseJsonColorString = (color) => {
if (typeof color !== "string") return color;
const trimmedColor = color.trim();
if (!trimmedColor.startsWith("{") && !trimmedColor.startsWith("[")) return color;
try {
return JSON.parse(trimmedColor);
} catch {
return color;
}
};
/**
* Parses a hexadecimal color string (e.g., "#RRGGBB" or "#RRGGBBAA") and returns an object containing the corresponding
* RGB color value and alpha transparency. Supports both 3/4-digit and 6/8-digit hex formats.
* @param color
* @returns {{colorCssValue: string, alpha: number}|null}
*/
const parseHexColor = (color) => {
if (typeof color !== "string") return null;
const normalizedHex = color.trim().replace(/^#/, "");
if (![3, 4, 6, 8].includes(normalizedHex.length) || /[^0-9a-f]/i.test(normalizedHex)) {
return null;
}
const expandedHex =
normalizedHex.length <= 4
? normalizedHex
.split("")
.map((character) => `${character}${character}`)
.join("")
: normalizedHex;
const hasAlpha = expandedHex.length === 8;
const red = Number.parseInt(expandedHex.slice(0, 2), 16);
const green = Number.parseInt(expandedHex.slice(2, 4), 16);
const blue = Number.parseInt(expandedHex.slice(4, 6), 16);
const alpha = hasAlpha ? Number.parseInt(expandedHex.slice(6, 8), 16) / 255 : 1;
return {
colorCssValue: `rgb(${red}, ${green}, ${blue})`,
alpha: clampAlpha(alpha)
};
};
/**
* Parses an RGB or RGBA color string (e.g., "rgb(255, 0, 0)" or "rgba(255, 0, 0, 0.5)") and returns an object
* containing the corresponding RGB color value and alpha transparency. Supports both integer and percentage formats for
* color channels and alpha.
* @param color
* @returns {{colorCssValue: string, alpha: number}|null}
*/
const parseRgbColor = (color) => {
if (typeof color !== "string") return null;
const rgbMatch = color.trim().match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i);
if (!rgbMatch) return null;
const [, red, green, blue, alpha = 1] = rgbMatch;
return {
colorCssValue: `rgb(${red}, ${green}, ${blue})`,
alpha: clampAlpha(alpha)
};
};
/**
* Normalizes a color input into a consistent descriptor object containing a CSS color value and an alpha transparency
* level.
* @param color
* @returns {{colorCssValue: string, alpha: number}|{colorCssValue: string, alpha: number}|*|{colorCssValue: string, alpha: number}|null}
*/
const getNormalizedColorDescriptor = (color) => {
if (!color) return null;
const normalizedColor = parseJsonColorString(color);
if (typeof normalizedColor === "string") {
return (
parseHexColor(normalizedColor) ||
parseRgbColor(normalizedColor) || {
colorCssValue: normalizedColor,
alpha: 1
}
);
}
if (typeof normalizedColor === "object" && normalizedColor.rgb) {
return getNormalizedColorDescriptor(normalizedColor.rgb);
}
if (typeof normalizedColor === "object" && typeof normalizedColor.hex === "string") {
return getNormalizedColorDescriptor(normalizedColor.hex);
}
if (
typeof normalizedColor === "object" &&
normalizedColor.r !== undefined &&
normalizedColor.g !== undefined &&
normalizedColor.b !== undefined
) {
return {
colorCssValue: `rgb(${normalizedColor.r}, ${normalizedColor.g}, ${normalizedColor.b})`,
alpha: clampAlpha(normalizedColor.a)
};
}
return null;
};
/**
* Generates CSS styles for tinting card surfaces based on a provided color input. The function normalizes the input
* color,
* @param color
* @returns {{surfaceBg: string, surfaceHeaderBg: string, surfaceBorderColor: string}|{}}
*/
export const getTintedCardSurfaceStyles = (color) => {
const normalizedColor = getNormalizedColorDescriptor(color);
if (!normalizedColor?.colorCssValue) return {};
const tintStrength = clampAlpha(normalizedColor.alpha);
if (tintStrength === 0) return {};
const backgroundTint = roundTintPercentage(10 * tintStrength);
const headerTint = roundTintPercentage(18 * tintStrength);
const borderTint = roundTintPercentage(30 * tintStrength);
return {
surfaceBg: `color-mix(in srgb, ${normalizedColor.colorCssValue} ${backgroundTint}%, var(--imex-form-surface))`,
surfaceHeaderBg: `color-mix(in srgb, ${normalizedColor.colorCssValue} ${headerTint}%, var(--imex-form-surface-head))`,
surfaceBorderColor: `color-mix(in srgb, ${normalizedColor.colorCssValue} ${borderTint}%, var(--imex-form-surface-border))`
};
};

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { getTintedCardSurfaceStyles } from "./shop-info.color.utils";
describe("shop info color utilities", () => {
it("scales card tint intensity with alpha for plain rgba values", () => {
expect(
getTintedCardSurfaceStyles({
r: 22,
g: 119,
b: 255,
a: 0.5
})
).toEqual({
surfaceBg: "color-mix(in srgb, rgb(22, 119, 255) 5%, var(--imex-form-surface))",
surfaceHeaderBg: "color-mix(in srgb, rgb(22, 119, 255) 9%, var(--imex-form-surface-head))",
surfaceBorderColor: "color-mix(in srgb, rgb(22, 119, 255) 15%, var(--imex-form-surface-border))"
});
});
it("returns no tint when the selected color alpha is zero", () => {
expect(
getTintedCardSurfaceStyles({
hex: "#1677ff",
rgb: {
r: 22,
g: 119,
b: 255,
a: 0
}
})
).toEqual({});
});
it("supports legacy JSON-stringified picker values", () => {
expect(
getTintedCardSurfaceStyles(
JSON.stringify({
rgb: {
r: 255,
g: 0,
b: 0,
a: 0.25
}
})
)
).toEqual({
surfaceBg: "color-mix(in srgb, rgb(255, 0, 0) 2.5%, var(--imex-form-surface))",
surfaceHeaderBg: "color-mix(in srgb, rgb(255, 0, 0) 4.5%, var(--imex-form-surface-head))",
surfaceBorderColor: "color-mix(in srgb, rgb(255, 0, 0) 7.5%, var(--imex-form-surface-border))"
});
});
});

View File

@@ -1,6 +1,7 @@
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Card, Tabs } from "antd";
import queryString from "query-string";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
@@ -21,6 +22,7 @@ import ShopInfoResponsibilityCenterComponent from "./shop-info.responsibilitycen
import ShopInfoRoGuard from "./shop-info.roguard.component";
import ShopInfoROStatusComponent from "./shop-info.rostatus.component";
import ShopInfoSchedulingComponent from "./shop-info.scheduling.component";
import ShopInfoSectionNavigator from "./shop-info.section-navigator.component.jsx";
import ShopInfoSpeedPrint from "./shop-info.speedprint.component";
import ShopInfoTaskPresets from "./shop-info.task-presets.component";
import ShopInfoIntellipay from "./shop-intellipay-config.component";
@@ -33,7 +35,7 @@ const mapDispatchToProps = () => ({
});
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoComponent);
export function ShopInfoComponent({ bodyshop, form, saveLoading }) {
export function ShopInfoComponent({ bodyshop, form, saveLoading, isDirty }) {
const {
treatments: { CriticalPartsScanning, Enhanced_Payroll }
} = useTreatmentsWithConfig({
@@ -47,6 +49,7 @@ export function ShopInfoComponent({ bodyshop, form, saveLoading }) {
const history = useNavigate();
const location = useLocation();
const search = queryString.parse(location.search);
const tabsRef = useRef(null);
const tabItems = [
{
@@ -154,23 +157,35 @@ export function ShopInfoComponent({ bodyshop, form, saveLoading }) {
]
: [])
];
const activeTabKey = search.subtab || tabItems[0]?.key;
return (
<Card
title={<ShopInfoSectionNavigator tabsRef={tabsRef} activeTabKey={activeTabKey} />}
extra={
<Button type="primary" loading={saveLoading} onClick={() => form.submit()} id="shop-info-save-button">
{t("general.actions.save")}
<Button
type="primary"
disabled={!isDirty || saveLoading}
loading={saveLoading}
onClick={() => form.submit()}
id="shop-info-save-button"
style={{ minWidth: 210 }}
>
{t("bodyshop.actions.save_shop_information")}
</Button>
}
>
<Tabs
defaultActiveKey={search.subtab}
onChange={(key) =>
history({
search: `?tab=${search.tab}&subtab=${key}`
})
}
items={tabItems}
/>
<div ref={tabsRef}>
<Tabs
activeKey={activeTabKey}
onChange={(key) =>
history({
search: `?tab=${search.tab}&subtab=${key}`
})
}
items={tabItems}
/>
</div>
</Card>
);
}

View File

@@ -1,4 +1,4 @@
import { Card, Typography } from "antd";
import { Card } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -15,9 +15,8 @@ function ShopInfoConsentComponent({ bodyshop }) {
const { t } = useTranslation();
return (
<Card>
<Typography.Title level={4}>{t("settings.title")}</Typography.Title>
{<PhoneNumberConsentList bodyshop={bodyshop} />}
<Card title={t("settings.title")}>
<PhoneNumberConsentList bodyshop={bodyshop} />
</Card>
);
}

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery } from "@apollo/client/react";
import { Form } from "antd";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { logImEXEvent } from "../../firebase/firebase.utils";
@@ -15,6 +15,7 @@ import { FEATURE_CONFIGS, useFormDataPreservation } from "./useFormDataPreservat
export default function ShopInfoContainer() {
const [form] = Form.useForm();
const { t } = useTranslation();
const [isShopInfoDirty, setIsShopInfoDirty] = useState(false);
const [saveLoading, setSaveLoading] = useState(false);
const [updateBodyshop] = useMutation(UPDATE_SHOP);
const { loading, error, data, refetch } = useQuery(QUERY_BODYSHOP, {
@@ -33,7 +34,10 @@ export default function ShopInfoContainer() {
return acc;
}, {});
const combinedFeatureConfig = combineFeatureConfigs(FEATURE_CONFIGS.general, FEATURE_CONFIGS.responsibilitycenters);
const combinedFeatureConfig = useMemo(
() => combineFeatureConfigs(FEATURE_CONFIGS.general, FEATURE_CONFIGS.responsibilitycenters),
[]
);
// Use form data preservation for all shop-info features
const { createSubmissionHandler, preserveHiddenFormData } = useFormDataPreservation(
@@ -51,7 +55,10 @@ export default function ShopInfoContainer() {
})
.then(() => {
notification.success({ title: t("bodyshop.successes.save") });
refetch().then(() => form.resetFields());
refetch().then(() => {
form.resetFields();
setIsShopInfoDirty(false);
});
})
.catch((error) => {
notification.error({
@@ -66,6 +73,7 @@ export default function ShopInfoContainer() {
form.resetFields();
// After reset, re-apply hidden field preservation so values aren't wiped
preserveHiddenFormData();
setIsShopInfoDirty(false);
}, [data, form, preserveHiddenFormData]);
if (error) return <AlertComponent title={error.message} type="error" />;
@@ -76,6 +84,9 @@ export default function ShopInfoContainer() {
layout="vertical"
autoComplete="new-password"
onFinish={handleFinish}
onValuesChange={() => {
setIsShopInfoDirty(form.isFieldsTouched());
}}
initialValues={
data
? data?.bodyshops?.[0]?.accountingconfig?.ClosingPeriod
@@ -99,8 +110,8 @@ export default function ShopInfoContainer() {
: null
}
>
<FormsFieldChanged form={form} />
<ShopInfoComponent form={form} saveLoading={saveLoading} />
<FormsFieldChanged form={form} onDirtyChange={setIsShopInfoDirty} />
<ShopInfoComponent form={form} saveLoading={saveLoading} isDirty={isShopInfoDirty} />
</Form>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,19 @@ import styled from "styled-components";
import { TemplateList } from "../../utils/TemplateConstants";
import ConfigFormTypes from "../config-form-components/config-form-types";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import {
INLINE_TITLE_GROUP_STYLE,
INLINE_TITLE_HANDLE_STYLE,
INLINE_TITLE_INPUT_STYLE,
INLINE_TITLE_LABEL_STYLE,
INLINE_TITLE_ROW_STYLE,
INLINE_TITLE_SEPARATOR_STYLE,
INLINE_TITLE_SWITCH_GROUP_STYLE,
InlineTitleListIcon
} from "../layout-form-row/inline-form-row-title.utils.js";
const SelectorDiv = styled.div`
.ant-form-item .ant-select {
@@ -19,306 +31,386 @@ export default function ShopInfoIntakeChecklistComponent({ form }) {
const TemplateListGenerated = TemplateList();
return (
<div>
<LayoutFormRow header={t("bodyshop.labels.intakechecklist")} id="intakechecklist">
<Form.List name={["intakechecklist", "form"]}>
{(fields, { add, remove, move }) => {
return (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<LayoutFormRow noDivider>
<Form.Item
label={t("jobs.fields.intake.name")}
key={`${index}name`}
name={[field.name, "name"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("jobs.fields.intake.type")}
key={`${index}type`}
name={[field.name, "type"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Select options={Object.keys(ConfigFormTypes).map((i) => ({ value: i, label: i }))} />
</Form.Item>
<Form.Item
label={t("jobs.fields.intake.label")}
key={`${index}label`}
name={[field.name, "label"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item shouldUpdate>
{() => {
if (form.getFieldValue(["intakechecklist", "form", index, "type"]) !== "slider") return null;
return (
<>
<Form.Item
label={t("jobs.fields.intake.min")}
key={`${index}min`}
name={[field.name, "min"]}
dependencies={[[field.name, "type"]]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
<Form.Item
label={t("jobs.fields.intake.max")}
key={`${index}max`}
name={[field.name, "max"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
</>
);
}}
</Form.Item>
<Form.Item
label={t("jobs.fields.intake.required")}
key={`${index}required`}
name={[field.name, "required"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Space wrap>
<DeleteFilled
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows move={move} index={index} total={fields.length} />
</Space>
</LayoutFormRow>
</Form.Item>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => {
add();
}}
style={{ width: "100%" }}
>
{t("general.actions.add")}
</Button>
</Form.Item>
</div>
);
}}
</Form.List>
</LayoutFormRow>
<SelectorDiv>
<Form.Item
name={["intakechecklist", "templates"]}
label={t("bodyshop.fields.intake.templates")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select
mode="multiple"
options={Object.keys(TemplateListGenerated).map((i) => ({
value: TemplateListGenerated[i].key,
label: TemplateListGenerated[i].title
}))}
/>
</Form.Item>
<Form.Item
name={["intakechecklist", "next_contact_hours"]}
label={t("bodyshop.fields.intake.next_contact_hours")}
>
<InputNumber min={0} precision={0} />
</Form.Item>
<LayoutFormRow header={t("bodyshop.labels.intake_delivery")} id="intake-delivery">
<Form.Item
col={{ xs: 24, sm: 24, md: 24, lg: 24, xl: 24, xxl: 24 }}
name={["intakechecklist", "templates"]}
label={t("bodyshop.fields.intake.templates")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select
mode="multiple"
options={Object.keys(TemplateListGenerated).map((i) => ({
value: TemplateListGenerated[i].key,
label: TemplateListGenerated[i].title
}))}
/>
</Form.Item>
<Form.Item
col={{ xs: 24, sm: 24, md: 24, lg: 24, xl: 24, xxl: 24 }}
name={["deliverchecklist", "templates"]}
label={t("bodyshop.fields.deliver.templates")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select
mode="multiple"
options={Object.keys(TemplateListGenerated).map((i) => ({
value: TemplateListGenerated[i].key,
label: TemplateListGenerated[i].title
}))}
/>
</Form.Item>
<Form.Item
col={{ xs: 24, sm: 10, md: 8, lg: 8, xl: 8, xxl: 8 }}
name={["intakechecklist", "next_contact_hours"]}
label={t("bodyshop.fields.intake.next_contact_hours")}
>
<InputNumber min={0} precision={0} suffix="hrs" />
</Form.Item>
<Form.Item
col={{ xs: 24, sm: 14, md: 16, lg: 16, xl: 16, xxl: 16 }}
name={["deliverchecklist", "actual_delivery"]}
label={t("bodyshop.fields.deliver.require_actual_delivery_date")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Switch />
</Form.Item>
</LayoutFormRow>
</SelectorDiv>
<LayoutFormRow header={t("bodyshop.labels.deliverchecklist")} id="deliverchecklist">
<Form.List name={["deliverchecklist", "form"]}>
{(fields, { add, remove, move }) => {
return (
<Form.List name={["intakechecklist", "form"]}>
{(fields, { add, remove, move }) => {
return (
<LayoutFormRow
header={t("bodyshop.labels.intakechecklist")}
id="intakechecklist"
actions={[
<Button
key="add-intake-checklist-item"
type="primary"
block
onClick={() => {
add();
}}
>
{t("bodyshop.actions.add_intake_checklist_item")}
</Button>
]}
>
<div>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<LayoutFormRow noDivider>
<Form.Item
label={t("jobs.fields.intake.name")}
key={`${index}named`}
name={[field.name, "name"]}
rules={[
{
required: true
//message: t("general.validation.required"),
{fields.length === 0 ? (
<ConfigListEmptyState actionLabel={t("bodyshop.actions.add_intake_checklist_item")} />
) : (
fields.map((field, index) => {
return (
<Form.Item noStyle key={field.key}>
<InlineValidatedFormRow
form={form}
errorNames={[["intakechecklist", "form", field.name, "name"]]}
noDivider
title={
<div style={INLINE_TITLE_ROW_STYLE}>
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />
<div style={INLINE_TITLE_GROUP_STYLE}>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("jobs.fields.intake.name")}</div>
<Form.Item
noStyle
name={[field.name, "name"]}
rules={[
{
required: true
}
]}
>
<Input
size="small"
placeholder={t("jobs.fields.intake.name")}
style={{
...INLINE_TITLE_INPUT_STYLE,
width: "100%"
}}
/>
</Form.Item>
</div>
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
<div style={INLINE_TITLE_SWITCH_GROUP_STYLE}>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("jobs.fields.intake.required")}</div>
<Form.Item noStyle name={[field.name, "required"]} valuePropName="checked">
<Switch />
</Form.Item>
</div>
</div>
}
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("jobs.fields.intake.type")}
key={`${index}typed`}
name={[field.name, "type"]}
rules={[
{
required: true
//message: t("general.validation.required"),
wrapTitle
extra={
<Space align="center" size="small">
<Button
type="text"
danger
icon={<DeleteFilled />}
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
]}
>
<Select options={Object.keys(ConfigFormTypes).map((i) => ({ value: i, label: i }))} />
</Form.Item>
>
<Form.Item
label={t("jobs.fields.intake.type")}
key={`${index}type`}
name={[field.name, "type"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Select options={Object.keys(ConfigFormTypes).map((i) => ({ value: i, label: i }))} />
</Form.Item>
<Form.Item
label={t("jobs.fields.intake.label")}
key={`${index}label`}
name={[field.name, "label"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("jobs.fields.intake.label")}
key={`${index}labeld`}
name={[field.name, "label"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
<Form.Item shouldUpdate>
{() => {
if (form.getFieldValue(["intakechecklist", "form", index, "type"]) !== "slider")
return null;
return (
<>
<Form.Item
label={t("jobs.fields.intake.min")}
key={`${index}min`}
name={[field.name, "min"]}
dependencies={[[field.name, "type"]]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
<Form.Item
label={t("jobs.fields.intake.max")}
key={`${index}max`}
name={[field.name, "max"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
</>
);
}}
</Form.Item>
</InlineValidatedFormRow>
</Form.Item>
<Form.Item shouldUpdate>
{() => {
if (form.getFieldValue(["deliverchecklist", "form", index, "type"]) !== "slider") return null;
return (
<>
<Form.Item
label={t("jobs.fields.intake.min")}
key={`${index}mind`}
name={[field.name, "min"]}
dependencies={[[field.name, "type"]]}
rules={[
{
required: form.getFieldValue([field.name, "type"]) === "slider"
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
<Form.Item
label={t("jobs.fields.intake.max")}
key={`${index}maxd`}
name={[field.name, "max"]}
dependencies={[[field.name, "type"]]}
rules={[
{
required: form.getFieldValue([field.name, "type"]) === "slider"
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
</>
);
}}
</Form.Item>
<Form.Item
label={t("jobs.fields.intake.required")}
key={`${index}requiredd`}
name={[field.name, "required"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<DeleteFilled
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows move={move} index={index} total={fields.length} />
</LayoutFormRow>
</Form.Item>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => {
add();
}}
style={{ width: "100%" }}
>
{t("general.actions.add")}
</Button>
</Form.Item>
);
})
)}
</div>
);
}}
</Form.List>
</LayoutFormRow>
<SelectorDiv>
<Form.Item
name={["deliverchecklist", "templates"]}
label={t("bodyshop.fields.deliver.templates")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select
mode="multiple"
options={Object.keys(TemplateListGenerated).map((i) => ({
value: TemplateListGenerated[i].key,
label: TemplateListGenerated[i].title
}))}
/>
</Form.Item>
<Form.Item
name={["deliverchecklist", "actual_delivery"]}
label={t("bodyshop.fields.deliver.require_actual_delivery_date")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Switch />
</Form.Item>
</SelectorDiv>
</LayoutFormRow>
);
}}
</Form.List>
<Form.List name={["deliverchecklist", "form"]}>
{(fields, { add, remove, move }) => {
return (
<LayoutFormRow
header={t("bodyshop.labels.deliverchecklist")}
id="deliverchecklist"
actions={[
<Button
key="add-delivery-checklist-item"
type="primary"
block
onClick={() => {
add();
}}
>
{t("bodyshop.actions.add_delivery_checklist_item")}
</Button>
]}
>
<div>
{fields.length === 0 ? (
<ConfigListEmptyState actionLabel={t("bodyshop.actions.add_delivery_checklist_item")} />
) : (
fields.map((field, index) => {
return (
<Form.Item noStyle key={field.key}>
<InlineValidatedFormRow
form={form}
errorNames={[["deliverchecklist", "form", field.name, "name"]]}
noDivider
title={
<div style={INLINE_TITLE_ROW_STYLE}>
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />
<div style={INLINE_TITLE_GROUP_STYLE}>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("jobs.fields.intake.name")}</div>
<Form.Item
noStyle
name={[field.name, "name"]}
rules={[
{
required: true
}
]}
>
<Input
size="small"
placeholder={t("jobs.fields.intake.name")}
style={{
...INLINE_TITLE_INPUT_STYLE,
width: "100%"
}}
/>
</Form.Item>
</div>
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
<div style={INLINE_TITLE_SWITCH_GROUP_STYLE}>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("jobs.fields.intake.required")}</div>
<Form.Item noStyle name={[field.name, "required"]} valuePropName="checked">
<Switch />
</Form.Item>
</div>
</div>
}
wrapTitle
extra={
<Space align="center" size="small">
<Button
type="text"
danger
icon={<DeleteFilled />}
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
>
<Form.Item
label={t("jobs.fields.intake.type")}
key={`${index}typed`}
name={[field.name, "type"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Select options={Object.keys(ConfigFormTypes).map((i) => ({ value: i, label: i }))} />
</Form.Item>
<Form.Item
label={t("jobs.fields.intake.label")}
key={`${index}labeld`}
name={[field.name, "label"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item shouldUpdate>
{() => {
if (form.getFieldValue(["deliverchecklist", "form", index, "type"]) !== "slider")
return null;
return (
<>
<Form.Item
label={t("jobs.fields.intake.min")}
key={`${index}mind`}
name={[field.name, "min"]}
dependencies={[[field.name, "type"]]}
rules={[
{
required: form.getFieldValue([field.name, "type"]) === "slider"
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
<Form.Item
label={t("jobs.fields.intake.max")}
key={`${index}maxd`}
name={[field.name, "max"]}
dependencies={[[field.name, "type"]]}
rules={[
{
required: form.getFieldValue([field.name, "type"]) === "slider"
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
</>
);
}}
</Form.Item>
</InlineValidatedFormRow>
</Form.Item>
);
})
)}
</div>
</LayoutFormRow>
);
}}
</Form.List>
</div>
);
}

View File

@@ -3,344 +3,392 @@ import { Button, Form, Input, Space } from "antd";
import { useTranslation } from "react-i18next";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import {
INLINE_TITLE_GROUP_STYLE,
INLINE_TITLE_HANDLE_STYLE,
INLINE_TITLE_INPUT_STYLE,
INLINE_TITLE_LABEL_STYLE,
INLINE_TITLE_ROW_STYLE,
InlineTitleListIcon
} from "../layout-form-row/inline-form-row-title.utils.js";
export default function ShopInfoLaborRates() {
const { t } = useTranslation();
const form = Form.useFormInstance();
return (
<>
<LayoutFormRow header={t("bodyshop.labels.shoprates")}>
<Form.Item label={t("jobs.fields.rate_ats")} name={["shoprates", "rate_ats"]}>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item label={t("jobs.fields.rate_ats_flat")} name={["shoprates", "rate_ats_flat"]}>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
</LayoutFormRow>
<LayoutFormRow header={t("bodyshop.labels.laborrates")}>
<Form.List name={["md_labor_rates"]}>
{(fields, { add, remove, move }) => {
return (
<Form.List name={["md_labor_rates"]}>
{(fields, { add, remove, move }) => {
return (
<LayoutFormRow
header={t("bodyshop.labels.laborrates")}
actions={[
<Button
key="add-labor-rate"
type="primary"
block
onClick={() => {
add();
}}
>
{t("bodyshop.actions.newlaborrate")}
</Button>
]}
>
<div>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<LayoutFormRow noDivider={index === 0}>
<Form.Item
label={t("jobs.fields.labor_rate_desc")}
key={`${index}rate_label`}
name={[field.name, "rate_label"]}
rules={[
{
required: true
//message: t("general.validation.required"),
{fields.length === 0 ? (
<ConfigListEmptyState actionLabel={t("bodyshop.actions.newlaborrate")} />
) : (
fields.map((field, index) => {
return (
<Form.Item noStyle key={field.key}>
<InlineValidatedFormRow
form={form}
errorNames={[["md_labor_rates", field.name, "rate_label"]]}
noDivider={index === 0}
title={
<div style={INLINE_TITLE_ROW_STYLE}>
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />
<div style={INLINE_TITLE_GROUP_STYLE}>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("jobs.fields.labor_rate_desc")}</div>
<Form.Item
noStyle
name={[field.name, "rate_label"]}
rules={[
{
required: true
}
]}
>
<Input
size="small"
placeholder={t("jobs.fields.labor_rate_desc")}
style={{
...INLINE_TITLE_INPUT_STYLE,
width: "100%"
}}
/>
</Form.Item>
</div>
</div>
}
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_laa")}
key={`${index}rate_laa`}
name={[field.name, "rate_laa"]}
rules={[
{
required: true
//message: t("general.validation.required"),
wrapTitle
extra={
<Space align="center" size="small">
<Button
type="text"
danger
icon={<DeleteFilled />}
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lab")}
key={`${index}rate_lab`}
name={[field.name, "rate_lab"]}
rules={[
>
<Form.Item
label={t("jobs.fields.rate_laa")}
key={`${index}rate_laa`}
name={[field.name, "rate_laa"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lab")}
key={`${index}rate_lab`}
name={[field.name, "rate_lab"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lad")}
key={`${index}rate_lad`}
name={[field.name, "rate_lad"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lae")}
key={`${index}rate_lae`}
name={[field.name, "rate_lae"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_laf")}
key={`${index}rate_laf`}
name={[field.name, "rate_laf"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lag")}
key={`${index}rate_lag`}
name={[field.name, "rate_lag"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lam")}
key={`${index}rate_lam`}
name={[field.name, "rate_lam"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lar")}
key={`${index}rate_lar`}
name={[field.name, "rate_lar"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_las")}
key={`${index}rate_las`}
name={[field.name, "rate_las"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_la1")}
key={`${index}rate_la1`}
name={[field.name, "rate_la1"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_la2")}
key={`${index}rate_la2`}
name={[field.name, "rate_la2"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_la3")}
key={`${index}rate_la3`}
name={[field.name, "rate_la3"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_la4")}
key={`${index}rate_la4`}
name={[field.name, "rate_la4"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_mash")}
key={`${index}rate_mash`}
name={[field.name, "rate_mash"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_mapa")}
key={`${index}rate_mapa`}
name={[field.name, "rate_mapa"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_ma2s")}
key={`${index}rate_ma2s`}
name={[field.name, "rate_ma2s"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_ma3s")}
key={`${index}rate_ma3s`}
name={[field.name, "rate_ma3s"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput prefix="$" min={0} />
</Form.Item>
{
required: true
//message: t("general.validation.required"),
// <Form.Item
// label={t("jobs.fields.rate_mabl")}
// key={`${index}rate_mabl`}
// name={[field.name, "rate_mabl"]}
// rules={[
// {
// required: true,
// //message: t("general.validation.required"),
// },
// ]}
// >
// <CurrencyInput min={0} />
// </Form.Item>
// <Form.Item
// label={t("jobs.fields.rate_macs")}
// key={`${index}rate_macs`}
// name={[field.name, "rate_macs"]}
// rules={[
// {
// required: true,
// //message: t("general.validation.required"),
// },
// ]}
// >
// <CurrencyInput min={0} />
// </Form.Item>
}
]}
>
<CurrencyInput min={0} />
<Form.Item
label={t("jobs.fields.rate_matd")}
key={`${index}rate_matd`}
name={[field.name, "rate_matd"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_mahw")}
key={`${index}rate_mahw`}
name={[field.name, "rate_mahw"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput prefix="$" min={0} />
</Form.Item>
</InlineValidatedFormRow>
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lad")}
key={`${index}rate_lad`}
name={[field.name, "rate_lad"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lae")}
key={`${index}rate_lae`}
name={[field.name, "rate_lae"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_laf")}
key={`${index}rate_laf`}
name={[field.name, "rate_laf"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lag")}
key={`${index}rate_lag`}
name={[field.name, "rate_lag"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lam")}
key={`${index}rate_lam`}
name={[field.name, "rate_lam"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lar")}
key={`${index}rate_lar`}
name={[field.name, "rate_lar"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_las")}
key={`${index}rate_las`}
name={[field.name, "rate_las"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_la1")}
key={`${index}rate_la1`}
name={[field.name, "rate_la1"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_la2")}
key={`${index}rate_la2`}
name={[field.name, "rate_la2"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_la3")}
key={`${index}rate_la3`}
name={[field.name, "rate_la3"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_la4")}
key={`${index}rate_la4`}
name={[field.name, "rate_la4"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_mash")}
key={`${index}rate_mash`}
name={[field.name, "rate_mash"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_mapa")}
key={`${index}rate_mapa`}
name={[field.name, "rate_mapa"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_ma2s")}
key={`${index}rate_ma2s`}
name={[field.name, "rate_ma2s"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_ma3s")}
key={`${index}rate_ma3s`}
name={[field.name, "rate_ma3s"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
{
// <Form.Item
// label={t("jobs.fields.rate_mabl")}
// key={`${index}rate_mabl`}
// name={[field.name, "rate_mabl"]}
// rules={[
// {
// required: true,
// //message: t("general.validation.required"),
// },
// ]}
// >
// <CurrencyInput min={0} />
// </Form.Item>
// <Form.Item
// label={t("jobs.fields.rate_macs")}
// key={`${index}rate_macs`}
// name={[field.name, "rate_macs"]}
// rules={[
// {
// required: true,
// //message: t("general.validation.required"),
// },
// ]}
// >
// <CurrencyInput min={0} />
// </Form.Item>
}
<Form.Item
label={t("jobs.fields.rate_matd")}
key={`${index}rate_matd`}
name={[field.name, "rate_matd"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_mahw")}
key={`${index}rate_mahw`}
name={[field.name, "rate_mahw"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Space>
<DeleteFilled
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows move={move} index={index} total={fields.rate_length} />
</Space>
</LayoutFormRow>
</Form.Item>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => {
add();
}}
style={{ width: "100%" }}
>
{t("bodyshop.actions.newlaborrate")}
</Button>
</Form.Item>
);
})
)}
</div>
);
}}
</Form.List>
</LayoutFormRow>
</LayoutFormRow>
);
}}
</Form.List>
</>
);
}

View File

@@ -1,6 +1,7 @@
import { Form, Typography } from "antd";
import { useTranslation } from "react-i18next";
import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
const { Text, Paragraph } = Typography;
@@ -11,43 +12,45 @@ export default function ShopInfoNotificationsAutoadd({ bodyshop }) {
const employeeOptions = bodyshop?.employees?.filter((e) => e.active && e.user_email && e.id) || [];
return (
<div>
<Paragraph>{t("bodyshop.fields.notifications.description")}</Paragraph>
<Text type="secondary">{t("bodyshop.labels.notifications.followers")}</Text>
{employeeOptions.length > 0 ? (
<Form.Item
normalize={(value) => (value || []).filter((id) => typeof id === "string" && id.trim() !== "")}
name="notification_followers"
rules={[
{
type: "array",
message: t("general.validation.array")
},
{
validator: async (_, value) => {
if (!value || value.length === 0) {
return Promise.resolve(); // Allow empty array
<LayoutFormRow header={t("bodyshop.labels.notification_options")}>
<div>
<Paragraph>{t("bodyshop.fields.notifications.description")}</Paragraph>
<Text type="secondary">{t("bodyshop.labels.notifications.followers")}</Text>
{employeeOptions.length > 0 ? (
<Form.Item
normalize={(value) => (value || []).filter((id) => typeof id === "string" && id.trim() !== "")}
name="notification_followers"
rules={[
{
type: "array",
message: t("general.validation.array")
},
{
validator: async (_, value) => {
if (!value || value.length === 0) {
return Promise.resolve(); // Allow empty array
}
const hasInvalid = value.some((id) => id == null || typeof id !== "string" || id.trim() === "");
if (hasInvalid) {
return Promise.reject(new Error(t("bodyshop.fields.notifications.invalid_followers")));
}
return Promise.resolve();
}
const hasInvalid = value.some((id) => id == null || typeof id !== "string" || id.trim() === "");
if (hasInvalid) {
return Promise.reject(new Error(t("bodyshop.fields.notifications.invalid_followers")));
}
return Promise.resolve();
}
}
]}
>
<EmployeeSearchSelectComponent
style={{ minWidth: "100%" }}
mode="multiple"
options={employeeOptions}
placeholder={t("bodyshop.fields.notifications.placeholder")}
showEmail={true}
/>
</Form.Item>
) : (
<Text type="secondary">{t("bodyshop.fields.no_employees_available")}</Text>
)}
</div>
]}
>
<EmployeeSearchSelectComponent
style={{ minWidth: "100%" }}
mode="multiple"
options={employeeOptions}
placeholder={t("bodyshop.fields.notifications.placeholder")}
showEmail={true}
/>
</Form.Item>
) : (
<Text type="secondary">{t("bodyshop.fields.no_employees_available")}</Text>
)}
</div>
</LayoutFormRow>
);
}

View File

@@ -3,7 +3,19 @@ import { Button, Col, Form, Input, Row, Select, Space, Switch } from "antd";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import {
INLINE_TITLE_GROUP_STYLE,
INLINE_TITLE_HANDLE_STYLE,
INLINE_TITLE_INPUT_STYLE,
INLINE_TITLE_LABEL_STYLE,
INLINE_TITLE_ROW_STYLE,
INLINE_TITLE_SEPARATOR_STYLE,
INLINE_TITLE_SWITCH_GROUP_STYLE,
InlineTitleListIcon
} from "../layout-form-row/inline-form-row-title.utils.js";
import i18n from "i18next";
const predefinedPartTypes = ["PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG"];
@@ -68,195 +80,223 @@ export default function ShopInfoPartsScan({ form }) {
return (
<div>
<LayoutFormRow header={t("bodyshop.labels.md_parts_scan")}>
<Form.List name={["md_parts_scan"]}>
{(fields, { add, remove, move }) => (
<Form.List name={["md_parts_scan"]}>
{(fields, { add, remove, move }) => (
<LayoutFormRow
header={t("bodyshop.labels.md_parts_scan")}
actions={[
<Button
key="add-parts-scan-rule"
type="primary"
block
onClick={() =>
add({
field: "line_desc",
operation: "contains",
mark_critical: true,
caseInsensitive: true
})
}
>
{t("bodyshop.actions.addpartsrule")}
</Button>
]}
>
<div>
{fields.map((field, index) => {
const selectedField = watchedFields?.[index]?.field || "line_desc";
const fieldType = getFieldType(selectedField);
{fields.length === 0 ? (
<ConfigListEmptyState actionLabel={t("bodyshop.actions.addpartsrule")} />
) : (
fields.map((field, index) => {
const selectedField = watchedFields?.[index]?.field || "line_desc";
const fieldType = getFieldType(selectedField);
return (
<Form.Item key={field.key}>
<Row gutter={[16, 16]} align="middle">
{/* Select Field */}
<Col span={6}>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.field")}
name={[field.name, "field"]}
rules={[
{
required: true,
message: t("general.validation.required", {
label: t("bodyshop.fields.md_parts_scan.field")
})
}
]}
>
<Select
options={fieldSelectOptions}
onChange={() => {
form.setFields([
{ name: ["md_parts_scan", index, "operation"], value: "contains" },
{ name: ["md_parts_scan", index, "value"], value: undefined }
]);
}}
/>
</Form.Item>
</Col>
return (
<Form.Item noStyle key={field.key}>
<InlineValidatedFormRow
form={form}
errorNames={[["md_parts_scan", field.name, "field"]]}
noDivider
title={
<div style={INLINE_TITLE_ROW_STYLE}>
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />
<div style={INLINE_TITLE_GROUP_STYLE}>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("bodyshop.fields.md_parts_scan.field")}</div>
<Form.Item
noStyle
name={[field.name, "field"]}
rules={[
{
required: true,
message: t("general.validation.required", {
label: t("bodyshop.fields.md_parts_scan.field")
})
}
]}
>
<Select
options={fieldSelectOptions}
onChange={() => {
form.setFields([
{ name: ["md_parts_scan", index, "operation"], value: "contains" },
{ name: ["md_parts_scan", index, "value"], value: undefined }
]);
}}
style={{
width: "100%"
}}
styles={{
selector: INLINE_TITLE_INPUT_STYLE
}}
size="small"
/>
</Form.Item>
</div>
{fieldType === "string" && (
<>
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
<div style={INLINE_TITLE_SWITCH_GROUP_STYLE}>
<div style={INLINE_TITLE_LABEL_STYLE}>
{t("bodyshop.fields.md_parts_scan.caseInsensitive")}
</div>
<Form.Item noStyle name={[field.name, "caseInsensitive"]} valuePropName="checked">
<Switch />
</Form.Item>
</div>
</>
)}
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
<div style={INLINE_TITLE_SWITCH_GROUP_STYLE}>
<div style={INLINE_TITLE_LABEL_STYLE}>
{t("bodyshop.fields.md_parts_scan.mark_critical")}
</div>
<Form.Item noStyle name={[field.name, "mark_critical"]} valuePropName="checked">
<Switch />
</Form.Item>
</div>
</div>
}
wrapTitle
extra={
<Space align="center" size="small">
<Button
type="text"
danger
icon={<DeleteFilled />}
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
>
<Row gutter={[16, 16]} align="middle">
{/* Operation */}
{fieldType !== "predefined" && fieldType && (
<Col span={6}>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.operation")}
name={[field.name, "operation"]}
rules={[
{
required: true,
message: t("general.validation.required", {
label: t("bodyshop.fields.md_parts_scan.operation")
})
}
]}
>
<Select options={operationOptions[fieldType]} />
</Form.Item>
</Col>
)}
{/* Operation */}
{fieldType !== "predefined" && fieldType && (
<Col span={6}>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.operation")}
name={[field.name, "operation"]}
rules={[
{
required: true,
message: t("general.validation.required", {
label: t("bodyshop.fields.md_parts_scan.operation")
})
}
]}
>
<Select options={operationOptions[fieldType]} />
</Form.Item>
</Col>
)}
{/* Value */}
{fieldType && (
<Col span={6}>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.value")}
name={[field.name, "value"]}
rules={[
{
required: true,
message: t("general.validation.required", {
label: t("bodyshop.fields.md_parts_scan.value")
})
}
]}
>
{fieldType === "predefined" ? (
<Select
options={
selectedField === "part_type"
? predefinedPartTypes.map((type) => ({
label: type,
value: type
}))
: predefinedModLbrTypes.map((type) => ({
label: type,
value: type
}))
}
/>
) : (
<Input />
)}
</Form.Item>
</Col>
)}
{/* Value */}
{fieldType && (
<Col span={6}>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.value")}
name={[field.name, "value"]}
rules={[
{
required: true,
message: t("general.validation.required", {
label: t("bodyshop.fields.md_parts_scan.value")
})
}
]}
>
{fieldType === "predefined" ? (
{/* Update Field */}
<Col span={4}>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.update_field")}
name={[field.name, "update_field"]}
>
<Select
options={
selectedField === "part_type"
? predefinedPartTypes.map((type) => ({
label: type,
value: type
}))
: predefinedModLbrTypes.map((type) => ({
label: type,
value: type
}))
options={fieldSelectOptions}
allowClear
onClear={() =>
form.setFields([{ name: ["md_parts_scan", index, "update_field"], value: null }])
}
/>
) : (
</Form.Item>
</Col>
{/* Update Field */}
<Col span={4}>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.update_value")}
name={[field.name, "update_value"]}
dependencies={[["md_parts_scan", index, "update_field"]]}
tooltip={t("bodyshop.tooltips.md_parts_scan.update_value_tooltip")}
rules={[
{
required: form.getFieldValue(["md_parts_scan", index, "update_field"]),
message: t("general.validation.required", {
label: t("bodyshop.fields.md_parts_scan.update_value")
})
}
]}
>
<Input />
)}
</Form.Item>
</Col>
)}
{/* Case Sensitivity */}
{fieldType === "string" && (
<Col span={4}>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.caseInsensitive")}
name={[field.name, "caseInsensitive"]}
valuePropName="checked"
labelCol={{ span: 14 }}
wrapperCol={{ span: 10 }}
>
<Switch />
</Form.Item>
</Col>
)}
{/* Mark Line as Critical */}
<Col span={4}>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.mark_critical")}
name={[field.name, "mark_critical"]}
valuePropName="checked"
labelCol={{ span: 14 }}
wrapperCol={{ span: 10 }}
>
<Switch />
</Form.Item>
</Col>
{/* Update Field */}
<Col span={4}>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.update_field")}
name={[field.name, "update_field"]}
>
<Select
options={fieldSelectOptions}
allowClear
onClear={() =>
form.setFields([{ name: ["md_parts_scan", index, "update_field"], value: null }])
}
/>
</Form.Item>
</Col>
{/* Update Field */}
<Col span={4}>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.update_value")}
name={[field.name, "update_value"]}
dependencies={[["md_parts_scan", index, "update_field"]]}
tooltip={t("bodyshop.tooltips.md_parts_scan.update_value_tooltip")}
rules={[
{
required: form.getFieldValue(["md_parts_scan", index, "update_field"]),
message: t("general.validation.required", {
label: t("bodyshop.fields.md_parts_scan.update_value")
})
}
]}
>
<Input />
</Form.Item>
</Col>
{/* Actions */}
<Col span={2}>
<Space>
<DeleteFilled onClick={() => remove(field.name)} />
<FormListMoveArrows move={move} index={index} total={fields.length} />
</Space>
</Col>
</Row>
</Form.Item>
);
})}
<Form.Item>
<Button
type="dashed"
onClick={() =>
add({
field: "line_desc",
operation: "contains",
mark_critical: true,
caseInsensitive: true
})
}
style={{ width: "100%" }}
>
{t("bodyshop.actions.addpartsrule")}
</Button>
</Form.Item>
</Form.Item>
</Col>
</Row>
</InlineValidatedFormRow>
</Form.Item>
);
})
)}
</div>
)}
</Form.List>
</LayoutFormRow>
</LayoutFormRow>
)}
</Form.List>
</div>
);
}

View File

@@ -27,7 +27,7 @@ export function ShopInfoRbacComponent({ bodyshop }) {
});
return (
<RbacWrapper action="shop:rbac">
<LayoutFormRow>
<LayoutFormRow header={t("bodyshop.labels.rbac_options")}>
{[
...(HasFeatureAccess({ featureName: "export", bodyshop })
? [

View File

@@ -1,4 +1,4 @@
import { Collapse, Divider, Form, Input, InputNumber, Space, Switch } from "antd";
import { Col, Collapse, Form, Input, InputNumber, Row, Switch } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -6,6 +6,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
import "./shop-info.responsibilitycenters.taxes.styles.scss";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
@@ -16,53 +17,102 @@ const mapDispatchToProps = () => ({
});
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoResponsibilityCenters);
const taxRootColProps = {
xs: 24,
sm: 12,
md: 8,
lg: { flex: "0 0 280px" },
xl: { flex: "0 0 240px" },
xxl: { flex: "0 0 300px" }
};
const taxTierFieldColProps = {
xs: 24,
sm: 12,
lg: 6
};
export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
const { t } = useTranslation();
//Iteratively build the form items.
const formItems = [];
for (let tyCounter = 1; tyCounter <= 5; tyCounter++) {
const section = [];
const profileTaxCards = [];
for (let typeNum = 1; typeNum <= 5; typeNum++) {
const rootTaxItems = getRootTaxFormItems({ typeNum, bodyshop, t });
section.push(
TaxFormItems({
typeNum: tyCounter,
rootElements: true,
bodyshop
})
profileTaxCards.push(
<LayoutFormRow key={`profile-tax-type-${typeNum}`} header={t("bodyshop.labels.responsibilitycenters.tax_type_card", { typeNum })}>
<div style={{ display: "grid", rowGap: 12 }}>
<Row gutter={[16, 16]} wrap>
{rootTaxItems.map((item, index) => (
<Col key={item.key ?? `tax-root-${typeNum}-${index}`} {...taxRootColProps}>
{item}
</Col>
))}
</Row>
<Row gutter={[12, 12]} wrap className="responsibility-centers-tax-tier-grid">
{Array.from({ length: 5 }, (_, index) => {
const typeNumIterator = index + 1;
const tierTaxItems = getTierTaxFormItems({
typeNum,
typeNumIterator,
t
});
return (
<Col
key={`tax-tier-row-${typeNum}-${typeNumIterator}`}
xs={24}
className="responsibility-centers-tax-tier-grid__col"
>
<LayoutFormRow
header={t("bodyshop.labels.responsibilitycenters.tax_tier_card", { typeNumIterator })}
style={{ marginBottom: 0 }}
styles={{
header: {
paddingInline: 12
},
body: {
padding: 12
}
}}
>
<Row gutter={[12, 8]} wrap>
{tierTaxItems.map((item, tierIndex) => (
<Col key={item.key ?? `tax-tier-${typeNum}-${typeNumIterator}-${tierIndex}`} {...taxTierFieldColProps}>
{item}
</Col>
))}
</Row>
</LayoutFormRow>
</Col>
);
})}
</Row>
</div>
</LayoutFormRow>
);
for (let iterator = 1; iterator <= 5; iterator++) {
section.push(
TaxFormItems({
typeNum: tyCounter,
typeNumIterator: iterator,
rootElements: false
})
);
}
formItems.push(<Space wrap>{section}</Space>);
formItems.push(<Divider />);
}
return (
<>
<Divider titlePlacement="left" orientation="horizontal" style={{ marginTop: ".8rem" }}>
{t("jobs.labels.cieca_pft")}
</Divider>
{formItems}
<LayoutFormRow header={t("jobs.labels.cieca_pft")}>
<div>{profileTaxCards}</div>
</LayoutFormRow>
<Collapse
items={[
{
key: "cieca_pfl",
label: t("jobs.labels.cieca_pfl"),
forceRender: true,
children: (
<>
<LayoutFormRow header={t("bodyshop.labels.responsibilitycenters.default_tax_setup")}>
<Collapse
items={[
{
key: "cieca_pfl",
label: t("jobs.labels.cieca_pfl"),
forceRender: true,
children: (
<>
<LayoutFormRow header={t("joblines.fields.lbr_types.LAB")}>
<Form.Item
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAB", "lbr_adjp"]}
>
<InputNumber min={-100} max={100} precision={4} />
<InputNumber min={-100} max={100} precision={4} suffix="%" />
</Form.Item>
<Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
@@ -89,7 +139,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
}
]}
>
<InputNumber min={0} max={100} precision={4} />
<InputNumber min={0} max={100} precision={4} suffix="%" />
</Form.Item>
);
}}
@@ -135,7 +185,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAD", "lbr_adjp"]}
>
<InputNumber min={-100} max={100} precision={4} />
<InputNumber min={-100} max={100} precision={4} suffix="%" />
</Form.Item>
<Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
@@ -162,7 +212,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
}
]}
>
<InputNumber min={0} max={100} precision={4} />
<InputNumber min={0} max={100} precision={4} suffix="%" />
</Form.Item>
);
}}
@@ -208,7 +258,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAE", "lbr_adjp"]}
>
<InputNumber min={-100} max={100} precision={4} />
<InputNumber min={-100} max={100} precision={4} suffix="%" />
</Form.Item>
<Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
@@ -235,7 +285,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
}
]}
>
<InputNumber min={0} max={100} precision={4} />
<InputNumber min={0} max={100} precision={4} suffix="%" />
</Form.Item>
);
}}
@@ -281,7 +331,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAF", "lbr_adjp"]}
>
<InputNumber min={-100} max={100} precision={4} />
<InputNumber min={-100} max={100} precision={4} suffix="%" />
</Form.Item>
<Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
@@ -308,7 +358,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
}
]}
>
<InputNumber min={0} max={100} precision={4} />
<InputNumber min={0} max={100} precision={4} suffix="%" />
</Form.Item>
);
}}
@@ -354,7 +404,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAG", "lbr_adjp"]}
>
<InputNumber min={-100} max={100} precision={4} />
<InputNumber min={-100} max={100} precision={4} suffix="%" />
</Form.Item>
<Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
@@ -381,7 +431,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
}
]}
>
<InputNumber min={0} max={100} precision={4} />
<InputNumber min={0} max={100} precision={4} suffix="%" />
</Form.Item>
);
}}
@@ -427,7 +477,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAM", "lbr_adjp"]}
>
<InputNumber min={-100} max={100} precision={4} />
<InputNumber min={-100} max={100} precision={4} suffix="%" />
</Form.Item>
<Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
@@ -454,7 +504,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
}
]}
>
<InputNumber min={0} max={100} precision={4} />
<InputNumber min={0} max={100} precision={4} suffix="%" />
</Form.Item>
);
}}
@@ -500,7 +550,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAR", "lbr_adjp"]}
>
<InputNumber min={-100} max={100} precision={4} />
<InputNumber min={-100} max={100} precision={4} suffix="%" />
</Form.Item>
<Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
@@ -527,7 +577,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
}
]}
>
<InputNumber min={0} max={100} precision={4} />
<InputNumber min={0} max={100} precision={4} suffix="%" />
</Form.Item>
);
}}
@@ -573,7 +623,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAS", "lbr_adjp"]}
>
<InputNumber min={-100} max={100} precision={4} />
<InputNumber min={-100} max={100} precision={4} suffix="%" />
</Form.Item>
<Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
@@ -673,7 +723,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
}
]}
>
<InputNumber min={0} max={100} precision={2} />
<InputNumber min={0} max={100} precision={2} suffix="%" />
</Form.Item>
);
}}
@@ -740,7 +790,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.materials.mat_adjp")}
name={["md_responsibility_centers", "cieca_pfm", "MAPA", "mat_adjp"]}
>
<InputNumber min={-100} max={100} precision={4} />
<InputNumber min={-100} max={100} precision={4} suffix="%" />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.tax_ind")}
@@ -767,7 +817,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
}
]}
>
<InputNumber min={0} max={100} precision={4} />
<InputNumber min={0} max={100} precision={4} suffix="%" />
</Form.Item>
);
}}
@@ -825,7 +875,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.materials.mat_adjp")}
name={["md_responsibility_centers", "cieca_pfm", "MASH", "mat_adjp"]}
>
<InputNumber min={-100} max={100} precision={4} />
<InputNumber min={-100} max={100} precision={4} suffix="%" />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.tax_ind")}
@@ -852,7 +902,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
}
]}
>
<InputNumber min={0} max={100} precision={4} />
<InputNumber min={0} max={100} precision={4} suffix="%" />
</Form.Item>
);
}}
@@ -893,15 +943,15 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
<Switch />
</Form.Item>
</LayoutFormRow>
</>
)
},
{
key: "cieca_pfo",
label: t("jobs.labels.cieca_pfo"),
forceRender: true,
children: (
<>
</>
)
},
{
key: "cieca_pfo",
label: t("jobs.labels.cieca_pfo"),
forceRender: true,
children: (
<>
<LayoutFormRow noDivider>
<Form.Item
label={t("jobs.fields.cieca_pfo.tow_t_in1")}
@@ -2145,76 +2195,74 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
<InputNumber min={0} max={100} precision={4} />
</Form.Item>
</LayoutFormRow>
</>
)
}
]}
/>
</>
)
}
]}
/>
</LayoutFormRow>
</>
);
}
function TaxFormItems({ typeNum, typeNumIterator, rootElements, bodyshop }) {
const { t } = useTranslation();
if (rootElements)
return (
<>
<Form.Item
label={t("bodyshop.fields.responsibilitycenter_tax_type", {
typeNum,
typeNumIterator
})}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `tax_type${typeNum}`]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.responsibilitycenters.state_tax")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "name"]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.responsibilitycenter_accountdesc")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "accountdesc"]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.responsibilitycenter_accountitem")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "accountitem"]}
>
<Input />
</Form.Item>
{bodyshopHasDmsKey(bodyshop) && (
function getRootTaxFormItems({ typeNum, bodyshop, t }) {
return [
<Form.Item
key={`tax_type_${typeNum}_type`}
label={t("bodyshop.fields.responsibilitycenter_tax_type", { typeNum })}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `tax_type${typeNum}`]}
>
<Input />
</Form.Item>,
<Form.Item
key={`tax_type_${typeNum}_name`}
label={t("bodyshop.fields.responsibilitycenters.state_tax")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "name"]}
>
<Input />
</Form.Item>,
<Form.Item
key={`tax_type_${typeNum}_accountdesc`}
label={t("bodyshop.fields.responsibilitycenter_accountdesc")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "accountdesc"]}
>
<Input />
</Form.Item>,
<Form.Item
key={`tax_type_${typeNum}_accountitem`}
label={t("bodyshop.fields.responsibilitycenter_accountitem")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "accountitem"]}
>
<Input />
</Form.Item>,
...(bodyshopHasDmsKey(bodyshop)
? [
<Form.Item
key={`tax_type_${typeNum}_dms_acctnumber`}
label={t("bodyshop.fields.dms.dms_acctnumber")}
rules={[
{
@@ -2226,71 +2274,64 @@ function TaxFormItems({ typeNum, typeNumIterator, rootElements, bodyshop }) {
>
<Input />
</Form.Item>
)}
</>
);
return (
<>
<Form.Item
label={t("bodyshop.fields.responsibilitycenter_tax_tier", {
typeNum,
typeNumIterator
})}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_tier${typeNumIterator}`]}
>
<InputNumber precision={0} min={0} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.responsibilitycenter_tax_thres", {
typeNum,
typeNumIterator
})}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_thres${typeNumIterator}`]}
>
<InputNumber min={0} precision={2} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.responsibilitycenter_tax_rate", {
typeNum,
typeNumIterator
})}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_rate${typeNumIterator}`]}
>
<InputNumber min={0} precision={2} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.responsibilitycenter_tax_sur", {
typeNum,
typeNumIterator
})}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_sur${typeNumIterator}`]}
>
<InputNumber min={0} precision={2} />
</Form.Item>
</>
);
]
: [])
];
}
function getTierTaxFormItems({ typeNum, typeNumIterator, t }) {
return [
<Form.Item
key={`tax_type_${typeNum}_tier_${typeNumIterator}`}
label={t("bodyshop.labels.responsibilitycenters.tax_tier_short")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_tier${typeNumIterator}`]}
>
<InputNumber precision={0} min={0} />
</Form.Item>,
<Form.Item
key={`tax_type_${typeNum}_threshold_${typeNumIterator}`}
label={t("bodyshop.labels.responsibilitycenters.tax_threshold_short")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_thres${typeNumIterator}`]}
>
<InputNumber min={0} precision={2} />
</Form.Item>,
<Form.Item
key={`tax_type_${typeNum}_rate_${typeNumIterator}`}
label={t("bodyshop.labels.responsibilitycenters.tax_rate_short")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_rate${typeNumIterator}`]}
>
<InputNumber min={0} precision={2} suffix="%" />
</Form.Item>,
<Form.Item
key={`tax_type_${typeNum}_surcharge_${typeNumIterator}`}
label={t("bodyshop.labels.responsibilitycenters.tax_surcharge_short")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_sur${typeNumIterator}`]}
>
<InputNumber min={0} precision={2} suffix="%" />
</Form.Item>
];
}

View File

@@ -0,0 +1,25 @@
.responsibility-centers-tax-tier-grid__col.ant-col {
flex: 0 0 100%;
max-width: 100%;
}
@media (min-width: 992px) {
.responsibility-centers-tax-tier-grid__col.ant-col {
flex: 0 0 50%;
max-width: 50%;
}
}
@media (min-width: 1600px) {
.responsibility-centers-tax-tier-grid__col.ant-col {
flex: 0 0 25%;
max-width: 25%;
}
}
@media (min-width: 2400px) {
.responsibility-centers-tax-tier-grid__col.ant-col {
flex: 0 0 20%;
max-width: 20%;
}
}

View File

@@ -21,7 +21,7 @@ export default function ShopInfoRoGuard({ form }) {
{() => {
const disabled = !form.getFieldValue(["md_ro_guard", "enabled"]);
return (
<LayoutFormRow noDivider>
<LayoutFormRow header={t("bodyshop.labels.md_ro_guard_options")}>
<Form.Item
label={t("bodyshop.fields.md_ro_guard.totalgppercent_minimum")}
name={["md_ro_guard", "totalgppercent_minimum"]}
@@ -32,7 +32,7 @@ export default function ShopInfoRoGuard({ form }) {
}
]}
>
<InputNumber min={0} max={100} precision={1} disabled={disabled} />
<InputNumber min={0} max={100} precision={1} suffix="%" disabled={disabled} />
</Form.Item>
<Form.Item

View File

@@ -1,10 +1,17 @@
import { DeleteFilled } from "@ant-design/icons";
import { CloseOutlined, DeleteFilled, HolderOutlined } from "@ant-design/icons";
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { arrayMove, rectSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button, Form, Select, Space } from "antd";
import { useState } from "react";
import { ChromePicker } from "react-color";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { DEFAULT_TRANSLUCENT_CARD_COLOR, getTintedCardSurfaceStyles } from "./shop-info.color.utils";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -24,10 +31,341 @@ const SelectorDiv = styled.div`
.ant-form-item .ant-select {
width: 200px;
}
.production-status-color-title-select {
min-width: 160px;
width: 100%;
}
.production-status-color-title-select .ant-select-selector {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding-inline: 0 !important;
}
.production-status-color-title-select .ant-select-selection-item,
.production-status-color-title-select .ant-select-selection-placeholder {
font-weight: 500;
}
.job-statuses-source-select .ant-select-selector {
align-items: flex-start !important;
}
.job-statuses-source-select .ant-select-selection-wrap {
gap: 4px 0;
}
.job-statuses-source-tag-wrapper {
display: inline-flex;
max-width: 100%;
margin-inline-end: 6px;
touch-action: none;
}
.job-statuses-source-tag-wrapper .ant-select-selection-item {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 30px;
min-width: 132px;
max-width: 100%;
padding-inline: 10px;
border-radius: 999px;
border: 1px solid var(--ant-color-border);
background: var(--ant-color-fill-quaternary);
justify-content: space-between;
max-width: 100%;
cursor: grab;
margin-inline-end: 0;
user-select: none;
}
.job-statuses-source-tag-wrapper .job-statuses-source-tag-handle {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--ant-color-text-tertiary);
flex: none;
font-size: 12px;
}
.job-statuses-source-tag-wrapper .ant-select-selection-item-content {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.job-statuses-source-tag-wrapper .ant-select-selection-item:active {
cursor: grabbing;
}
.job-statuses-source-tag-wrapper .ant-select-selection-item-remove {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 18px;
height: 18px;
border-radius: 999px;
color: var(--ant-color-text-tertiary);
transition:
background 0.2s ease,
color 0.2s ease;
}
.job-statuses-source-tag-wrapper .ant-select-selection-item-remove:hover {
background: var(--ant-color-fill-secondary);
color: var(--ant-color-text);
}
.job-statuses-source-tag-wrapper--dragging {
opacity: 0.55;
}
`;
const normalizeStatuses = (statuses) => [...new Set((statuses || []).map((item) => item?.trim()).filter(Boolean))];
const getTranslatedDragRect = (active, delta) => {
const rect = active?.rect?.current?.initial || active?.rect?.current?.translated;
if (!rect) return null;
const x = delta?.x || 0;
const y = delta?.y || 0;
return {
left: rect.left + x,
right: rect.right + x,
top: rect.top + y,
bottom: rect.bottom + y,
width: rect.width,
height: rect.height
};
};
const isPointWithinRect = (point, rect) => {
if (!point || !rect) return false;
return point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom;
};
const DraggableStatusTag = ({ label, value, closable, onClose }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: value
});
const labelText = String(label ?? value);
return (
<span
ref={setNodeRef}
className={`job-statuses-source-tag-wrapper ${isDragging ? "job-statuses-source-tag-wrapper--dragging" : ""}`}
data-status-tag-value={value}
style={{ transform: CSS.Transform.toString(transform), transition }}
onMouseDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
}}
{...attributes}
{...listeners}
>
<span
className="ant-select-selection-item"
onMouseDown={(event) => {
if (event.target.closest(".ant-select-selection-item-remove")) {
event.stopPropagation();
return;
}
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
if (event.target.closest(".ant-select-selection-item-remove")) {
event.stopPropagation();
return;
}
event.stopPropagation();
}}
title={labelText}
>
<span className="job-statuses-source-tag-handle" aria-hidden>
<HolderOutlined />
</span>
<span className="ant-select-selection-item-content">{labelText}</span>
{closable ? (
<span
className="ant-select-selection-item-remove"
onClick={(event) => {
event.stopPropagation();
onClose?.(event);
}}
onMouseDown={(event) => {
event.stopPropagation();
}}
>
<CloseOutlined />
</span>
) : null}
</span>
</span>
);
};
const SortableStatusesSelect = ({ value, onChange, mode = "tags", options = [] }) => {
const statuses = normalizeStatuses(value);
const isTagsMode = mode === "tags";
const [knownStatuses, setKnownStatuses] = useState(statuses);
const selectWrapperRef = useRef(null);
const dragRectRef = useRef(null);
const tagSensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 6
}
})
);
const handleStatusesChange = (nextValues) => {
const normalizedNextValues = normalizeStatuses(nextValues);
if (isTagsMode) {
setKnownStatuses((currentKnownStatuses) => normalizeStatuses([...currentKnownStatuses, ...normalizedNextValues]));
}
onChange?.(normalizedNextValues);
};
useEffect(() => {
if (isTagsMode) {
setKnownStatuses((currentKnownStatuses) => normalizeStatuses([...currentKnownStatuses, ...statuses]));
}
}, [isTagsMode, statuses]);
const shouldMoveStatusToEnd = (activeId, dragRect) => {
const selectRect =
selectWrapperRef.current?.querySelector?.(".ant-select-selector")?.getBoundingClientRect?.() ||
selectWrapperRef.current?.getBoundingClientRect?.();
if (!dragRect || !selectRect) return false;
const dragLeadingPoint = {
x: dragRect.left,
y: dragRect.top
};
const dragTrailingPoint = {
x: dragRect.right,
y: dragRect.bottom
};
if (!isPointWithinRect(dragLeadingPoint, selectRect) && !isPointWithinRect(dragTrailingPoint, selectRect)) {
return false;
}
const trailingStatus = statuses.filter((status) => status !== activeId).at(-1);
if (!trailingStatus) return false;
const trailingTagNode = selectWrapperRef.current?.querySelector?.(
`.job-statuses-source-tag-wrapper[data-status-tag-value="${CSS.escape(String(trailingStatus))}"]`
);
const trailingTagRect = trailingTagNode?.getBoundingClientRect?.();
if (!trailingTagRect) return false;
const isOnTrailingRow = dragRect.bottom >= trailingTagRect.top && dragRect.top <= trailingTagRect.bottom;
if (isOnTrailingRow) {
return dragRect.left >= trailingTagRect.right - 4;
}
return dragRect.top >= trailingTagRect.bottom - 4;
};
const handleStatusSortEnd = ({ active, over, delta }) => {
const oldIndex = statuses.indexOf(active.id);
const dragRect = dragRectRef.current || getTranslatedDragRect(active, delta);
dragRectRef.current = null;
if (oldIndex < 0) return;
if (!over) {
if (oldIndex !== statuses.length - 1 && shouldMoveStatusToEnd(active.id, dragRect)) {
onChange?.(arrayMove(statuses, oldIndex, statuses.length - 1));
}
return;
}
if (active.id === over.id) return;
const newIndex = statuses.indexOf(over.id);
if (newIndex < 0) return;
onChange?.(arrayMove(statuses, oldIndex, newIndex));
};
const renderStatusTag = ({ label, value: tagValue, closable, onClose }) => {
return <DraggableStatusTag closable={closable} label={label} onClose={onClose} value={tagValue} />;
};
const statusSelectOptions = isTagsMode
? knownStatuses.map((status) => ({
value: status,
label: status
}))
: options;
if (statuses.length === 0) {
return (
<Select
className="job-statuses-source-select"
mode={mode}
onChange={handleStatusesChange}
options={statusSelectOptions}
value={statuses}
/>
);
}
return (
<div ref={selectWrapperRef}>
<DndContext
collisionDetection={closestCenter}
onDragCancel={() => {
dragRectRef.current = null;
}}
onDragEnd={handleStatusSortEnd}
onDragMove={({ active, delta }) => {
dragRectRef.current = getTranslatedDragRect(active, delta);
}}
sensors={tagSensors}
>
<SortableContext items={statuses} strategy={rectSortingStrategy}>
<Select
className="job-statuses-source-select"
mode={mode}
onChange={handleStatusesChange}
options={statusSelectOptions}
tagRender={renderStatusTag}
value={statuses}
/>
</SortableContext>
</DndContext>
</div>
);
};
export function ShopInfoROStatusComponent({ bodyshop, form }) {
const { t } = useTranslation();
const allStatuses = normalizeStatuses(Form.useWatch(["md_ro_statuses", "statuses"], form));
const productionStatuses = Form.useWatch(["md_ro_statuses", "production_statuses"], form) || [];
const additionalBoardStatuses = Form.useWatch(["md_ro_statuses", "additional_board_statuses"], form) || [];
const productionColors = Form.useWatch(["md_ro_statuses", "production_colors"], form) || [];
const statusOptions = allStatuses;
const statusSelectOptions = statusOptions.map((item) => ({ value: item, label: item }));
const availableProductionStatuses = [...new Set([...productionStatuses, ...additionalBoardStatuses].filter(Boolean))];
const {
treatments: { Production_List_Status_Colors }
@@ -37,117 +375,119 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
splitKey: bodyshop.imexshopid
});
const [options, setOptions] = useState(form.getFieldValue(["md_ro_statuses", "statuses"]) || []);
const [productionStatus, setProductionStatus] = useState(
(form.getFieldValue(["md_ro_statuses", "production_statuses"]) || []).concat(
form.getFieldValue(["md_ro_statuses", "additional_board_statuses"]) || []
) || []
);
const handleBlur = () => {
setOptions(form.getFieldValue(["md_ro_statuses", "statuses"]));
setProductionStatus(
form
.getFieldValue(["md_ro_statuses", "production_statuses"])
.concat(form.getFieldValue(["md_ro_statuses", "additional_board_statuses"]))
);
};
return (
<SelectorDiv id="jobstatus">
<Form.Item
name={["md_ro_statuses", "statuses"]}
label={t("bodyshop.labels.alljobstatuses")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" onBlur={handleBlur} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "active_statuses"]}
label={t("bodyshop.fields.statuses.active_statuses")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "pre_production_statuses"]}
label={t("bodyshop.fields.statuses.pre_production_statuses")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "production_statuses"]}
label={t("bodyshop.fields.statuses.production_statuses")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "post_production_statuses"]}
label={t("bodyshop.fields.statuses.post_production_statuses")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "ready_statuses"]}
label={t("bodyshop.fields.statuses.ready_statuses")}
rules={[
{
//required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "additional_board_statuses"]}
label={t("bodyshop.fields.statuses.additional_board_statuses")}
rules={[
{
//required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<LayoutFormRow noDivider>
<LayoutFormRow grow header={t("bodyshop.labels.job_status_options")}>
<div>
<Form.Item
name={["md_ro_statuses", "statuses"]}
label={t("bodyshop.labels.alljobstatuses")}
required
rules={[
{
validator: async (_, value) => {
const populatedStatuses = normalizeStatuses(value);
if (populatedStatuses.length === 0) {
return Promise.reject(
new Error(
t("general.validation.required", {
label: t("bodyshop.labels.alljobstatuses")
})
)
);
}
if (populatedStatuses.length !== (value || []).filter(Boolean).length) {
return Promise.reject(new Error(t("bodyshop.errors.duplicate_job_status")));
}
}
}
]}
>
<SortableStatusesSelect />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "active_statuses"]}
label={t("bodyshop.fields.statuses.active_statuses")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "pre_production_statuses"]}
label={t("bodyshop.fields.statuses.pre_production_statuses")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "production_statuses"]}
label={t("bodyshop.fields.statuses.production_statuses")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "post_production_statuses"]}
label={t("bodyshop.fields.statuses.post_production_statuses")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "ready_statuses"]}
label={t("bodyshop.fields.statuses.ready_statuses")}
rules={[
{
//required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "additional_board_statuses"]}
label={t("bodyshop.fields.statuses.additional_board_statuses")}
rules={[
{
//required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
</Form.Item>
</div>
</LayoutFormRow>
<LayoutFormRow grow header={t("general.actions.defaults")}>
<Form.Item
label={t("bodyshop.fields.statuses.default_scheduled")}
rules={[
@@ -158,7 +498,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]}
name={["md_ro_statuses", "default_scheduled"]}
>
<Select options={options.map((item) => ({ value: item, label: item }))} />
<Select options={statusSelectOptions} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.statuses.default_arrived")}
@@ -170,7 +510,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]}
name={["md_ro_statuses", "default_arrived"]}
>
<Select options={options.map((item) => ({ value: item, label: item }))} />
<Select options={statusSelectOptions} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.statuses.default_exported")}
@@ -182,7 +522,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]}
name={["md_ro_statuses", "default_exported"]}
>
<Select options={options.map((item) => ({ value: item, label: item }))} />
<Select options={statusSelectOptions} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.statuses.default_imported")}
@@ -194,7 +534,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]}
name={["md_ro_statuses", "default_imported"]}
>
<Select options={options.map((item) => ({ value: item, label: item }))} />
<Select options={statusSelectOptions} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.statuses.default_invoiced")}
@@ -206,7 +546,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]}
name={["md_ro_statuses", "default_invoiced"]}
>
<Select options={options.map((item) => ({ value: item, label: item }))} />
<Select options={statusSelectOptions} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.statuses.default_completed")}
@@ -218,7 +558,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]}
name={["md_ro_statuses", "default_completed"]}
>
<Select options={options.map((item) => ({ value: item, label: item }))} />
<Select options={statusSelectOptions} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.statuses.default_delivered")}
@@ -230,7 +570,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]}
name={["md_ro_statuses", "default_delivered"]}
>
<Select options={options.map((item) => ({ value: item, label: item }))} />
<Select options={statusSelectOptions} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.statuses.default_void")}
@@ -242,73 +582,122 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]}
name={["md_ro_statuses", "default_void"]}
>
<Select options={options.map((item) => ({ value: item, label: item }))} />
<Select options={statusSelectOptions} />
</Form.Item>
</LayoutFormRow>
{Production_List_Status_Colors.treatment === "on" && (
<LayoutFormRow grow header={t("bodyshop.fields.statuses.production_colors")} id="production_colors">
<Form.List name={["md_ro_statuses", "production_colors"]}>
{(fields, { add, remove }) => {
return (
<Form.List name={["md_ro_statuses", "production_colors"]}>
{(fields, { add, remove }) => {
return (
<LayoutFormRow
grow
header={t("bodyshop.fields.statuses.production_colors")}
id="production_colors"
actions={[
<Button
key="add-production-status-color"
type="primary"
block
onClick={() => {
add({
color: { ...DEFAULT_TRANSLUCENT_CARD_COLOR }
});
}}
>
{t("bodyshop.actions.add_production_status_color")}
</Button>
]}
>
<div>
<Space size="large" wrap>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<Space orientation="vertical">
<div style={{ display: "flex" }}>
<Form.Item
style={{ flex: 1 }}
label={t("jobs.fields.status")}
key={`${index}status`}
name={[field.name, "status"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Select options={productionStatus.map((item) => ({ value: item, label: item }))} />
</Form.Item>
<DeleteFilled
onClick={() => {
remove(field.name);
}}
/>
</div>
<Form.Item
label={t("bodyshop.fields.statuses.color")}
key={`${index}color`}
name={[field.name, "color"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
{fields.length === 0 ? (
<ConfigListEmptyState actionLabel={t("bodyshop.actions.add_production_status_color")} />
) : (
<Space size="large" wrap align="start">
{fields.map((field, index) => {
const productionColor = productionColors[field.name] || {};
const productionColorSurfaceStyles = getTintedCardSurfaceStyles(productionColor.color);
const selectedProductionColorStatuses = productionColors
.map((item) => item?.status)
.filter(Boolean);
const productionColorStatusOptions = [
...new Set([productionColor.status, ...availableProductionStatuses])
]
.filter(Boolean)
.filter(
(status) =>
status === productionColor.status || !selectedProductionColorStatuses.includes(status)
);
return (
<InlineValidatedFormRow
form={form}
errorNames={[["md_ro_statuses", "production_colors", field.name, "status"]]}
key={field.key}
noDivider
title={
<Form.Item
noStyle
key={`${index}status`}
name={[field.name, "status"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Select
className="production-status-color-title-select"
variant="borderless"
placeholder={getFormListItemTitle(
t("jobs.fields.status"),
index,
productionColor.status
)}
options={productionColorStatusOptions.map((item) => ({
value: item,
label: item
}))}
/>
</Form.Item>
}
extra={
<Button
type="text"
danger
icon={<DeleteFilled />}
onClick={() => {
remove(field.name);
}}
/>
}
{...productionColorSurfaceStyles}
style={{ width: 260, marginBottom: 0 }}
>
<ColorPicker />
</Form.Item>
</Space>
</Form.Item>
))}
</Space>
<Form.Item>
<Button
type="dashed"
onClick={() => {
add();
}}
style={{ width: "100%" }}
>
{t("general.actions.add")}
</Button>
</Form.Item>
<div>
<Form.Item
key={`${index}color`}
name={[field.name, "color"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<ColorPicker />
</Form.Item>
</div>
</InlineValidatedFormRow>
);
})}
</Space>
)}
</div>
);
}}
</Form.List>
</LayoutFormRow>
</LayoutFormRow>
);
}}
</Form.List>
)}
</SelectorDiv>
);

View File

@@ -1,5 +1,5 @@
import { DeleteFilled } from "@ant-design/icons";
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch, TimePicker } from "antd";
import { DeleteFilled, ReloadOutlined } from "@ant-design/icons";
import { Button, Col, Form, Input, InputNumber, Row, Select, Space, Switch, TimePicker, Tooltip } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -7,8 +7,16 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import ColorpickerFormItemComponent from "../form-items-formatted/colorpicker-form-item.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { ColorPicker } from "./shop-info.rostatus.component";
import {
DEFAULT_TRANSLUCENT_CARD_COLOR,
DEFAULT_TRANSLUCENT_PICKER_COLOR,
getTintedCardSurfaceStyles
} from "./shop-info.color.utils";
import "./shop-info.scheduling.styles.scss";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -17,301 +25,514 @@ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
const WORKING_DAYS = [
{ key: "sunday", labelKey: "general.labels.sunday" },
{ key: "monday", labelKey: "general.labels.monday" },
{ key: "tuesday", labelKey: "general.labels.tuesday" },
{ key: "wednesday", labelKey: "general.labels.wednesday" },
{ key: "thursday", labelKey: "general.labels.thursday" },
{ key: "friday", labelKey: "general.labels.friday" },
{ key: "saturday", labelKey: "general.labels.saturday" }
];
const APPOINTMENT_COLOR_PICKER_STYLES = {
default: {
wrap: {
display: "flex",
flexWrap: "wrap",
gap: "12px",
alignItems: "flex-start"
},
hue: {
flex: "1 1 180px",
height: "12px",
position: "relative",
marginTop: "20px"
},
swatches: {
flex: "1 1 160px"
}
}
};
const SCHEDULING_BUCKET_COLOR_PICKER_STYLES = {
default: {
picker: {
width: "100%",
height: "100%",
background: "color-mix(in srgb, var(--imex-form-surface) 92%, transparent)",
boxShadow: "none",
border: "1px solid color-mix(in srgb, var(--imex-form-surface-border) 72%, transparent)",
borderRadius: "8px",
boxSizing: "border-box",
overflow: "hidden"
},
saturation: {
width: "100%",
paddingBottom: "48%",
position: "relative",
borderRadius: "8px 8px 0 0",
overflow: "hidden"
},
body: {
padding: "12px"
},
controls: {
display: "flex",
gap: "10px"
},
color: {
width: "28px"
},
swatch: {
marginTop: "0",
width: "12px",
height: "12px",
borderRadius: "999px"
},
toggles: {
flex: "1"
},
hue: {
height: "10px",
position: "relative",
marginBottom: "8px"
},
alpha: {
height: "10px",
position: "relative"
}
}
};
const SECTION_TITLE_INPUT_STYLE = {
background: "color-mix(in srgb, var(--imex-form-surface) 78%, transparent)",
border: "1px solid color-mix(in srgb, var(--imex-form-surface-border) 72%, transparent)",
borderRadius: 6,
fontWeight: 500
};
const SECTION_TITLE_INPUT_ROW_STYLE = {
display: "flex",
gap: 8,
flexWrap: "wrap",
alignItems: "center",
minWidth: 180,
maxWidth: "100%"
};
const SECTION_TITLE_INPUT_GROUP_STYLE = {
display: "flex",
alignItems: "center",
gap: 6,
minWidth: 0
};
const SECTION_TITLE_INPUT_LABEL_STYLE = {
fontSize: 12,
lineHeight: 1.1,
opacity: 0.75,
whiteSpace: "nowrap"
};
export function ShopInfoSchedulingComponent({ form, bodyshop }) {
const { t } = useTranslation();
const appointmentColors = Form.useWatch(["appt_colors"], form) || form.getFieldValue(["appt_colors"]) || [];
const schedulingBuckets = Form.useWatch(["ssbuckets"], form) || form.getFieldValue(["ssbuckets"]) || [];
return (
<div>
<LayoutFormRow id="shopinfo-scheduling">
<Form.Item
label={t("bodyshop.fields.appt_length")}
name={"appt_length"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={15} precision={0} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.schedule_start_time")}
name={"schedule_start_time"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
id="schedule_start_time"
>
<TimePicker disableSeconds={true} format="HH:mm" />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.schedule_end_time")}
name={"schedule_end_time"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
id="schedule_end_time"
>
<TimePicker disableSeconds={true} format="HH:mm" />
</Form.Item>
<Form.Item
name={["appt_alt_transport"]}
label={t("bodyshop.fields.appt_alt_transport")}
rules={[
{
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>
<Form.Item
name={["ss_configuration", "dailyhrslimit"]}
label={t("bodyshop.fields.ss_configuration.dailyhrslimit")}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={["ss_configuration", "nobusinessdays"]}
label={t("bodyshop.fields.ss_configuration.nobusinessdays")}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={["md_lost_sale_reasons"]}
label={t("bodyshop.fields.md_lost_sale_reasons")}
rules={[
{
// required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>
<LayoutFormRow grow header={t("bodyshop.labels.scheduling")} id="shopinfo-scheduling">
<>
<Form.Item
name={["appt_alt_transport"]}
label={t("bodyshop.fields.appt_alt_transport")}
rules={[
{
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>
<Form.Item
name={["md_lost_sale_reasons"]}
label={t("bodyshop.fields.md_lost_sale_reasons")}
rules={[
{
// required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>
<Row gutter={[16, 0]} wrap>
<Col xs={24} sm={12} xl={6}>
<Form.Item
label={t("bodyshop.fields.appt_length")}
name={"appt_length"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={15} precision={0} suffix="min" />
</Form.Item>
</Col>
<Col xs={24} sm={12} xl={6}>
<Form.Item
label={t("bodyshop.fields.schedule_start_time")}
name={"schedule_start_time"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
id="schedule_start_time"
>
<TimePicker disableSeconds={true} format="HH:mm" />
</Form.Item>
</Col>
<Col xs={24} sm={12} xl={6}>
<Form.Item
label={t("bodyshop.fields.schedule_end_time")}
name={"schedule_end_time"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
id="schedule_end_time"
>
<TimePicker disableSeconds={true} format="HH:mm" />
</Form.Item>
</Col>
<Col xs={24} sm={12} xl={6}>
<Form.Item
name={["ss_configuration", "dailyhrslimit"]}
label={t("bodyshop.fields.ss_configuration.dailyhrslimit")}
>
<InputNumber min={0} suffix="hrs" />
</Form.Item>
</Col>
<Col xs={24} sm={12} xl={6}>
<Form.Item
name={["ss_configuration", "nobusinessdays"]}
label={t("bodyshop.fields.ss_configuration.nobusinessdays")}
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
</Row>
</>
</LayoutFormRow>
<Divider titlePlacement="left">{t("bodyshop.labels.workingdays")}</Divider>
<Space wrap size="large" id="workingdays">
<Form.Item label={t("general.labels.sunday")} name={["workingdays", "sunday"]} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t("general.labels.monday")} name={["workingdays", "monday"]} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t("general.labels.tuesday")} name={["workingdays", "tuesday"]} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t("general.labels.wednesday")} name={["workingdays", "wednesday"]} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t("general.labels.thursday")} name={["workingdays", "thursday"]} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t("general.labels.friday")} name={["workingdays", "friday"]} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t("general.labels.saturday")} name={["workingdays", "saturday"]} valuePropName="checked">
<Switch />
</Form.Item>
</Space>
<LayoutFormRow header={t("bodyshop.labels.apptcolors")} id="apptcolors">
<Form.List name={["appt_colors"]}>
<LayoutFormRow header={t("bodyshop.labels.workingdays")} id="workingdays">
<Space wrap size="middle">
{WORKING_DAYS.map(({ key, labelKey }) => (
<Form.Item key={key} label={t(labelKey)} name={["workingdays", key]} valuePropName="checked">
<Switch />
</Form.Item>
))}
</Space>
</LayoutFormRow>
<Form.List name={["appt_colors"]}>
{(fields, { add, remove, move }) => {
return (
<LayoutFormRow
header={t("bodyshop.labels.apptcolors")}
id="apptcolors"
actions={[
<Button
key="add-appointment-color"
type="primary"
block
onClick={() => {
add({
color: {
...DEFAULT_TRANSLUCENT_PICKER_COLOR,
rgb: { ...DEFAULT_TRANSLUCENT_PICKER_COLOR.rgb }
}
});
}}
>
{t("bodyshop.actions.addapptcolor")}
</Button>
]}
>
<div>
{fields.length === 0 ? (
<ConfigListEmptyState actionLabel={t("bodyshop.actions.addapptcolor")} />
) : (
fields.map((field, index) => {
const appointmentColor =
appointmentColors[field.name] || form.getFieldValue(["appt_colors", field.name]) || {};
const appointmentColorSurfaceStyles = getTintedCardSurfaceStyles(appointmentColor.color);
return (
<Form.Item noStyle key={field.key}>
<InlineValidatedFormRow
form={form}
errorNames={[["appt_colors", field.name, "label"]]}
noDivider
title={
<div style={{ minWidth: 180, maxWidth: "100%" }}>
<Form.Item
noStyle
key={`${index}aptcolorlabel`}
name={[field.name, "label"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input
size="small"
placeholder={t("bodyshop.fields.appt_colors.label")}
style={SECTION_TITLE_INPUT_STYLE}
/>
</Form.Item>
</div>
}
extra={
<Space align="center" size="small">
<Button
type="text"
danger
icon={<DeleteFilled />}
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
{...appointmentColorSurfaceStyles}
>
<Form.Item
key={`${index}aptcolorcolor`}
name={[field.name, "color"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<ColorpickerFormItemComponent styles={APPOINTMENT_COLOR_PICKER_STYLES} />
</Form.Item>
</InlineValidatedFormRow>
</Form.Item>
);
})
)}
</div>
</LayoutFormRow>
);
}}
</Form.List>
{HasFeatureAccess({ featureName: "smartscheduling", bodyshop }) && (
<Form.List name={["ssbuckets"]}>
{(fields, { add, remove, move }) => {
return (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<LayoutFormRow noDivider>
<Form.Item
label={t("bodyshop.fields.appt_colors.label")}
key={`${index}aptcolorlabel`}
name={[field.name, "label"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.appt_colors.color")}
key={`${index}aptcolorcolor`}
name={[field.name, "color"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<ColorpickerFormItemComponent />
</Form.Item>
<Space wrap>
<DeleteFilled
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows move={move} index={index} total={fields.length} />
</Space>
</LayoutFormRow>
</Form.Item>
))}
<Form.Item>
<LayoutFormRow
header={t("bodyshop.labels.ssbuckets")}
id="ssbuckets"
actions={[
<Button
type="dashed"
key="add-job-size-definition"
type="primary"
block
onClick={() => {
add();
add({
color: { ...DEFAULT_TRANSLUCENT_CARD_COLOR }
});
}}
style={{ width: "100%" }}
>
{t("bodyshop.actions.addapptcolor")}
{t("bodyshop.actions.addbucket")}
</Button>
</Form.Item>
</div>
]}
>
<div>
{fields.length === 0 ? (
<ConfigListEmptyState actionLabel={t("bodyshop.actions.addbucket")} />
) : (
fields.map((field, index) => {
const schedulingBucket =
schedulingBuckets[field.name] || form.getFieldValue(["ssbuckets", field.name]) || {};
const schedulingBucketSurfaceStyles = getTintedCardSurfaceStyles(schedulingBucket.color);
return (
<Form.Item noStyle key={field.key}>
<InlineValidatedFormRow
form={form}
errorNames={[
["ssbuckets", field.name, "id"],
["ssbuckets", field.name, "label"]
]}
noDivider
title={
<div style={SECTION_TITLE_INPUT_ROW_STYLE}>
<div style={SECTION_TITLE_INPUT_GROUP_STYLE}>
<div style={SECTION_TITLE_INPUT_LABEL_STYLE}>{t("bodyshop.fields.ssbuckets.id")}</div>
<Form.Item
noStyle
key={`${index}id`}
name={[field.name, "id"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input
size="small"
placeholder={t("bodyshop.fields.ssbuckets.id")}
style={{
...SECTION_TITLE_INPUT_STYLE,
width: 72
}}
/>
</Form.Item>
</div>
<div
style={{
...SECTION_TITLE_INPUT_GROUP_STYLE,
flex: 1,
minWidth: 0
}}
>
<div style={SECTION_TITLE_INPUT_LABEL_STYLE}>
{t("bodyshop.fields.ssbuckets.label")}
</div>
<Form.Item
noStyle
key={`${index}label`}
name={[field.name, "label"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input
size="small"
placeholder={t("bodyshop.fields.ssbuckets.label")}
style={{
...SECTION_TITLE_INPUT_STYLE,
width: "100%"
}}
/>
</Form.Item>
</div>
</div>
}
extra={
<Space align="center" size="small">
<Button
type="text"
danger
icon={<DeleteFilled />}
onClick={() => {
remove(field.name);
}}
/>
<Tooltip title={t("bodyshop.tooltips.reset-color")}>
<Button
type="text"
icon={<ReloadOutlined />}
onClick={() => {
form.setFieldValue(["ssbuckets", field.name, "color"]);
form.setFields([
{
name: ["ssbuckets", field.name, "color"],
touched: true
}
]);
}}
/>
</Tooltip>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
{...schedulingBucketSurfaceStyles}
>
<div className="shop-info-scheduling__bucket-card-body">
<div className="shop-info-scheduling__bucket-card-fields">
<Form.Item
label={t("bodyshop.fields.ssbuckets.gte")}
key={`${index}gte`}
name={[field.name, "gte"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber suffix="hrs" />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.ssbuckets.lt")}
key={`${index}lt`}
name={[field.name, "lt"]}
>
<InputNumber suffix="hrs" />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.ssbuckets.target")}
key={`${index}target`}
name={[field.name, "target"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
</div>
<div className="shop-info-scheduling__bucket-card-color">
<Form.Item key={`${index}color`} name={[field.name, "color"]}>
<ColorPicker styles={SCHEDULING_BUCKET_COLOR_PICKER_STYLES} />
</Form.Item>
</div>
</div>
</InlineValidatedFormRow>
</Form.Item>
);
})
)}
</div>
</LayoutFormRow>
);
}}
</Form.List>
</LayoutFormRow>
{HasFeatureAccess({ featureName: "smartscheduling", bodyshop }) && (
<LayoutFormRow header={t("bodyshop.labels.ssbuckets")} id="ssbuckets">
<Form.List name={["ssbuckets"]}>
{(fields, { add, remove, move }) => {
return (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<LayoutFormRow noDivider>
<Form.Item
label={t("bodyshop.fields.ssbuckets.id")}
key={`${index}id`}
name={[field.name, "id"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.ssbuckets.label")}
key={`${index}label`}
name={[field.name, "label"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.ssbuckets.gte")}
key={`${index}gte`}
name={[field.name, "gte"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.ssbuckets.lt")}
key={`${index}lt`}
name={[field.name, "lt"]}
>
<InputNumber />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.ssbuckets.target")}
key={`${index}target`}
name={[field.name, "target"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
<Space orientation="horizontal">
<Form.Item
label={
<Space>
{t("bodyshop.fields.ssbuckets.color")}
<Button
size="small"
onClick={() => {
form.setFieldValue(["ssbuckets", field.name, "color"]);
form.setFields([
{
name: ["ssbuckets", field.name, "color"],
touched: true
}
]);
}}
>
Reset
</Button>
</Space>
}
key={`${index}color`}
name={[field.name, "color"]}
>
<ColorPicker />
</Form.Item>
<Space wrap>
<DeleteFilled
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows move={move} index={index} total={fields.length} />
</Space>
</Space>
</LayoutFormRow>
</Form.Item>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => {
add();
}}
style={{ width: "100%" }}
>
{t("bodyshop.actions.addbucket")}
</Button>
</Form.Item>
</div>
);
}}
</Form.List>
</LayoutFormRow>
)}
</div>
);

View File

@@ -0,0 +1,58 @@
.shop-info-scheduling__bucket-card-body {
display: flex;
gap: 12px;
align-items: stretch;
}
.shop-info-scheduling__bucket-card-fields {
flex: 1 1 0;
min-width: 0;
display: grid;
grid-template-columns: repeat(3, minmax(92px, 1fr));
gap: 0 12px;
}
.shop-info-scheduling__bucket-card-fields .ant-form-item {
margin-bottom: 10px;
}
.shop-info-scheduling__bucket-card-color {
flex: 0 0 360px;
min-width: 360px;
max-width: 360px;
display: flex;
align-items: stretch;
}
.shop-info-scheduling__bucket-card-color .ant-form-item {
margin-bottom: 0;
width: 100%;
}
.shop-info-scheduling__bucket-card-color .ant-form-item-control,
.shop-info-scheduling__bucket-card-color .ant-form-item-control-input,
.shop-info-scheduling__bucket-card-color .ant-form-item-control-input-content {
height: 100%;
}
@media (max-width: 1199px) {
.shop-info-scheduling__bucket-card-body {
flex-direction: column;
}
.shop-info-scheduling__bucket-card-fields {
grid-template-columns: repeat(2, minmax(120px, 1fr));
}
.shop-info-scheduling__bucket-card-color {
flex-basis: auto;
min-width: 0;
max-width: none;
}
}
@media (max-width: 575px) {
.shop-info-scheduling__bucket-card-fields {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@@ -0,0 +1,213 @@
import { Select } from "antd";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import "./shop-info.section-navigator.styles.scss";
const HIGHLIGHT_CLASS = "shop-info-section-navigator__target--active";
export default function ShopInfoSectionNavigator({ tabsRef, activeTabKey }) {
const { t } = useTranslation();
const targetMapRef = useRef(new Map());
const highlightedTargetRef = useRef(null);
const [options, setOptions] = useState([]);
const [selectedSection, setSelectedSection] = useState(undefined);
useEffect(() => {
const tabsContainer = tabsRef.current;
if (!tabsContainer) return undefined;
let animationFrameId = 0;
const refreshOptions = () => {
const activePane = tabsContainer.querySelector(".ant-tabs-tabpane-active");
if (!activePane) {
targetMapRef.current = new Map();
setOptions([]);
return;
}
const nextTargetMap = new Map();
const nextOptions = Array.from(activePane.querySelectorAll(".imex-form-row"))
.filter((card) => {
return shouldIncludeCardInNavigator(card, activePane);
})
.map((card, index) => {
const { title, depth, searchLabel } = getCardNavigatorInfo(card, activePane);
const value = `${activeTabKey}-shop-info-section-${index}`;
nextTargetMap.set(value, card);
return {
label: renderNavigatorOptionLabel(title, depth),
labelText: title,
searchLabel,
depth,
value
};
});
targetMapRef.current = nextTargetMap;
setOptions((currentOptions) => (areOptionsEqual(currentOptions, nextOptions) ? currentOptions : nextOptions));
};
const scheduleRefresh = () => {
cancelAnimationFrame(animationFrameId);
animationFrameId = requestAnimationFrame(refreshOptions);
};
scheduleRefresh();
const observer = new MutationObserver(scheduleRefresh);
observer.observe(tabsContainer, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ["class"]
});
return () => {
cancelAnimationFrame(animationFrameId);
observer.disconnect();
};
}, [activeTabKey, tabsRef]);
useEffect(() => {
clearHighlightedTarget(highlightedTargetRef);
setSelectedSection(undefined);
}, [activeTabKey]);
const handleSectionChange = (value) => {
setSelectedSection(value);
clearHighlightedTarget(highlightedTargetRef);
if (!value) return;
const target = targetMapRef.current.get(value);
if (target) {
target.classList.add(HIGHLIGHT_CLASS);
highlightedTargetRef.current = target;
target.scrollIntoView({
behavior: "smooth",
block: "start"
});
}
window.setTimeout(() => {
setSelectedSection(undefined);
}, 0);
};
return (
<div className="shop-info-section-navigator">
<Select
allowClear
showSearch
value={selectedSection}
placeholder={t("bodyshop.labels.jump_to_section")}
options={options}
popupMatchSelectWidth={false}
disabled={options.length === 0}
filterOption={(input, option) => option?.searchLabel?.toLowerCase().includes(input.toLowerCase())}
onChange={handleSectionChange}
/>
</div>
);
}
function getOwnCardTitleNode(card) {
const headNode = Array.from(card.children).find((child) => child.classList?.contains("ant-card-head"));
return headNode?.querySelector(".ant-card-head-title");
}
function getOwnCardTitle(card) {
return getOwnCardTitleNode(card)?.textContent?.trim();
}
function getAncestorCards(card, activePane) {
const ancestors = [];
let currentCard = card.parentElement?.closest(".imex-form-row");
while (currentCard && activePane.contains(currentCard)) {
ancestors.push(currentCard);
currentCard = currentCard.parentElement?.closest(".imex-form-row");
}
return ancestors.reverse();
}
function getCardDepth(card, activePane) {
return getAncestorCards(card, activePane).length;
}
function isVisibleCard(card) {
return card.offsetParent !== null;
}
function isNavigatorEligibleSubsection(card) {
return (
!card.classList.contains("imex-form-row--compact") &&
!card.classList.contains("imex-form-row--title-only") &&
!card.querySelector(":scope > .ant-card-actions")
);
}
function shouldIncludeCardInNavigator(card, activePane) {
const title = getOwnCardTitle(card);
if (!title || !isVisibleCard(card)) return false;
const depth = getCardDepth(card, activePane);
if (depth === 0) return true;
if (depth === 1) return isNavigatorEligibleSubsection(card);
return false;
}
function getCardNavigatorInfo(card, activePane) {
const title = getOwnCardTitle(card);
const ancestors = getAncestorCards(card, activePane);
const depth = ancestors.length;
const parentTitle = depth === 1 ? getOwnCardTitle(ancestors[0]) : null;
return {
title,
depth,
searchLabel: parentTitle ? `${parentTitle} ${title}` : title
};
}
function renderNavigatorOptionLabel(title, depth) {
return (
<span
className={[
"shop-info-section-navigator__option",
depth > 0 ? "shop-info-section-navigator__option--subsection" : null
]
.filter(Boolean)
.join(" ")}
>
<span className="shop-info-section-navigator__option-label">{title}</span>
</span>
);
}
function clearHighlightedTarget(highlightedTargetRef) {
if (highlightedTargetRef.current) {
highlightedTargetRef.current.classList.remove(HIGHLIGHT_CLASS);
highlightedTargetRef.current = null;
}
}
function areOptionsEqual(currentOptions, nextOptions) {
if (currentOptions.length !== nextOptions.length) return false;
return currentOptions.every((option, index) => {
const nextOption = nextOptions[index];
return (
option.labelText === nextOption.labelText &&
option.searchLabel === nextOption.searchLabel &&
option.depth === nextOption.depth &&
option.value === nextOption.value
);
});
}

View File

@@ -0,0 +1,55 @@
.shop-info-section-navigator {
max-width: 360px;
width: min(360px, 100%);
.ant-select {
width: 100%;
}
}
.shop-info-section-navigator__option {
display: inline-flex;
align-items: center;
min-height: 24px;
}
.shop-info-section-navigator__option--subsection {
position: relative;
padding-left: 18px;
}
.shop-info-section-navigator__option--subsection::before {
content: "";
position: absolute;
left: 6px;
top: 50%;
width: 8px;
height: 1px;
background: var(--ant-colorTextDescription);
transform: translateY(-50%);
}
.shop-info-section-navigator__option-label {
display: inline-block;
}
.imex-form-row.shop-info-section-navigator__target--active.ant-card {
border-color: color-mix(
in srgb,
var(--ant-colorPrimary, #1890ff) 65%,
var(--imex-form-surface-border)
);
background: color-mix(in srgb, var(--ant-colorPrimary, #1890ff) 7%, var(--imex-form-surface));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-colorPrimary, #1890ff) 24%, transparent);
transition: border-color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease;
.ant-card-head {
background: color-mix(in srgb, var(--ant-colorPrimary, #1890ff) 12%, var(--imex-form-surface-head));
}
.ant-card-body {
background: color-mix(in srgb, var(--ant-colorPrimary, #1890ff) 7%, var(--imex-form-surface));
}
}

View File

@@ -3,11 +3,23 @@ import { Button, Form, Input, Select, Space } from "antd";
import { useTranslation } from "react-i18next";
import { TemplateList } from "../../utils/TemplateConstants";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import {
INLINE_TITLE_GROUP_STYLE,
INLINE_TITLE_HANDLE_STYLE,
INLINE_TITLE_INPUT_STYLE,
INLINE_TITLE_LABEL_STYLE,
INLINE_TITLE_ROW_STYLE,
INLINE_TITLE_SEPARATOR_STYLE,
InlineTitleListIcon
} from "../layout-form-row/inline-form-row-title.utils.js";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
export default function ShopInfoSpeedPrint() {
const { t } = useTranslation();
const form = Form.useFormInstance();
const allTemplates = TemplateList("job");
const TemplateListGenerated = InstanceRenderManager({
imex: Object.fromEntries(Object.entries(allTemplates).filter(([, { enhanced_payroll }]) => !enhanced_payroll)),
@@ -18,80 +30,131 @@ export default function ShopInfoSpeedPrint() {
<Form.List name={["speedprint"]}>
{(fields, { add, remove, move }) => {
return (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
<LayoutFormRow grow>
<Form.Item
label={t("bodyshop.fields.speedprint.id")}
key={`${index}id`}
name={[field.name, "id"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.speedprint.label")}
key={`${index}label`}
name={[field.name, "label"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
name={[field.name, "templates"]}
label={t("bodyshop.fields.speedprint.templates")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select
mode="multiple"
options={Object.keys(TemplateListGenerated).map((key) => ({
value: TemplateListGenerated[key].key,
label: TemplateListGenerated[key].title
}))}
/>
</Form.Item>
<Space wrap>
<DeleteFilled
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows move={move} index={index} total={fields.length} />
</Space>
</LayoutFormRow>
</Form.Item>
))}
<Form.Item>
<LayoutFormRow
header={t("bodyshop.labels.speedprint_configurations")}
actions={[
<Button
type="dashed"
key="add-speedprint"
type="primary"
block
onClick={() => {
add();
}}
style={{ width: "100%" }}
>
{t("bodyshop.actions.addspeedprint")}
</Button>
</Form.Item>
</div>
]}
>
<div>
{fields.length === 0 ? (
<ConfigListEmptyState actionLabel={t("bodyshop.actions.addspeedprint")} />
) : (
fields.map((field, index) => {
return (
<Form.Item noStyle key={field.key}>
<InlineValidatedFormRow
form={form}
errorNames={[
["speedprint", field.name, "id"],
["speedprint", field.name, "label"]
]}
noDivider
title={
<div style={INLINE_TITLE_ROW_STYLE}>
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />
<div style={INLINE_TITLE_GROUP_STYLE}>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("bodyshop.fields.speedprint.id")}</div>
<Form.Item
noStyle
name={[field.name, "id"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input
size="small"
placeholder={t("bodyshop.fields.speedprint.id")}
style={{
...INLINE_TITLE_INPUT_STYLE,
width: "100%"
}}
/>
</Form.Item>
</div>
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
<div style={INLINE_TITLE_GROUP_STYLE}>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("bodyshop.fields.speedprint.label")}</div>
<Form.Item
noStyle
name={[field.name, "label"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input
size="small"
placeholder={t("bodyshop.fields.speedprint.label")}
style={{
...INLINE_TITLE_INPUT_STYLE,
width: "100%"
}}
/>
</Form.Item>
</div>
</div>
}
wrapTitle
extra={
<Space align="center" size="small">
<Button
type="text"
danger
icon={<DeleteFilled />}
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
>
<Form.Item
name={[field.name, "templates"]}
label={t("bodyshop.fields.speedprint.templates")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select
mode="multiple"
options={Object.keys(TemplateListGenerated).map((key) => ({
value: TemplateListGenerated[key].key,
label: TemplateListGenerated[key].title
}))}
/>
</Form.Item>
</InlineValidatedFormRow>
</Form.Item>
);
})
)}
</div>
</LayoutFormRow>
);
}}
</Form.List>

View File

@@ -2,6 +2,8 @@ import { DeleteFilled } from "@ant-design/icons";
import { Button, Checkbox, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
import { useTranslation } from "react-i18next";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { connect } from "react-redux";
@@ -55,10 +57,12 @@ const getTaskPresetAllocationErrors = (presets = [], t) => {
export function ShopInfoTaskPresets({ bodyshop }) {
const { t } = useTranslation();
const form = Form.useFormInstance();
const taskPresets = Form.useWatch(["md_tasks_presets", "presets"], form) || [];
return (
<>
<LayoutFormRow noDivider>
<LayoutFormRow header={t("bodyshop.labels.task_preset_options")}>
<Form.Item
label={t("bodyshop.fields.md_tasks_presets.enable_tasks")}
valuePropName="checked"
@@ -75,187 +79,216 @@ export function ShopInfoTaskPresets({ bodyshop }) {
</Form.Item>
</LayoutFormRow>
<LayoutFormRow header={t("bodyshop.labels.md_tasks_presets")}>
<Form.List
name={["md_tasks_presets", "presets"]}
rules={[
{
validator: async (_, presets) => {
const allocationErrors = getTaskPresetAllocationErrors(presets, t);
<Form.List
name={["md_tasks_presets", "presets"]}
rules={[
{
validator: async (_, presets) => {
const allocationErrors = getTaskPresetAllocationErrors(presets, t);
if (allocationErrors.length > 0) {
throw new Error(allocationErrors.join(" "));
}
if (allocationErrors.length > 0) {
throw new Error(allocationErrors.join(" "));
}
}
]}
>
{(fields, { add, remove, move }, { errors }) => {
return (
}
]}
>
{(fields, { add, remove, move }, { errors }) => {
return (
<LayoutFormRow
header={t("bodyshop.labels.md_tasks_presets")}
actions={[
<Button
key="add-task-preset"
type="primary"
block
onClick={() => {
add();
}}
>
{t("bodyshop.actions.add_task_preset")}
</Button>
]}
>
<div>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<LayoutFormRow noDivider>
<Form.Item
label={t("bodyshop.fields.md_tasks_presets.name")}
key={`${index}name`}
name={[field.name, "name"]}
rules={[
{
required: true
//message: t("general.validation.required"),
{fields.length === 0 ? (
<ConfigListEmptyState actionLabel={t("bodyshop.actions.add_task_preset")} />
) : (
fields.map((field, index) => {
const taskPreset = taskPresets[field.name] || {};
return (
<Form.Item key={field.key}>
<LayoutFormRow
noDivider
title={getFormListItemTitle(
t("bodyshop.fields.md_tasks_presets.name"),
index,
taskPreset.name,
taskPreset.memo
)}
extra={
<Space align="center" size="small">
<Button
type="text"
danger
icon={<DeleteFilled />}
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
]}
>
<Input />
>
<Form.Item
label={t("bodyshop.fields.md_tasks_presets.name")}
key={`${index}name`}
name={[field.name, "name"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
span={12}
label={t("bodyshop.fields.md_tasks_presets.hourstype")}
key={`${index}hourstype`}
name={[field.name, "hourstype"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Checkbox.Group>
<Row>
<Col span={4}>
<Checkbox value="LAA" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LAA")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LAB" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LAB")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LAD" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LAD")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LAE" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LAE")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LAF" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LAF")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LAG" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LAG")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LAM" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LAM")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LAR" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LAR")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LAS" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LAS")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LAU" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LAU")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LA1" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LA1")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LA2" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LA2")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LA3" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LA3")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LA4" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LA4")}
</Checkbox>
</Col>
</Row>
</Checkbox.Group>
</Form.Item>
<Form.Item
label={t("bodyshop.fields.md_tasks_presets.percent")}
key={`${index}percent`}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={[field.name, "percent"]}
>
<InputNumber min={0} max={100} suffix="%" />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.md_tasks_presets.memo")}
key={`${index}memo`}
name={[field.name, "memo"]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.md_tasks_presets.nextstatus")}
key={`${index}nextstatus`}
name={[field.name, "nextstatus"]}
>
<Select
options={bodyshop.md_ro_statuses.production_statuses.map((o) => ({
value: o,
label: o
}))}
/>
</Form.Item>
</LayoutFormRow>
</Form.Item>
<Form.Item
span={12}
label={t("bodyshop.fields.md_tasks_presets.hourstype")}
key={`${index}hourstype`}
name={[field.name, "hourstype"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Checkbox.Group>
<Row>
<Col span={4}>
<Checkbox value="LAA" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LAA")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LAB" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LAB")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LAD" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LAD")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LAE" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LAE")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LAF" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LAF")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LAG" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LAG")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LAM" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LAM")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LAR" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LAR")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LAS" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LAS")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LAU" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LAU")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LA1" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LA1")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LA2" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LA2")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LA3" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LA3")}
</Checkbox>
</Col>
<Col span={4}>
<Checkbox value="LA4" style={{ lineHeight: "32px" }}>
{t("joblines.fields.lbr_types.LA4")}
</Checkbox>
</Col>
</Row>
</Checkbox.Group>
</Form.Item>
<Form.Item
label={t("bodyshop.fields.md_tasks_presets.percent")}
key={`${index}percent`}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={[field.name, "percent"]}
>
<InputNumber min={0} max={100} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.md_tasks_presets.memo")}
key={`${index}memo`}
name={[field.name, "memo"]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.md_tasks_presets.nextstatus")}
key={`${index}nextstatus`}
name={[field.name, "nextstatus"]}
>
<Select
options={bodyshop.md_ro_statuses.production_statuses.map((o) => ({
value: o,
label: o
}))}
/>
</Form.Item>
<Space wrap>
<DeleteFilled
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows move={move} index={index} total={fields.length} />
</Space>
</LayoutFormRow>
</Form.Item>
))}
);
})
)}
<Form.ErrorList errors={errors} />
<Form.Item>
<Button
type="dashed"
onClick={() => {
add();
}}
style={{ width: "100%" }}
>
{t("bodyshop.actions.add_task_preset")}
</Button>
</Form.Item>
</div>
);
}}
</Form.List>
</LayoutFormRow>
</LayoutFormRow>
);
}}
</Form.List>
</>
);
}

View File

@@ -5,6 +5,7 @@ import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -17,19 +18,22 @@ export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoIntellipay);
// noinspection JSUnusedLocalSymbols
export function ShopInfoIntellipay({ bodyshop, form }) {
const { t } = useTranslation();
const cashDiscountEnabled = Form.useWatch(["intellipay_config", "enable_cash_discount"], form);
return (
<>
<Form.Item dependencies={[["intellipay_config", "enable_cash_discount"]]}>
{() => {
const { intellipay_config } = form.getFieldsValue();
{cashDiscountEnabled && (
<div style={{ marginBottom: 12 }}>
<Alert title={t("bodyshop.labels.intellipay_cash_discount")} />
</div>
)}
if (intellipay_config?.enable_cash_discount)
return <Alert title={t("bodyshop.labels.intellipay_cash_discount")} />;
}}
</Form.Item>
<LayoutFormRow noDivider>
<LayoutFormRow
header={InstanceRenderManager({
rome: t("bodyshop.labels.romepay"),
imex: t("bodyshop.labels.imexpay")
})}
>
<Form.Item
label={t("bodyshop.fields.intellipay_config.enable_cash_discount")}
valuePropName="checked"

View File

@@ -1,23 +1,9 @@
import { DeleteFilled } from "@ant-design/icons";
import { useMutation, useQuery } from "@apollo/client/react";
import {
Button,
Card,
Col,
Form,
Input,
InputNumber,
Row,
Select,
Skeleton,
Space,
Switch,
Tag,
Typography
} from "antd";
import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Skeleton, Space, Switch, Typography } from "antd";
import querystring from "query-string";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
@@ -25,9 +11,22 @@ import { createStructuredSelector } from "reselect";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component";
import FormsFieldChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import {
INLINE_TITLE_GROUP_STYLE,
INLINE_TITLE_HANDLE_STYLE,
INLINE_TITLE_INPUT_STYLE,
INLINE_TITLE_LABEL_STYLE,
INLINE_TITLE_ROW_STYLE,
INLINE_TITLE_SEPARATOR_STYLE,
INLINE_TITLE_SWITCH_GROUP_STYLE,
InlineTitleListIcon
} from "../layout-form-row/inline-form-row-title.utils.js";
import {
INSERT_EMPLOYEE_TEAM,
@@ -37,11 +36,10 @@ import {
import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import {
LABOR_TYPES,
getSplitTotal,
hasExactSplitTotal,
LABOR_TYPES,
normalizeEmployeeTeam,
normalizeTeamMember,
validateEmployeeTeamMembers
} from "./shop-employee-teams.form.utils.js";
@@ -55,24 +53,8 @@ const PAYOUT_METHOD_OPTIONS = [
{ labelKey: "employee_teams.options.commission_percentage", value: "commission" }
];
const TEAM_MEMBER_PRIMARY_FIELD_COLS = {
employee: { xs: 24, lg: 13, xxl: 14 },
allocation: { xs: 24, sm: 12, lg: 4, xxl: 4 },
payoutMethod: { xs: 24, sm: 12, lg: 7, xxl: 6 }
};
const TEAM_MEMBER_RATE_FIELD_COLS = { xs: 24, sm: 12, md: 8, lg: 6, xxl: 4 };
const getPayoutMethodTagColor = (payoutMethod) => (payoutMethod === "commission" ? "gold" : "blue");
const getEmployeeDisplayName = (employees = [], employeeId) => {
const employee = employees.find((currentEmployee) => currentEmployee.id === employeeId);
if (!employee) return null;
const fullName = [employee.first_name, employee.last_name].filter(Boolean).join(" ").trim();
return fullName || employee.employee_number || null;
};
const formatAllocationPercentage = (percentage) => {
if (percentage === null || percentage === undefined || percentage === "") return null;
@@ -82,16 +64,19 @@ const formatAllocationPercentage = (percentage) => {
return `${numericValue.toFixed(2).replace(/\.?0+$/, "")}%`;
};
export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
export function ShopEmployeeTeamsFormComponent({ bodyshop, form, onDirtyChange, isDirty }) {
const { t } = useTranslation();
const [form] = Form.useForm();
const [internalForm] = Form.useForm();
const [internalIsDirty, setInternalIsDirty] = useState(false);
const teamForm = form ?? internalForm;
const resolvedIsDirty = typeof isDirty === "boolean" ? isDirty : internalIsDirty;
const history = useNavigate();
const search = querystring.parse(useLocation().search);
const notification = useNotification();
const [hydratedTeamId, setHydratedTeamId] = useState(search.employeeTeamId === "new" ? "new" : null);
const isNewTeam = search.employeeTeamId === "new";
const { error, data, loading } = useQuery(QUERY_EMPLOYEE_TEAM_BY_ID, {
const { error, data, loading, refetch } = useQuery(QUERY_EMPLOYEE_TEAM_BY_ID, {
variables: { id: search.employeeTeamId },
skip: !search.employeeTeamId || isNewTeam,
fetchPolicy: "network-only",
@@ -99,29 +84,68 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
notifyOnNetworkStatusChange: true
});
useEffect(() => {
if (!search.employeeTeamId) return;
const currentTeamData = data?.employee_teams_by_pk?.id === search.employeeTeamId ? data.employee_teams_by_pk : null;
const updateDirtyState = useCallback(
(nextDirtyState) => {
setInternalIsDirty(nextDirtyState);
onDirtyChange?.(nextDirtyState);
},
[onDirtyChange]
);
const clearTeamFormMeta = useCallback(() => {
const fieldMeta = teamForm.getFieldsError().map(({ name }) => ({
name,
touched: false,
validating: false,
errors: [],
warnings: []
}));
if (fieldMeta.length > 0) {
teamForm.setFields(fieldMeta);
}
updateDirtyState(false);
}, [teamForm, updateDirtyState]);
const resetTeamFormToCurrentData = useCallback(() => {
let hydrationFrameId;
teamForm.resetFields();
if (isNewTeam) {
form.resetFields();
setHydratedTeamId("new");
return;
hydrationFrameId = window.requestAnimationFrame(() => {
clearTeamFormMeta();
});
return () => {
if (hydrationFrameId) window.cancelAnimationFrame(hydrationFrameId);
};
}
setHydratedTeamId(null);
}, [form, isNewTeam, search.employeeTeamId]);
useEffect(() => {
if (!search.employeeTeamId || isNewTeam || loading) return;
if (data?.employee_teams_by_pk?.id === search.employeeTeamId) {
form.setFieldsValue(normalizeEmployeeTeam(data.employee_teams_by_pk));
setHydratedTeamId(search.employeeTeamId);
} else {
form.resetFields();
setHydratedTeamId(search.employeeTeamId);
if (loading) {
return undefined;
}
}, [data, form, isNewTeam, loading, search.employeeTeamId]);
if (currentTeamData) {
teamForm.setFieldsValue(normalizeEmployeeTeam(currentTeamData));
}
hydrationFrameId = window.requestAnimationFrame(() => {
setHydratedTeamId(search.employeeTeamId);
clearTeamFormMeta();
});
return () => {
if (hydrationFrameId) window.cancelAnimationFrame(hydrationFrameId);
};
}, [clearTeamFormMeta, currentTeamData, isNewTeam, loading, search.employeeTeamId, teamForm]);
useEffect(() => resetTeamFormToCurrentData(), [resetTeamFormToCurrentData]);
const [updateEmployeeTeam] = useMutation(UPDATE_EMPLOYEE_TEAM);
const [insertEmployeeTeam] = useMutation(INSERT_EMPLOYEE_TEAM);
@@ -129,34 +153,25 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
label: t(labelKey),
value
}));
const teamName = Form.useWatch("name", form);
const teamMembers = Form.useWatch(["employee_team_members"], form) || [];
const teamName = Form.useWatch("name", teamForm);
const teamMembers = Form.useWatch(["employee_team_members"], teamForm) || [];
const isTeamHydrating = !isNewTeam && Boolean(search.employeeTeamId) && hydratedTeamId !== search.employeeTeamId;
const teamCardTitle = isTeamHydrating
? t("employee_teams.fields.name")
: teamName?.trim() || t("employee_teams.fields.name");
const getTeamMemberTitle = (teamMember = {}) => {
const employeeName =
getEmployeeDisplayName(bodyshop.employees, teamMember.employeeid) || t("employee_teams.fields.employeeid");
const allocation = formatAllocationPercentage(teamMember.percentage);
const payoutMethod =
teamMember.payout_method === "commission"
? t("employee_teams.options.commission")
: t("employee_teams.options.hourly");
return (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
<Typography.Text strong>{employeeName}</Typography.Text>
<Tag variant="filled" color="geekblue">
{`${t("employee_teams.fields.allocation")}: ${allocation || "--"}`}
</Tag>
<Tag variant="filled" color={getPayoutMethodTagColor(teamMember.payout_method)}>
{payoutMethod}
</Tag>
</div>
);
};
const isAllocationTotalExact = hasExactSplitTotal(teamMembers);
const allocationTotalValue = formatAllocationPercentage(getSplitTotal(teamMembers))?.replace("%", "") || "0";
const teamNameDisplay = teamName?.trim() || t("employee_teams.fields.name");
const teamCardTitle = isTeamHydrating ? (
t("employee_teams.fields.name")
) : (
<span>
<span>{teamNameDisplay}</span>
<span> - </span>
<Typography.Text type={isAllocationTotalExact ? undefined : "danger"}>
{t("employee_teams.labels.allocation_total", {
total: allocationTotalValue
})}
</Typography.Text>
</span>
);
const handleFinish = async ({ employee_team_members = [], ...values }) => {
const { normalizedTeamMembers, errorKey } = validateEmployeeTeamMembers(employee_team_members);
@@ -193,6 +208,8 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
});
if (!result.errors) {
updateDirtyState(false);
void refetch();
notification.success({
title: t("employees.successes.save")
});
@@ -216,6 +233,7 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
},
refetchQueries: ["QUERY_TEAMS"]
}).then((response) => {
updateDirtyState(false);
search.employeeTeamId = response.data.insert_employee_teams_one.id;
history({ search: querystring.stringify(search) });
notification.success({
@@ -230,18 +248,66 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
return (
<Card
title={teamCardTitle}
title={isTeamHydrating ? undefined : teamCardTitle}
extra={
<Button type="primary" onClick={() => form.submit()} disabled={isTeamHydrating}>
{t("general.actions.save")}
<Button
type="primary"
onClick={() => teamForm.submit()}
disabled={isTeamHydrating || !resolvedIsDirty}
style={{ minWidth: 190 }}
>
{t("employee_teams.actions.save_team")}
</Button>
}
>
{isTeamHydrating ? (
<Skeleton active title={false} paragraph={{ rows: 12 }} />
) : (
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
<LayoutFormRow>
<Form
onFinish={handleFinish}
autoComplete={"off"}
layout="vertical"
form={teamForm}
onValuesChange={() => {
updateDirtyState(teamForm.isFieldsTouched());
}}
>
<FormsFieldChanged form={teamForm} onReset={resetTeamFormToCurrentData} onDirtyChange={updateDirtyState} />
<LayoutFormRow
title={
<div
style={{
...INLINE_TITLE_ROW_STYLE,
justifyContent: "space-between"
}}
>
<div
style={{
whiteSpace: "nowrap",
fontWeight: 500,
fontSize: "var(--ant-font-size-lg)",
lineHeight: 1.2,
marginRight: "auto"
}}
>
{t("employee_teams.labels.team_options")}
</div>
<div
style={{
...INLINE_TITLE_SWITCH_GROUP_STYLE,
marginLeft: "auto"
}}
>
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employee_teams.fields.active")}</div>
<Form.Item noStyle name="active" valuePropName="checked">
<Switch />
</Form.Item>
</div>
</div>
}
wrapTitle
>
<Form.Item
name="name"
label={t("employee_teams.fields.name")}
@@ -253,9 +319,6 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
>
<Input />
</Form.Item>
<Form.Item label={t("employee_teams.fields.active")} name="active" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
label={t("employee_teams.fields.max_load")}
name="max_load"
@@ -265,128 +328,19 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
}
]}
>
<InputNumber min={0} precision={1} />
<InputNumber min={0} precision={1} suffix="%" />
</Form.Item>
</LayoutFormRow>
<Form.List name={["employee_team_members"]}>
{(fields, { add, remove, move }) => {
return (
<div>
{fields.map((field, index) => {
const teamMember = normalizeTeamMember(teamMembers[field.name]);
return (
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
<Form.Item label={t("employees.fields.id")} key={`${index}`} name={[field.name, "id"]} hidden>
<Input type="hidden" />
</Form.Item>
<LayoutFormRow
grow
title={getTeamMemberTitle(teamMember)}
extra={
<Space align="center" size="small">
<Button
type="text"
icon={<DeleteFilled />}
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
>
<div>
<Row gutter={[16, 0]}>
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.employee}>
<Form.Item
label={t("employee_teams.fields.employeeid")}
key={`${index}`}
name={[field.name, "employeeid"]}
rules={[
{
required: true
}
]}
>
<EmployeeSearchSelectComponent options={bodyshop.employees} />
</Form.Item>
</Col>
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.allocation}>
<Form.Item
label={t("employee_teams.fields.allocation_percentage")}
key={`${index}`}
name={[field.name, "percentage"]}
rules={[
{
required: true
}
]}
>
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
</Col>
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.payoutMethod}>
<Form.Item
label={t("employee_teams.fields.payout_method")}
key={`${index}-payout-method`}
name={[field.name, "payout_method"]}
initialValue="hourly"
rules={[
{
required: true
}
]}
>
<Select options={payoutMethodOptions} />
</Form.Item>
</Col>
</Row>
<Form.Item noStyle dependencies={[["employee_team_members", field.name, "payout_method"]]}>
{() => {
const payoutMethod =
form.getFieldValue(["employee_team_members", field.name, "payout_method"]) ||
"hourly";
const fieldName = payoutMethod === "commission" ? "commission_rates" : "labor_rates";
return (
<Row gutter={[16, 0]}>
{LABOR_TYPES.map((laborType) => (
<Col {...TEAM_MEMBER_RATE_FIELD_COLS} key={`${index}-${fieldName}-${laborType}`}>
<Form.Item
label={t(`joblines.fields.lbr_types.${laborType}`)}
name={[field.name, fieldName, laborType]}
rules={[
{
required: true
}
]}
>
{payoutMethod === "commission" ? (
<InputNumber min={0} max={100} precision={2} />
) : (
<CurrencyInput />
)}
</Form.Item>
</Col>
))}
</Row>
);
}}
</Form.Item>
</div>
</LayoutFormRow>
</Form.Item>
);
})}
<Form.Item>
<LayoutFormRow
title={t("employee_teams.labels.members")}
actions={[
<Button
type="dashed"
key="add-team-member"
type="primary"
block
onClick={() => {
add({
percentage: 0,
@@ -395,26 +349,166 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
commission_rates: {}
});
}}
style={{ width: "100%" }}
>
{t("employee_teams.actions.newmember")}
</Button>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{() => {
const teamMembers = form.getFieldValue(["employee_team_members"]) || [];
const splitTotal = getSplitTotal(teamMembers);
]}
>
<div>
{fields.length === 0 ? (
<ConfigListEmptyState actionLabel={t("employee_teams.actions.newmember")} />
) : (
fields.map((field, index) => {
return (
<Form.Item noStyle key={field.key}>
<Form.Item name={[field.name, "id"]} hidden>
<Input type="hidden" />
</Form.Item>
<InlineValidatedFormRow
form={teamForm}
errorNames={[
["employee_team_members", field.name, "employeeid"],
["employee_team_members", field.name, "percentage"],
["employee_team_members", field.name, "payout_method"]
]}
grow
title={
<div style={INLINE_TITLE_ROW_STYLE}>
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />
<div style={INLINE_TITLE_GROUP_STYLE}>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employee_teams.fields.employeeid")}</div>
<Form.Item
noStyle
name={[field.name, "employeeid"]}
rules={[
{
required: true
}
]}
>
<EmployeeSearchSelectComponent options={bodyshop.employees} />
</Form.Item>
</div>
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
<div style={INLINE_TITLE_GROUP_STYLE}>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employee_teams.fields.allocation")}</div>
<Form.Item
noStyle
name={[field.name, "percentage"]}
rules={[
{
required: true
}
]}
>
<InputNumber
min={0}
max={100}
precision={2}
size="small"
aria-label={t("employee_teams.fields.allocation")}
suffix="%"
style={{
...INLINE_TITLE_INPUT_STYLE,
width: "100%"
}}
/>
</Form.Item>
</div>
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
<div style={INLINE_TITLE_GROUP_STYLE}>
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employee_teams.fields.payout_method")}</div>
<Form.Item
noStyle
key={`${index}-payout-method`}
name={[field.name, "payout_method"]}
initialValue="hourly"
rules={[
{
required: true
}
]}
>
<Select
aria-label={t("employee_teams.fields.payout_method")}
size="small"
options={payoutMethodOptions}
style={{ width: "100%" }}
styles={{
selector: INLINE_TITLE_INPUT_STYLE
}}
/>
</Form.Item>
</div>
</div>
}
wrapTitle
extra={
<Space align="center" size="small">
<Button
type="text"
danger
icon={<DeleteFilled />}
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
>
<div>
<Form.Item
noStyle
dependencies={[["employee_team_members", field.name, "payout_method"]]}
>
{() => {
const payoutMethod =
teamForm.getFieldValue(["employee_team_members", field.name, "payout_method"]) ||
"hourly";
const fieldName = payoutMethod === "commission" ? "commission_rates" : "labor_rates";
return (
<Typography.Text type={hasExactSplitTotal(teamMembers) ? undefined : "danger"}>
{t("employee_teams.labels.allocation_total", {
total: splitTotal.toFixed(2)
})}
</Typography.Text>
);
}}
</Form.Item>
</div>
return (
<Row gutter={[16, 0]}>
{LABOR_TYPES.map((laborType) => (
<Col
{...TEAM_MEMBER_RATE_FIELD_COLS}
key={`${index}-${fieldName}-${laborType}`}
>
<Form.Item
label={t(`joblines.fields.lbr_types.${laborType}`)}
name={[field.name, fieldName, laborType]}
rules={[
{
required: true
}
]}
>
{payoutMethod === "commission" ? (
<InputNumber min={0} max={100} precision={2} suffix="%" />
) : (
<CurrencyInput prefix="$" />
)}
</Form.Item>
</Col>
))}
</Row>
);
}}
</Form.Item>
</div>
</InlineValidatedFormRow>
</Form.Item>
);
})
)}
</div>
</LayoutFormRow>
);
}}
</Form.List>

View File

@@ -42,9 +42,11 @@ vi.mock("react-i18next", () => ({
"employee_teams.options.commission": "Commission",
"employee_teams.options.commission_percentage": "Commission",
"employee_teams.actions.newmember": "New Team Member",
"employee_teams.actions.save_team": "Save Employee Team",
"employee_teams.errors.minimum_one_member": "Add at least one team member.",
"employee_teams.errors.duplicate_member": "Team members must be unique.",
"employee_teams.errors.allocation_total_exact": "Allocation must total exactly 100%.",
"general.labels.click_to_begin": `Click ${values.action ?? ""} to begin`,
"general.actions.save": "Save",
"employees.successes.save": "Saved"
};
@@ -66,6 +68,10 @@ vi.mock("../../contexts/Notifications/notificationContext.jsx", () => ({
useNotification: () => notification
}));
vi.mock("../form-fields-changed-alert/form-fields-changed-alert.component.jsx", () => ({
default: () => null
}));
vi.mock("../../firebase/firebase.utils", () => ({
logImEXEvent: vi.fn()
}));
@@ -101,11 +107,12 @@ vi.mock("../form-items-formatted/currency-form-item.component", () => ({
}));
vi.mock("../layout-form-row/layout-form-row.component", () => ({
default: ({ title, extra, children }) => (
default: ({ title, extra, actions, children }) => (
<div>
{title}
{extra}
{children}
{actions}
</div>
)
}));
@@ -144,7 +151,7 @@ const addBaseTeamMember = ({ employeeId = "emp-1", percentage = 100, rate = 25 }
fireEvent.change(screen.getByLabelText("Employee"), {
target: { value: employeeId }
});
fireEvent.change(screen.getByRole("spinbutton", { name: "Allocation %" }), {
fireEvent.change(screen.getByRole("spinbutton", { name: "Allocation" }), {
target: { value: String(percentage) }
});
fillHourlyRates(rate);
@@ -211,7 +218,7 @@ describe("ShopEmployeeTeamsFormComponent", () => {
rate: 27.5
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
fireEvent.click(screen.getByRole("button", { name: "Save Employee Team" }));
await waitFor(() => {
expect(insertEmployeeTeamMock).toHaveBeenCalledWith({

View File

@@ -2,20 +2,47 @@ import { Button } from "antd";
import queryString from "query-string";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import ResponsiveTable from "../responsive-table/responsive-table.component";
export default function ShopEmployeeTeamsListComponent({ loading, employee_teams }) {
export default function ShopEmployeeTeamsListComponent({
loading,
employee_teams,
onRequestTeamChange,
selectedTeamId
}) {
const { t } = useTranslation();
const history = useNavigate();
const search = queryString.parse(useLocation().search);
const navigateToTeam = (employeeTeamId) => {
if (onRequestTeamChange) {
onRequestTeamChange(employeeTeamId);
return;
}
history({
search: queryString.stringify({
...search,
employeeTeamId
})
});
};
const clearTeamSelection = () => {
const { employeeTeamId, ...nextSearch } = search;
void employeeTeamId;
history({
search: queryString.stringify(nextSearch)
});
};
const handleOnRowClick = (record) => {
if (record) {
search.employeeTeamId = record.id;
history({ search: queryString.stringify(search) });
navigateToTeam(record.id);
} else {
delete search.employeeTeamId;
history({ search: queryString.stringify(search) });
clearTeamSelection();
}
};
const columns = [
@@ -27,43 +54,38 @@ export default function ShopEmployeeTeamsListComponent({ loading, employee_teams
];
return (
<div>
<ResponsiveTable
title={() => {
return (
<Button
type="primary"
onClick={() => {
search.employeeTeamId = "new";
history({ search: queryString.stringify(search) });
}}
>
{t("employee_teams.actions.new")}
</Button>
);
}}
loading={loading}
pagination={{ placement: "top" }}
columns={columns}
mobileColumnKeys={["name"]}
rowKey="id"
dataSource={employee_teams}
rowSelection={{
onSelect: (props) => {
search.employeeTeamId = props.id;
history({ search: queryString.stringify(search) });
},
type: "radio",
selectedRowKeys: [search.employeeTeamId]
}}
onRow={(record) => {
return {
onClick: () => {
handleOnRowClick(record);
}
};
}}
/>
</div>
<LayoutFormRow
title={t("bodyshop.labels.employee_teams")}
actions={[
<Button key="new-team" type="primary" block onClick={() => navigateToTeam("new")}>
{t("employee_teams.actions.new")}
</Button>
]}
>
{employee_teams.length === 0 ? (
<ConfigListEmptyState actionLabel={t("employee_teams.actions.new")} />
) : (
<ResponsiveTable
loading={loading}
pagination={{ placement: "top" }}
columns={columns}
mobileColumnKeys={["name"]}
rowKey="id"
dataSource={employee_teams}
rowSelection={{
onSelect: (props) => navigateToTeam(props.id),
type: "radio",
selectedRowKeys: [selectedTeamId || search.employeeTeamId]
}}
onRow={(record) => {
return {
onClick: () => {
handleOnRowClick(record);
}
};
}}
/>
)}
</LayoutFormRow>
);
}

View File

@@ -1,36 +1,70 @@
import { Form } from "antd";
import { useQuery } from "@apollo/client/react";
import queryString from "query-string";
import { connect } from "react-redux";
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { QUERY_TEAMS } from "../../graphql/employee_teams.queries";
import useConfirmDirtyFormNavigation from "../../hooks/useConfirmDirtyFormNavigation.jsx";
import AlertComponent from "../alert/alert.component";
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
import ShopEmployeeTeamsListComponent from "./shop-employee-teams.list";
import ShopEmployeeTeamsFormComponent from "./shop-employee-teams.form.component";
import { Col, Row } from "antd";
import "./shop-teams.styles.scss";
const mapStateToProps = createStructuredSelector({});
function ShopTeamsContainer() {
const [form] = Form.useForm();
const [isTeamFormDirty, setIsTeamFormDirty] = useState(false);
const navigate = useNavigate();
const search = queryString.parse(useLocation().search);
const { loading, error, data } = useQuery(QUERY_TEAMS, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
const hasSelectedTeam = Boolean(search.employeeTeamId);
const hasDirtyTeamForm = Boolean(search.employeeTeamId) && isTeamFormDirty;
const confirmCloseDirtyTeam = useConfirmDirtyFormNavigation(hasDirtyTeamForm);
const navigateToTeam = (employeeTeamId) => {
if (employeeTeamId === search.employeeTeamId) return;
if (!confirmCloseDirtyTeam()) return;
setIsTeamFormDirty(false);
navigate({
search: queryString.stringify({
...search,
employeeTeamId
})
});
};
if (error) return <AlertComponent title={error.message} type="error" />;
return (
<div>
<RbacWrapper action="employee_teams:page">
<Row gutter={[16, 16]}>
<Col span={6}>
<ShopEmployeeTeamsListComponent employee_teams={data ? data.employee_teams : []} loading={loading} />
</Col>
<Col span={18}>
<ShopEmployeeTeamsFormComponent />
</Col>
</Row>
</RbacWrapper>
</div>
<RbacWrapper action="employee_teams:page">
<div
className={["shop-teams-layout", hasSelectedTeam ? "shop-teams-layout--with-detail" : null]
.filter(Boolean)
.join(" ")}
>
<div className="shop-teams-layout__list">
<ShopEmployeeTeamsListComponent
employee_teams={data ? data.employee_teams : []}
loading={loading}
onRequestTeamChange={navigateToTeam}
selectedTeamId={search.employeeTeamId}
/>
</div>
{hasSelectedTeam ? (
<div className="shop-teams-layout__details">
<ShopEmployeeTeamsFormComponent form={form} onDirtyChange={setIsTeamFormDirty} isDirty={isTeamFormDirty} />
</div>
) : null}
</div>
</RbacWrapper>
);
}

View File

@@ -0,0 +1,16 @@
.shop-teams-layout {
display: grid;
gap: 16px;
align-items: start;
}
.shop-teams-layout__list,
.shop-teams-layout__details {
min-width: 0;
}
@media (min-width: 1700px) {
.shop-teams-layout--with-detail {
grid-template-columns: minmax(420px, 500px) minmax(0, 1fr);
}
}

View File

@@ -6,6 +6,7 @@ import { createStructuredSelector } from "reselect";
import { QUERY_SHOP_ASSOCIATIONS } from "../../graphql/user.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import ShopUsersAuthEdit from "../shop-users-auth-edit/shop-users-auth-edit.component";
@@ -66,7 +67,7 @@ export function ShopInfoUsersComponent({ bodyshop }) {
return <AlertComponent type="error" title={JSON.stringify(error)} />;
}
return (
<div>
<LayoutFormRow title={t("bodyshop.labels.licensing")}>
<ResponsiveTable
loading={loading}
pagination={{ placement: "top" }}
@@ -75,6 +76,6 @@ export function ShopInfoUsersComponent({ bodyshop }) {
rowKey="id"
dataSource={data && data.associations}
/>
</div>
</LayoutFormRow>
);
}

View File

@@ -2,7 +2,7 @@ import { PageHeader } from "@ant-design/pro-layout";
import { useMutation, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Form, Modal, Space } from "antd";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -15,21 +15,27 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import dayjs from "../../utils/day";
import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time-tickets-commit-toggle.component";
import TimeTicketModalComponent from "./time-ticket-modal.component";
import { insertAuditTrail } from "../../redux/application/application.actions.js";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { buildTimeTicketAuditSummary } from "../../utils/auditTrailDetails.js";
const mapStateToProps = createStructuredSelector({
timeTicketModal: selectTimeTicket,
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("timeTicket"))
toggleModalVisible: () => dispatch(toggleModalVisible("timeTicket")),
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
});
export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, bodyshop }) {
export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, bodyshop, insertAuditTrail }) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const { t } = useTranslation();
const [enterAgain, setEnterAgain] = useState(false);
const lastSubmittedRef = useRef(null);
const [lineTicketRefreshKey, setLineTicketRefreshKey] = useState(0);
const [insertTicket] = useMutation(INSERT_NEW_TIME_TICKET);
@@ -48,47 +54,77 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
const employees = EmployeeAutoCompleteData?.employees ?? [];
const handleFinish = (values) => {
lastSubmittedRef.current = values;
setLoading(true);
const emps = EmployeeAutoCompleteData?.employees.filter((e) => e.id === values.employeeid);
if (timeTicketModal.context.id) {
updateTicket({
variables: {
timeticketId: timeTicketModal.context.id,
timeticket: {
...values,
rate: emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0]?.rate : null
}
}
})
.then(handleMutationSuccess)
.catch(handleMutationError);
} else {
//Get selected employee rate.
insertTicket({
variables: {
timeTicketInput: [
{
const isEdit = Boolean(timeTicketModal.context.id);
const emps = employees.filter((employee) => employee.id === values.employeeid);
const mutation = isEdit
? updateTicket({
variables: {
timeticketId: timeTicketModal.context.id,
timeticket: {
...values,
rate:
emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0].rate : null,
bodyshopid: bodyshop.id,
created_by: timeTicketModal.context.created_by
emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0]?.rate : null
}
]
}
})
.then(handleMutationSuccess)
.catch(handleMutationError);
}
}
})
: insertTicket({
variables: {
timeTicketInput: [
{
...values,
rate:
emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0].rate : null,
bodyshopid: bodyshop.id,
created_by: timeTicketModal.context.created_by
}
]
}
});
mutation.then((result) => handleMutationSuccess(result, isEdit)).catch(handleMutationError);
};
const handleMutationSuccess = () => {
const handleMutationSuccess = (result, isEdit) => {
notification.success({
title: t("timetickets.successes.created")
});
const savedTicket =
result?.data?.update_timetickets?.returning?.[0] ?? result?.data?.insert_timetickets?.returning?.[0] ?? {};
const originalTicket = timeTicketModal.context?.timeticket ?? {};
const submittedValues = {
...(lastSubmittedRef.current ?? {}),
date: lastSubmittedRef.current?.date ?? savedTicket.date ?? originalTicket.date ?? null,
employeeid: lastSubmittedRef.current?.employeeid ?? savedTicket.employeeid ?? originalTicket.employeeid ?? null,
jobid:
lastSubmittedRef.current?.jobid ??
savedTicket.jobid ??
timeTicketModal.context.jobId ??
originalTicket.job?.id ??
originalTicket.jobid ??
null
};
const auditSummary = buildTimeTicketAuditSummary({
originalTicket,
submittedValues,
employees
});
if (auditSummary.jobid) {
insertAuditTrail({
jobid: auditSummary.jobid,
operation: isEdit
? AuditTrailMapping.timeticketupdated(auditSummary.employeeName, auditSummary.date, auditSummary.details)
: AuditTrailMapping.timeticketcreated(auditSummary.employeeName, auditSummary.date, auditSummary.details),
type: isEdit ? "timeticketupdated" : "timeticketcreated"
});
}
// Refresh parent screens (Job Labor tab, etc.)
if (timeTicketModal.actions.refetch) timeTicketModal.actions.refetch();

View File

@@ -0,0 +1,11 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
export default function useConfirmDirtyFormNavigation(isDirty) {
const { t } = useTranslation();
return useCallback(() => {
if (!isDirty) return true;
return window.confirm(t("general.messages.unsavedchangespopup"));
}, [isDirty, t]);
}

View File

@@ -8,13 +8,14 @@ import { createStructuredSelector } from "reselect";
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
import { INSERT_NEW_JOB } from "../../graphql/jobs.queries";
import { QUERY_OWNER_FOR_JOB_CREATION } from "../../graphql/owners.queries";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import JobsCreateComponent from "./jobs-create.component";
import JobCreateContext from "./jobs-create.context";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { logImEXEvent } from "../../firebase/firebase.utils";
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -22,10 +23,11 @@ const mapStateToProps = createStructuredSelector({
});
const mapDispatchToProps = (dispatch) => ({
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
setSelectedHeader: (key) => dispatch(setSelectedHeader(key)),
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
});
function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, currentUser }) {
function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, currentUser, insertAuditTrail }) {
const { t } = useTranslation();
const notification = useNotification();
@@ -84,6 +86,11 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr
newJobId: resp.data.insert_jobs.returning[0].id
});
logImEXEvent("manual_job_create_completed", {});
insertAuditTrail({
jobid: resp.data.insert_jobs.returning[0].id,
operation: AuditTrailMapping.jobmanualcreate(),
type: "jobmanualcreate"
});
setIsSubmitting(false);
})
.catch((error) => {

View File

@@ -27,12 +27,19 @@ const mapDispatchToProps = (dispatch) => ({
});
export function TechAssignedProdJobs({ setTimeTicketTaskContext, technician, bodyshop }) {
const technicianId = technician?.id;
const teamIds = (bodyshop?.employee_teams || [])
.filter((employeeTeam) =>
employeeTeam?.employee_team_members?.some((teamMember) => teamMember?.employeeid === technicianId)
)
.map((employeeTeam) => employeeTeam.id)
.filter(Boolean);
const hasAssignedTeams = Boolean(technicianId) && teamIds.length > 0;
const { loading, error, data, refetch } = useQuery(QUERY_JOBS_TECH_ASIGNED_TO_BY_TEAM, {
variables: {
teamIds: bodyshop.employee_teams
.filter((et) => et.employee_team_members.find((etm) => etm.employeeid === technician.id))
.map((et) => et.id)
}
teamIds
},
skip: !technicianId || !hasAssignedTeams
});
const searchParams = queryString.parse(useLocation().search);
@@ -177,7 +184,7 @@ export function TechAssignedProdJobs({ setTimeTicketTaskContext, technician, bod
<Card
extra={
<Space wrap>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Button disabled={!hasAssignedTeams} onClick={() => refetch()} icon={<SyncOutlined />} />
<Input.Search
placeholder={t("general.labels.search")}
onChange={(e) => {

View File

@@ -122,7 +122,7 @@
"billdeleted": "Bill with invoice number {{invoice_number}} deleted.",
"billmarkforreexport": "Bill with invoice number {{invoice_number}} marked for re-export.",
"billposted": "Bill with invoice number {{invoice_number}} posted.",
"billupdated": "Bill with invoice number {{invoice_number}} updated.",
"billupdated": "Bill with invoice number {{invoice_number}} updated with the following details: {{details}}.",
"failedpayment": "Failed payment attempt.",
"jobassignmentchange": "Employee {{name}} assigned to {{operation}}",
"jobassignmentremoved": "Employee assignment removed for {{operation}}",
@@ -137,6 +137,9 @@
"jobintake": "Job intake completed. Status set to {{status}}. Scheduled completion is {{scheduled_completion}}.",
"jobinvoiced": "Job has been invoiced.",
"jobioucreated": "IOU Created.",
"joblineupdate": "Job line {{lineDescription}} updated with the following details: {{details}}.",
"jobmanualcreate": "Job manually created.",
"jobmanuallineinsert": "Job line manually added with the following details: {{details}}.",
"jobmodifylbradj": "Labor adjustments modified {{mod_lbr_ty}} / {{hours}}.",
"jobnoteadded": "Note added to Job.",
"jobnotedeleted": "Note deleted from Job.",
@@ -152,7 +155,9 @@
"tasks_deleted": "Task '{{title}}' deleted by {{deletedBy}}",
"tasks_uncompleted": "Task '{{title}}' uncompleted by {{uncompletedBy}}",
"tasks_undeleted": "Task '{{title}}' undeleted by {{undeletedBy}}",
"tasks_updated": "Task '{{title}}' updated by {{updatedBy}}"
"tasks_updated": "Task '{{title}}' updated by {{updatedBy}}",
"timeticketcreated": "Time Ticket for {{employee}} on {{date}} created with the following details: {{details}}.",
"timeticketupdated": "Time Ticket for {{employee}} on {{date}} updated with the following details: {{details}}"
}
},
"billlines": {
@@ -293,7 +298,23 @@
},
"bodyshop": {
"actions": {
"add_adjuster": "Add Adjuster",
"add_control_number": "Add Control Number",
"add_cost_center": "Add Cost Center",
"add_courtesy_car_rate_preset": "Add Courtesy Car Contract Rate Preset",
"add_delivery_checklist_item": "Add Delivery Checklist Item",
"add_dms_allocation": "Add DMS Allocation",
"add_estimator": "Add Estimator",
"add_insurance_company": "Add Insurance Company",
"add_intake_checklist_item": "Add Intake Checklist Item",
"add_jobline_preset": "Add Jobline Preset",
"add_messaging_preset": "Add Messaging Preset",
"add_note_preset": "Add Note Preset",
"add_parts_order_comment": "Add Parts Order Comment",
"add_production_status_color": "Add Production Status Color",
"add_profit_center": "Add Profit Center",
"add_task_preset": "Add Task Preset",
"add_to_email_preset": "Add To Email Preset",
"addapptcolor": "Add Appointment Color",
"addbucket": "Add Definition",
"addpartslocation": "Add Parts Location",
@@ -302,11 +323,13 @@
"addtemplate": "Add Template",
"newlaborrate": "New Labor Rate",
"newsalestaxcode": "New Sales Tax Code",
"save_shop_information": "Save Shop Information",
"newstatus": "Add Status",
"testrender": "Test Render"
},
"errors": {
"creatingdefaultview": "Error creating default view.",
"duplicate_job_status": "Duplicate job status. Each job status must be unique.",
"duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique",
"loading": "Unable to load shop details. Please call technical support.",
"saving": "Error encountered while saving. {{message}}",
@@ -404,6 +427,35 @@
"logo_img_path": "Shop Logo",
"logo_img_path_height": "Logo Image Height",
"logo_img_path_width": "Logo Image Width",
"scoreboard_setup": {
"daily_body_target": "Daily Body Target",
"daily_paint_target": "Daily Paint Target",
"ignore_blocked_days": "Ignore Blocked Days",
"last_number_working_days": "Last Number of Working Days",
"production_target_hours": "Production Target Hours"
},
"system_settings": {
"auto_email": {
"attach_pdf_to_email": "Attach PDF to Sent Emails?",
"from_emails": "Additional From Emails",
"parts_order_cc": "Parts Orders CC",
"parts_return_slip_cc": "Parts Returns CC"
},
"job_costing": {
"paint_hour_split": "Paint Hour Split",
"paint_materials_hourly_cost_rate": "Paint Materials Hourly Cost Rate",
"prep_hour_split": "Prep Hour Split",
"shop_materials_hourly_cost_rate": "Shop Materials Hourly Cost Rate",
"target_touch_time": "Target Touch Time",
"use_paint_scale_data": "Use Paint Scale Data"
},
"local_media_server": {
"enabled": "Enabled",
"http_path": "HTTP Path",
"network_path": "Network Path",
"token": "Token"
}
},
"md_categories": "Categories",
"md_ccc_rates": "Courtesy Car Contract Rate Presets",
"md_classes": "Classes",
@@ -464,9 +516,13 @@
"use_approvals": "Use Time Ticket Approval Queue"
},
"messaginglabel": "Messaging Preset Label",
"messaginglabel_short": "Label",
"messagingtext": "Messaging Preset Text",
"messagingtext_short": "Text",
"noteslabel": "Note Label",
"noteslabel_short": "Label",
"notestext": "Note Text",
"notestext_short": "Text",
"notifications": {
"description": "Select employees to automatically follow new jobs and receive notifications for job updates.",
"invalid_followers": "Invalid selection. Please select valid employees.",
@@ -600,12 +656,17 @@
"federal_tax_itc": "Federal Tax Credit",
"gogcode": "GOG Code (BreakOut)",
"gst_override": "GST Override Account #",
"invoice_federal_tax_rate_short": "Federal Tax Rate",
"invoice_local_tax_rate_short": "Local Tax Rate",
"invoice_state_tax_rate_short": "State Tax Rate",
"invoiceexemptcode": "QuickBooks US - Invoice Tax Exempt Code",
"invoiceexemptcode_short": "Invoice Tax Exempt Code",
"item_type": "Item Type",
"item_type_freight": "Freight",
"item_type_gog": "GOG",
"item_type_paint": "Paint Materials",
"itemexemptcode": "QuickBooks US - Line Item Tax Exempt Code",
"itemexemptcode_short": "Line Item Tax Exempt Code",
"la1": "LA1",
"la2": "LA2",
"la3": "LA3",
@@ -722,6 +783,7 @@
"customtemplates": "Custom Templates",
"defaultcostsmapping": "Default Costs Mapping",
"defaultprofitsmapping": "Default Profits Mapping",
"dms_setup": "DMS Setup",
"deliverchecklist": "Delivery Checklist",
"dms": {
"cdk": {
@@ -738,24 +800,33 @@
},
"emaillater": "Email Later",
"employee_teams": "Employee Teams",
"employee_options": "Employee Options",
"employee_rates": "Employee Rates",
"employee_vacation": "Employee Vacation",
"employees": "Employees",
"estimators": "Estimators",
"filehandlers": "Adjusters",
"imexpay": "ImEX Pay",
"insurancecos": "Insurance Companies",
"intake_delivery": "Intake / Delivery Options",
"intakechecklist": "Intake Checklist",
"intellipay_cash_discount": "Please ensure that cash discounting has been enabled on your merchant account. Reach out to IntelliPay Support if you need assistance. ",
"job_status_options": "Job Status Options",
"jobstatuses": "Job Statuses",
"laborrates": "Labor Rates",
"licensing": "Licensing",
"md_parts_scan": "Parts Scan Rules",
"md_ro_guard": "RO Guard",
"md_ro_guard_options": "RO Guard Options",
"md_tasks_presets": "Tasks Presets",
"task_preset_options": "Task Preset Options",
"md_to_emails": "Preset To Emails",
"md_to_emails_emails": "Emails",
"messagingpresets": "Messaging Presets",
"notification_options": "Notification Options",
"notemplatesavailable": "No templates available to add.",
"notespresets": "Notes Presets",
"jump_to_section": "Jump to section",
"notifications": {
"followers": "Notifications"
},
@@ -769,11 +840,22 @@
"qbo_departmentid": "QBO Department ID",
"qbo_usa": "QBO USA Compatibility",
"rbac": "Role Based Access Control",
"rbac_options": "Role Based Access Control Options",
"responsibilitycenters": {
"costs": "Cost Centers",
"default_tax_setup": "Default Tax Setup",
"invoices": "Invoices",
"profits": "Profit Centers",
"quickbooks_qbd": "QuickBooks / QBD",
"quickbooks_us": "QuickBooks US",
"sales_tax_codes": "Sales Tax Codes",
"tax_accounts": "Tax Accounts",
"tax_rate_short": "Rate",
"tax_surcharge_short": "Surcharge",
"tax_threshold_short": "Threshold",
"tax_tier_card": "Tier {{typeNumIterator}}",
"tax_tier_short": "Tier",
"tax_type_card": "Tax Type {{typeNum}}",
"title": "Responsibility Centers",
"ttl_adjustment": "Subtotal Adjustment Account",
"ttl_tax_adjustment": "Tax Adjustment Account"
@@ -781,6 +863,9 @@
"roguard": {
"title": "RO Guard"
},
"autoemail": "Auto Email",
"jobcosting": "Job Costing",
"localmediaserver": "Local Media Server",
"romepay": "Rome Pay",
"scheduling": "SMART Scheduling",
"scoreboardsetup": "Scoreboard Setup",
@@ -788,6 +873,7 @@
"shopinfo": "Shop Information",
"shoprates": "Shop Rates",
"speedprint": "Speed Print Configuration",
"speedprint_configurations": "Speed Print Configurations",
"ssbuckets": "Job Size Definitions",
"systemsettings": "System Settings",
"task-presets": "Task Presets",
@@ -811,7 +897,8 @@
"tooltips": {
"md_parts_scan": {
"update_value_tooltip": "Some fields require coded values in order to function properly (e.g. labor and part types). Please reach out to support if you have any questions."
}
},
"reset-color": "Reset color"
},
"validation": {
"centermustexist": "The chosen responsibility center does not exist.",
@@ -1179,7 +1266,8 @@
"employee_teams": {
"actions": {
"new": "New Team",
"newmember": "New Team Member"
"newmember": "New Team Member",
"save_team": "Save Employee Team"
},
"errors": {
"allocation_total_exact": "Team allocation must total exactly 100%.",
@@ -1197,7 +1285,9 @@
"percentage": "Percent"
},
"labels": {
"allocation_total": "Allocation Total: {{total}}%"
"allocation_total": "Allocation Total: {{total}}%",
"members": "Members",
"team_options": "Team Options"
},
"options": {
"commission": "Commission",
@@ -1207,9 +1297,11 @@
},
"employees": {
"actions": {
"addrate": "Add Rate",
"addvacation": "Add Vacation",
"new": "New Employee",
"newrate": "New Rate",
"save_employee": "Save Employee",
"select": "Select Employee"
},
"errors": {
@@ -1241,6 +1333,7 @@
"labels": {
"actions": "Actions",
"active": "Active",
"employee_number_short": "Employee #",
"endmustbeafterstart": "End date must be after start date.",
"flat_rate": "Flat Rate",
"inactive": "Inactive",
@@ -1373,6 +1466,7 @@
"beta": "BETA",
"cancel": "Are you sure you want to cancel? Your changes will not be saved.",
"changelog": "Change Log",
"click_to_begin": "Click {{action}} to begin",
"clear": "Clear",
"confirmpassword": "Confirm Password",
"created_at": "Created At",
@@ -1691,6 +1785,7 @@
},
"jobs": {
"actions": {
"addpayer": "Add Payer",
"addDocuments": "Add Job Documents",
"addNote": "Add Note",
"addtopartsqueue": "Add to Parts Queue",
@@ -1918,10 +2013,15 @@
"employee_refinish": "Refinish",
"est_addr1": "Estimator Address",
"est_co_nm": "Estimator Company",
"est_co_nm_short": "Company",
"est_ct_fn": "Estimator First Name",
"est_ct_fn_short": "First Name",
"est_ct_ln": "Estimator Last Name",
"est_ct_ln_short": "Last Name",
"est_ea": "Estimator Email",
"est_ea_short": "Email",
"est_ph1": "Estimator Phone #",
"est_ph1_short": "Phone #",
"estimate_approved": "Estimate Approved",
"estimate_sent_approval": "Estimate Sent for Approval",
"federal_tax_payable": "Federal Tax Payable",
@@ -1934,9 +2034,13 @@
"ins_co_nm": "Insurance Company Name",
"ins_co_nm_short": "Ins. Co.",
"ins_ct_fn": "Adjuster First Name",
"ins_ct_fn_short": "First Name",
"ins_ct_ln": "Adjuster Last Name",
"ins_ct_ln_short": "Last Name",
"ins_ea": "Adjuster Email",
"ins_ea_short": "Email",
"ins_ph1": "Adjuster Phone #",
"ins_ph1_short": "Phone #",
"intake": {
"label": "Label",
"max": "Maximum",

View File

@@ -122,7 +122,6 @@
"billdeleted": "",
"billposted": "",
"billmarkforreexport": "",
"billupdated": "",
"failedpayment": "",
"jobassignmentchange": "",
"jobassignmentremoved": "",
@@ -293,7 +292,23 @@
},
"bodyshop": {
"actions": {
"add_adjuster": "",
"add_control_number": "",
"add_cost_center": "",
"add_courtesy_car_rate_preset": "",
"add_delivery_checklist_item": "",
"add_dms_allocation": "",
"add_estimator": "",
"add_insurance_company": "",
"add_intake_checklist_item": "",
"add_jobline_preset": "",
"add_messaging_preset": "",
"add_note_preset": "",
"add_parts_order_comment": "",
"add_production_status_color": "",
"add_profit_center": "",
"add_task_preset": "",
"add_to_email_preset": "",
"addapptcolor": "",
"addbucket": "",
"addpartslocation": "",
@@ -302,11 +317,13 @@
"addtemplate": "",
"newlaborrate": "",
"newsalestaxcode": "",
"save_shop_information": "",
"newstatus": "",
"testrender": ""
},
"errors": {
"creatingdefaultview": "",
"duplicate_job_status": "",
"duplicate_insurance_company": "",
"loading": "No se pueden cargar los detalles de la tienda. Por favor llame al soporte técnico.",
"saving": "",
@@ -404,6 +421,35 @@
"logo_img_path": "",
"logo_img_path_height": "",
"logo_img_path_width": "",
"scoreboard_setup": {
"daily_body_target": "",
"daily_paint_target": "",
"ignore_blocked_days": "",
"last_number_working_days": "",
"production_target_hours": ""
},
"system_settings": {
"auto_email": {
"attach_pdf_to_email": "",
"from_emails": "",
"parts_order_cc": "",
"parts_return_slip_cc": ""
},
"job_costing": {
"paint_hour_split": "",
"paint_materials_hourly_cost_rate": "",
"prep_hour_split": "",
"shop_materials_hourly_cost_rate": "",
"target_touch_time": "",
"use_paint_scale_data": ""
},
"local_media_server": {
"enabled": "",
"http_path": "",
"network_path": "",
"token": ""
}
},
"md_categories": "",
"md_ccc_rates": "",
"md_classes": "",
@@ -464,9 +510,13 @@
"use_approvals": ""
},
"messaginglabel": "",
"messaginglabel_short": "",
"messagingtext": "",
"messagingtext_short": "",
"noteslabel": "",
"noteslabel_short": "",
"notestext": "",
"notestext_short": "",
"notifications": {
"description": "",
"invalid_followers": "",
@@ -600,12 +650,17 @@
"federal_tax_itc": "",
"gogcode": "",
"gst_override": "",
"invoice_federal_tax_rate_short": "",
"invoice_local_tax_rate_short": "",
"invoice_state_tax_rate_short": "",
"invoiceexemptcode": "",
"invoiceexemptcode_short": "",
"item_type": "Item Type",
"item_type_freight": "",
"item_type_gog": "",
"item_type_paint": "",
"itemexemptcode": "",
"itemexemptcode_short": "",
"la1": "",
"la2": "",
"la3": "",
@@ -722,6 +777,7 @@
"customtemplates": "",
"defaultcostsmapping": "",
"defaultprofitsmapping": "",
"dms_setup": "",
"deliverchecklist": "",
"dms": {
"cdk": {
@@ -738,24 +794,33 @@
},
"emaillater": "",
"employee_teams": "",
"employee_options": "",
"employee_rates": "",
"employee_vacation": "",
"employees": "",
"estimators": "",
"filehandlers": "",
"imexpay": "",
"insurancecos": "",
"intake_delivery": "",
"intakechecklist": "",
"intellipay_cash_discount": "",
"job_status_options": "",
"jobstatuses": "",
"laborrates": "",
"licensing": "",
"md_parts_scan": "",
"md_ro_guard": "",
"md_ro_guard_options": "",
"md_tasks_presets": "",
"task_preset_options": "",
"md_to_emails": "",
"md_to_emails_emails": "",
"messagingpresets": "",
"notification_options": "",
"notemplatesavailable": "",
"notespresets": "",
"jump_to_section": "",
"notifications": {
"followers": ""
},
@@ -769,11 +834,22 @@
"qbo_departmentid": "",
"qbo_usa": "",
"rbac": "",
"rbac_options": "",
"responsibilitycenters": {
"costs": "",
"default_tax_setup": "",
"invoices": "",
"profits": "",
"quickbooks_qbd": "",
"quickbooks_us": "",
"sales_tax_codes": "",
"tax_accounts": "",
"tax_rate_short": "",
"tax_surcharge_short": "",
"tax_threshold_short": "",
"tax_tier_card": "",
"tax_tier_short": "",
"tax_type_card": "",
"title": "",
"ttl_adjustment": "",
"ttl_tax_adjustment": ""
@@ -781,6 +857,9 @@
"roguard": {
"title": ""
},
"autoemail": "",
"jobcosting": "",
"localmediaserver": "",
"romepay": "",
"scheduling": "",
"scoreboardsetup": "",
@@ -788,6 +867,7 @@
"shopinfo": "",
"shoprates": "",
"speedprint": "",
"speedprint_configurations": "",
"ssbuckets": "",
"systemsettings": "",
"task-presets": "",
@@ -811,7 +891,8 @@
"tooltips": {
"md_parts_scan": {
"update_value_tooltip": ""
}
},
"reset-color": ""
},
"validation": {
"centermustexist": "",
@@ -1179,7 +1260,8 @@
"employee_teams": {
"actions": {
"new": "",
"newmember": ""
"newmember": "",
"save_team": ""
},
"errors": {
"allocation_total_exact": "",
@@ -1197,7 +1279,9 @@
"percentage": ""
},
"labels": {
"allocation_total": ""
"allocation_total": "",
"members": "",
"team_options": ""
},
"options": {
"commission": "",
@@ -1207,9 +1291,11 @@
},
"employees": {
"actions": {
"addrate": "",
"addvacation": "",
"new": "Nuevo empleado",
"newrate": "",
"save_employee": "",
"select": ""
},
"errors": {
@@ -1241,6 +1327,7 @@
"labels": {
"actions": "",
"active": "",
"employee_number_short": "",
"endmustbeafterstart": "",
"flat_rate": "",
"inactive": "",
@@ -1373,6 +1460,7 @@
"beta": "",
"cancel": "",
"changelog": "",
"click_to_begin": "",
"clear": "",
"confirmpassword": "",
"created_at": "",
@@ -1691,6 +1779,7 @@
},
"jobs": {
"actions": {
"addpayer": "",
"addDocuments": "Agregar documentos de trabajo",
"addNote": "Añadir la nota",
"addtopartsqueue": "",
@@ -1918,10 +2007,15 @@
"employee_refinish": "",
"est_addr1": "Dirección del tasador",
"est_co_nm": "Tasador",
"est_co_nm_short": "",
"est_ct_fn": "Nombre del tasador",
"est_ct_fn_short": "",
"est_ct_ln": "Apellido del tasador",
"est_ct_ln_short": "",
"est_ea": "Correo electrónico del tasador",
"est_ea_short": "",
"est_ph1": "Número de teléfono del tasador",
"est_ph1_short": "",
"estimate_approved": "",
"estimate_sent_approval": "",
"federal_tax_payable": "Impuesto federal por pagar",
@@ -1934,9 +2028,13 @@
"ins_co_nm": "Nombre de la compañía de seguros",
"ins_co_nm_short": "",
"ins_ct_fn": "Nombre del controlador de archivos",
"ins_ct_fn_short": "",
"ins_ct_ln": "Apellido del manejador de archivos",
"ins_ct_ln_short": "",
"ins_ea": "Correo electrónico del controlador de archivos",
"ins_ea_short": "",
"ins_ph1": "File Handler Phone #",
"ins_ph1_short": "",
"intake": {
"label": "",
"max": "",

View File

@@ -122,7 +122,6 @@
"billdeleted": "",
"billmarkforreexport": "",
"billposted": "",
"billupdated": "",
"failedpayment": "",
"jobassignmentchange": "",
"jobassignmentremoved": "",
@@ -293,7 +292,23 @@
},
"bodyshop": {
"actions": {
"add_adjuster": "",
"add_control_number": "",
"add_cost_center": "",
"add_courtesy_car_rate_preset": "",
"add_delivery_checklist_item": "",
"add_dms_allocation": "",
"add_estimator": "",
"add_insurance_company": "",
"add_intake_checklist_item": "",
"add_jobline_preset": "",
"add_messaging_preset": "",
"add_note_preset": "",
"add_parts_order_comment": "",
"add_production_status_color": "",
"add_profit_center": "",
"add_task_preset": "",
"add_to_email_preset": "",
"addapptcolor": "",
"addbucket": "",
"addpartslocation": "",
@@ -302,11 +317,13 @@
"addtemplate": "",
"newlaborrate": "",
"newsalestaxcode": "",
"save_shop_information": "",
"newstatus": "",
"testrender": ""
},
"errors": {
"creatingdefaultview": "",
"duplicate_job_status": "",
"duplicate_insurance_company": "",
"loading": "Impossible de charger les détails de la boutique. Veuillez appeler le support technique.",
"saving": "",
@@ -404,6 +421,35 @@
"logo_img_path": "",
"logo_img_path_height": "",
"logo_img_path_width": "",
"scoreboard_setup": {
"daily_body_target": "",
"daily_paint_target": "",
"ignore_blocked_days": "",
"last_number_working_days": "",
"production_target_hours": ""
},
"system_settings": {
"auto_email": {
"attach_pdf_to_email": "",
"from_emails": "",
"parts_order_cc": "",
"parts_return_slip_cc": ""
},
"job_costing": {
"paint_hour_split": "",
"paint_materials_hourly_cost_rate": "",
"prep_hour_split": "",
"shop_materials_hourly_cost_rate": "",
"target_touch_time": "",
"use_paint_scale_data": ""
},
"local_media_server": {
"enabled": "",
"http_path": "",
"network_path": "",
"token": ""
}
},
"md_categories": "",
"md_ccc_rates": "",
"md_classes": "",
@@ -464,9 +510,13 @@
"use_approvals": ""
},
"messaginglabel": "",
"messaginglabel_short": "",
"messagingtext": "",
"messagingtext_short": "",
"noteslabel": "",
"noteslabel_short": "",
"notestext": "",
"notestext_short": "",
"notifications": {
"description": "",
"invalid_followers": "",
@@ -600,12 +650,17 @@
"federal_tax_itc": "",
"gogcode": "",
"gst_override": "",
"invoice_federal_tax_rate_short": "",
"invoice_local_tax_rate_short": "",
"invoice_state_tax_rate_short": "",
"invoiceexemptcode": "",
"invoiceexemptcode_short": "",
"item_type": "Item Type",
"item_type_freight": "",
"item_type_gog": "",
"item_type_paint": "",
"itemexemptcode": "",
"itemexemptcode_short": "",
"la1": "",
"la2": "",
"la3": "",
@@ -722,6 +777,7 @@
"customtemplates": "",
"defaultcostsmapping": "",
"defaultprofitsmapping": "",
"dms_setup": "",
"deliverchecklist": "",
"dms": {
"cdk": {
@@ -738,24 +794,33 @@
},
"emaillater": "",
"employee_teams": "",
"employee_options": "",
"employee_rates": "",
"employee_vacation": "",
"employees": "",
"estimators": "",
"filehandlers": "",
"imexpay": "",
"insurancecos": "",
"intake_delivery": "",
"intakechecklist": "",
"intellipay_cash_discount": "",
"job_status_options": "",
"jobstatuses": "",
"laborrates": "",
"licensing": "",
"md_parts_scan": "",
"md_ro_guard": "",
"md_ro_guard_options": "",
"md_tasks_presets": "",
"task_preset_options": "",
"md_to_emails": "",
"md_to_emails_emails": "",
"messagingpresets": "",
"notification_options": "",
"notemplatesavailable": "",
"notespresets": "",
"jump_to_section": "",
"notifications": {
"followers": ""
},
@@ -769,11 +834,22 @@
"qbo_departmentid": "",
"qbo_usa": "",
"rbac": "",
"rbac_options": "",
"responsibilitycenters": {
"costs": "",
"default_tax_setup": "",
"invoices": "",
"profits": "",
"quickbooks_qbd": "",
"quickbooks_us": "",
"sales_tax_codes": "",
"tax_accounts": "",
"tax_rate_short": "",
"tax_surcharge_short": "",
"tax_threshold_short": "",
"tax_tier_card": "",
"tax_tier_short": "",
"tax_type_card": "",
"title": "",
"ttl_adjustment": "",
"ttl_tax_adjustment": ""
@@ -781,6 +857,9 @@
"roguard": {
"title": ""
},
"autoemail": "",
"jobcosting": "",
"localmediaserver": "",
"romepay": "",
"scheduling": "",
"scoreboardsetup": "",
@@ -788,6 +867,7 @@
"shopinfo": "",
"shoprates": "",
"speedprint": "",
"speedprint_configurations": "",
"ssbuckets": "",
"systemsettings": "",
"task-presets": "",
@@ -811,7 +891,8 @@
"tooltips": {
"md_parts_scan": {
"update_value_tooltip": ""
}
},
"reset-color": ""
},
"validation": {
"centermustexist": "",
@@ -1179,7 +1260,8 @@
"employee_teams": {
"actions": {
"new": "",
"newmember": ""
"newmember": "",
"save_team": ""
},
"errors": {
"allocation_total_exact": "",
@@ -1197,7 +1279,9 @@
"percentage": ""
},
"labels": {
"allocation_total": ""
"allocation_total": "",
"members": "",
"team_options": ""
},
"options": {
"commission": "",
@@ -1207,9 +1291,11 @@
},
"employees": {
"actions": {
"addrate": "",
"addvacation": "",
"new": "Nouvel employé",
"newrate": "",
"save_employee": "",
"select": ""
},
"errors": {
@@ -1241,6 +1327,7 @@
"labels": {
"actions": "",
"active": "",
"employee_number_short": "",
"endmustbeafterstart": "",
"flat_rate": "",
"inactive": "",
@@ -1373,6 +1460,7 @@
"beta": "",
"cancel": "",
"changelog": "",
"click_to_begin": "",
"clear": "",
"confirmpassword": "",
"created_at": "",
@@ -1691,6 +1779,7 @@
},
"jobs": {
"actions": {
"addpayer": "",
"addDocuments": "Ajouter des documents de travail",
"addNote": "Ajouter une note",
"addtopartsqueue": "",
@@ -1918,10 +2007,15 @@
"employee_refinish": "",
"est_addr1": "Adresse de l'évaluateur",
"est_co_nm": "Expert",
"est_co_nm_short": "",
"est_ct_fn": "Prénom de l'évaluateur",
"est_ct_fn_short": "",
"est_ct_ln": "Nom de l'évaluateur",
"est_ct_ln_short": "",
"est_ea": "Courriel de l'évaluateur",
"est_ea_short": "",
"est_ph1": "Numéro de téléphone de l'évaluateur",
"est_ph1_short": "",
"estimate_approved": "",
"estimate_sent_approval": "",
"federal_tax_payable": "Impôt fédéral à payer",
@@ -1934,9 +2028,13 @@
"ins_co_nm": "Nom de la compagnie d'assurance",
"ins_co_nm_short": "",
"ins_ct_fn": "Prénom du gestionnaire de fichiers",
"ins_ct_fn_short": "",
"ins_ct_ln": "Nom du gestionnaire de fichiers",
"ins_ct_ln_short": "",
"ins_ea": "Courriel du gestionnaire de fichiers",
"ins_ea_short": "",
"ins_ph1": "Numéro de téléphone du gestionnaire de fichiers",
"ins_ph1_short": "",
"intake": {
"label": "",
"max": "",

View File

@@ -10,7 +10,7 @@ const AuditTrailMapping = {
billdeleted: (invoice_number) => i18n.t("audit_trail.messages.billdeleted", { invoice_number }),
billmarkforreexport: (invoice_number) => i18n.t("audit_trail.messages.billmarkforreexport", { invoice_number }),
billposted: (invoice_number) => i18n.t("audit_trail.messages.billposted", { invoice_number }),
billupdated: (invoice_number) => i18n.t("audit_trail.messages.billupdated", { invoice_number }),
billupdated: (invoice_number, details) => i18n.t("audit_trail.messages.billupdated", { invoice_number, details }),
jobassignmentchange: (operation, name) => i18n.t("audit_trail.messages.jobassignmentchange", { operation, name }),
jobassignmentremoved: (operation) => i18n.t("audit_trail.messages.jobassignmentremoved", { operation }),
jobchecklist: (type, inproduction, status) =>
@@ -26,6 +26,10 @@ const AuditTrailMapping = {
jobinproductionchange: (inproduction) => i18n.t("audit_trail.messages.jobinproductionchange", { inproduction }),
jobinvoiced: () => i18n.t("audit_trail.messages.jobinvoiced"),
jobclosedwithbypass: () => i18n.t("audit_trail.messages.jobclosedwithbypass"),
joblineupdate: (lineDescription, details) =>
i18n.t("audit_trail.messages.joblineupdate", { details, lineDescription }),
jobmanualcreate: () => i18n.t("audit_trail.messages.jobmanualcreate"),
jobmanuallineinsert: (details) => i18n.t("audit_trail.messages.jobmanuallineinsert", { details }),
jobmodifylbradj: ({ mod_lbr_ty, hours }) => i18n.t("audit_trail.messages.jobmodifylbradj", { mod_lbr_ty, hours }),
jobnoteadded: () => i18n.t("audit_trail.messages.jobnoteadded"),
jobnoteupdated: () => i18n.t("audit_trail.messages.jobnoteupdated"),
@@ -72,7 +76,11 @@ const AuditTrailMapping = {
i18n.t("audit_trail.messages.tasks_uncompleted", {
title,
uncompletedBy
})
}),
timeticketcreated: (employee, date, details) =>
i18n.t("audit_trail.messages.timeticketcreated", { employee, date, details }),
timeticketupdated: (employee, date, details) =>
i18n.t("audit_trail.messages.timeticketupdated", { employee, date, details })
};
export default AuditTrailMapping;

View File

@@ -0,0 +1,186 @@
import dayjs from "./day";
const EMPTY_VALUE = "<<empty>>";
const NO_CHANGES = "No changes";
const BILL_LINE_KEYS = ["line_desc", "quantity", "actual_price", "actual_cost", "cost_center"];
const JOB_LINE_SKIP_KEYS = new Set(["ah_detail_line", "prt_dsmk_p"]);
const DATE_ONLY_KEYS = new Set(["date"]);
const DATE_TIME_KEYS = new Set(["clockon", "clockoff"]);
const CURRENCY_KEYS = new Set(["actual_price", "actual_cost", "act_price", "db_price", "rate"]);
const HOUR_KEYS = new Set(["productivehrs", "actualhrs", "mod_lb_hrs"]);
const isBlank = (value) => value == null || value === "";
const isStructuredValue = (value) => value != null && typeof value === "object" && !dayjs.isDayjs?.(value);
const formatDate = (value) => (isBlank(value) ? EMPTY_VALUE : dayjs(value).format("YYYY-MM-DD"));
const formatDateTime = (value) => (isBlank(value) ? EMPTY_VALUE : dayjs(value).format("YYYY-MM-DD HH:mm"));
const formatNumber = (value, fractionDigits) =>
typeof value === "number" ? value.toFixed(fractionDigits) : String(value);
const compareValue = (key, value) => {
if (isBlank(value)) return EMPTY_VALUE;
if (DATE_TIME_KEYS.has(key)) return formatDateTime(value);
if (DATE_ONLY_KEYS.has(key)) return formatDate(value);
if (dayjs.isDayjs?.(value)) return formatDateTime(value);
return String(value);
};
const buildFieldChangeDetails = ({ keys, original = {}, updated = {}, displayValue, skippedKeys = new Set() }) =>
keys
.filter((key) => key !== "__typename" && !skippedKeys.has(key))
.filter((key) => !isStructuredValue(original[key]) && !isStructuredValue(updated[key]))
.map((key) => {
if (compareValue(key, original[key]) === compareValue(key, updated[key])) return null;
return `${key}: ${displayValue(key, original[key])} -> ${displayValue(key, updated[key])}`;
})
.filter(Boolean);
const formatBillValue = (key, value) => {
if (isBlank(value)) return EMPTY_VALUE;
if (DATE_ONLY_KEYS.has(key)) return formatDate(value);
if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value);
return String(value);
};
const formatJobLineValue = (key, value) => {
if (isBlank(value)) return EMPTY_VALUE;
if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value);
if (HOUR_KEYS.has(key)) return formatNumber(value, 1);
return String(value);
};
const getEmployeeName = (employeeId, employees = [], fallbackEmployee) => {
if (
(employeeId == null || fallbackEmployee?.id === employeeId) &&
(fallbackEmployee?.first_name || fallbackEmployee?.last_name)
) {
return [fallbackEmployee.first_name, fallbackEmployee.last_name].filter(Boolean).join(" ");
}
const employee = employees.find(({ id }) => id === employeeId);
if (employee) {
return [employee.first_name, employee.last_name].filter(Boolean).join(" ");
}
return employeeId ? String(employeeId) : EMPTY_VALUE;
};
const formatTimeTicketValue = (key, value, { employees = [], fallbackEmployee } = {}) => {
if (isBlank(value)) return EMPTY_VALUE;
if (key === "employeeid") return getEmployeeName(value, employees, fallbackEmployee);
if (DATE_TIME_KEYS.has(key)) return formatDateTime(value);
if (DATE_ONLY_KEYS.has(key)) return formatDate(value);
if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value);
if (HOUR_KEYS.has(key)) return formatNumber(value, 1);
if (typeof value === "boolean") return value ? "true" : "false";
return String(value);
};
const buildBillLineSummary = (line) =>
BILL_LINE_KEYS.map((key) => `${key}: ${formatBillValue(key, line[key])}`).join(", ");
export function buildBillUpdateAuditDetails({ originalBill = {}, bill = {}, billlines = [] }) {
const updatedBill = { ...bill, billlines };
const billKeys = Array.from(new Set([...Object.keys(originalBill), ...Object.keys(updatedBill)])).filter(
(key) => key !== "billlines"
);
const changed = buildFieldChangeDetails({
keys: billKeys,
original: originalBill,
updated: updatedBill,
displayValue: formatBillValue
});
const originalBillLines = originalBill.billlines ?? [];
const updatedBillLines = updatedBill.billlines ?? [];
const addedLines = updatedBillLines
.filter((line) => !line.id)
.map((line) => `+${line.line_desc || line.description || "new line"} (${buildBillLineSummary(line)})`);
const removedLines = originalBillLines
.filter((line) => !updatedBillLines.some((updatedLine) => updatedLine.id === line.id))
.map(
(line) => `-${line.line_desc || line.description || line.id || "removed line"} (${buildBillLineSummary(line)})`
);
const modifiedLines = updatedBillLines
.filter((line) => line.id)
.flatMap((line) => {
const originalLine = originalBillLines.find(({ id }) => id === line.id);
if (!originalLine) return [];
const lineChanges = buildFieldChangeDetails({
keys: BILL_LINE_KEYS,
original: originalLine,
updated: line,
displayValue: formatBillValue
});
if (!lineChanges.length) return [];
return [`${line.line_desc || line.description || line.id}: ${lineChanges.join("; ")}`];
});
if (addedLines.length) changed.push(`billlines added: ${addedLines.join(" | ")}`);
if (removedLines.length) changed.push(`billlines removed: ${removedLines.join(" | ")}`);
if (modifiedLines.length) changed.push(`billlines modified: ${modifiedLines.join(" | ")}`);
return changed.length ? changed.join("; ") : NO_CHANGES;
}
export function buildJobLineInsertAuditDetails(values = {}) {
const details = Object.entries(values)
.filter(([key, value]) => !JOB_LINE_SKIP_KEYS.has(key) && !isBlank(value))
.map(([key, value]) => `${key}: ${formatJobLineValue(key, value)}`);
return details.length ? details.join("; ") : NO_CHANGES;
}
export function buildJobLineUpdateAuditDetails({ originalLine = {}, values = {} }) {
const details = buildFieldChangeDetails({
keys: Object.keys(values),
original: originalLine,
updated: values,
displayValue: formatJobLineValue,
skippedKeys: JOB_LINE_SKIP_KEYS
});
return details.length ? details.join("; ") : NO_CHANGES;
}
export function buildTimeTicketAuditSummary({ originalTicket = {}, submittedValues = {}, employees = [] }) {
const normalizedOriginal = {
...originalTicket,
jobid: originalTicket.job?.id ?? originalTicket.jobid ?? null
};
const details = buildFieldChangeDetails({
keys: Object.keys(submittedValues),
original: normalizedOriginal,
updated: submittedValues,
displayValue: (key, value) =>
formatTimeTicketValue(key, value, {
employees,
fallbackEmployee: key === "employeeid" ? normalizedOriginal.employee : null
})
});
const employeeName = getEmployeeName(
submittedValues.employeeid ?? normalizedOriginal.employeeid,
employees,
normalizedOriginal.employee
);
return {
date: formatDate(submittedValues.date ?? normalizedOriginal.date),
details: details.length ? details.join("; ") : NO_CHANGES,
employeeName,
jobid: submittedValues.jobid ?? normalizedOriginal.jobid ?? null
};
}

View File

@@ -1164,6 +1164,7 @@
- notification_followers
- state
- md_order_statuses
- md_ro_statuses
retry_conf:
interval_sec: 10
num_retries: 0
@@ -1184,7 +1185,8 @@
"new": {
"id": {{$body.event.data.new.id}},
"shopname": {{$body.event.data.new.shopname}},
"md_order_statuses": {{$body.event.data.new.md_order_statuses}}
"md_order_statuses": {{$body.event.data.new.md_order_statuses}},
"md_ro_statuses": {{$body.event.data.new.md_ro_statuses}}
}
},
"op": {{$body.event.op}},

View File

@@ -32,6 +32,7 @@ async function OpenSearchUpdateHandler(req, res) {
clm_no
clm_total
comment
dms_id
ins_co_nm
owner_owing
ownr_co_nm

843
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,25 +18,25 @@
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
},
"dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.1014.0",
"@aws-sdk/client-elasticache": "^3.1014.0",
"@aws-sdk/client-s3": "^3.1014.0",
"@aws-sdk/client-secrets-manager": "^3.1014.0",
"@aws-sdk/client-ses": "^3.1014.0",
"@aws-sdk/client-sqs": "^3.1014.0",
"@aws-sdk/client-textract": "^3.1014.0",
"@aws-sdk/credential-provider-node": "^3.972.24",
"@aws-sdk/lib-storage": "^3.1014.0",
"@aws-sdk/s3-request-presigner": "^3.1014.0",
"@aws-sdk/client-cloudwatch-logs": "^3.1020.0",
"@aws-sdk/client-elasticache": "^3.1020.0",
"@aws-sdk/client-s3": "^3.1020.0",
"@aws-sdk/client-secrets-manager": "^3.1020.0",
"@aws-sdk/client-ses": "^3.1020.0",
"@aws-sdk/client-sqs": "^3.1020.0",
"@aws-sdk/client-textract": "^3.1020.0",
"@aws-sdk/credential-provider-node": "^3.972.28",
"@aws-sdk/lib-storage": "^3.1020.0",
"@aws-sdk/s3-request-presigner": "^3.1020.0",
"@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0",
"archiver": "^7.0.1",
"aws4": "^1.13.2",
"axios": "^1.13.6",
"axios": "^1.14.0",
"axios-curlirize": "^2.0.0",
"better-queue": "^3.8.12",
"bullmq": "^5.71.0",
"bullmq": "^5.71.1",
"chart.js": "^4.5.1",
"cloudinary": "^2.9.0",
"compression": "^1.8.1",
@@ -46,10 +46,10 @@
"dinero.js": "^1.9.1",
"dotenv": "^17.3.1",
"express": "^4.21.1",
"fast-xml-parser": "^5.5.8",
"fast-xml-parser": "^5.5.9",
"firebase-admin": "^13.7.0",
"fuse.js": "^7.1.0",
"graphql": "^16.13.1",
"graphql": "^16.13.2",
"graphql-request": "^6.1.0",
"intuit-oauth": "^4.2.2",
"ioredis": "^5.10.1",
@@ -73,7 +73,7 @@
"socket.io": "^4.8.3",
"socket.io-adapter": "^2.5.6",
"ssh2-sftp-client": "^11.0.0",
"twilio": "^5.13.0",
"twilio": "^5.13.1",
"uuid": "^11.1.0",
"winston": "^3.19.0",
"winston-cloudwatch": "^6.3.0",
@@ -91,6 +91,6 @@
"p-limit": "^3.1.0",
"prettier": "^3.8.1",
"supertest": "^7.2.2",
"vitest": "^4.1.0"
"vitest": "^4.1.2"
}
}

View File

@@ -250,7 +250,8 @@ async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShop
function buildInteractionPayload(bodyshop, j) {
const isCompany = Boolean(j.ownr_co_nm);
const locationIdentifier = `${bodyshop.chatter_company_id}-${bodyshop.id}`;
const locationIdentifier = bodyshop?.imexshopid ?? `${bodyshop.chatter_company_id}-${bodyshop.id}`;
const timestamp = formatChatterTimestamp(j.actual_delivery, bodyshop.timezone);
if (j.actual_delivery && !timestamp) {

View File

@@ -2442,6 +2442,9 @@ exports.ACTIVE_SHOP_BY_USER = `query ACTIVE_SHOP_BY_USER($user: String) {
associations(where: {active: {_eq: true}, useremail: {_eq: $user}}) {
id
shopid
bodyshop {
rr_dealerid
}
}
}`;
@@ -2953,6 +2956,7 @@ exports.GET_BODYSHOP_BY_ID = `
query GET_BODYSHOP_BY_ID($id: uuid!) {
bodyshops_by_pk(id: $id) {
id
md_ro_statuses
md_order_statuses
shopname
imexshopid

View File

@@ -4,6 +4,7 @@ const queries = require("../graphql-client/queries");
const client = require("../graphql-client/graphql-client").client;
const { pick, isNil } = require("lodash");
const { getClient } = require("../../libs/awsUtils");
const { JOB_DOCUMENT_FIELDS, getGlobalSearchQueryStringFields } = require("./os-search-config");
async function OpenSearchUpdateHandler(req, res) {
try {
@@ -21,27 +22,7 @@ async function OpenSearchUpdateHandler(req, res) {
switch (req.body.table.name) {
case "jobs":
document = pick(req.body.event.data.new, [
"id",
"bodyshopid",
"clm_no",
"clm_total",
"comment",
"ins_co_nm",
"owner_owing",
"ownr_co_nm",
"ownr_fn",
"ownr_ln",
"ownr_ph1",
"ownr_ph2",
"plate_no",
"ro_number",
"status",
"v_model_yr",
"v_make_desc",
"v_model_desc",
"v_vin"
]);
document = pick(req.body.event.data.new, JOB_DOCUMENT_FIELDS);
document.bodyshopid = req.body.event.data.new.shopid;
break;
case "vehicles":
@@ -197,15 +178,18 @@ async function OpenSearchSearchHandler(req, res) {
user: req.user.email
});
if (assocs.length === 0) {
if (assocs.associations.length === 0) {
res.sendStatus(401);
return;
}
const osClient = await getClient();
const activeAssociation = assocs.associations[0];
const bodyShopIdMatchOverride = isNil(process.env.BODY_SHOP_ID_MATCH_OVERRIDE)
? assocs.associations[0].shopid
? activeAssociation.shopid
: process.env.BODY_SHOP_ID_MATCH_OVERRIDE;
const isReynoldsEnabled = Boolean(activeAssociation.bodyshop?.rr_dealerid);
const { body } = await osClient.search({
...(index ? { index } : { index: ["jobs", "vehicles", "owners", "bills", "payments"] }),
@@ -241,21 +225,8 @@ async function OpenSearchSearchHandler(req, res) {
query: `*${search}*`,
// Weighted Fields
fields: [
"*ro_number^20",
"*clm_no^14",
"*v_vin^12",
"*plate_no^12",
"*ownr_ln^10",
"transactionid^10",
"paymentnum^10",
"invoice_number^10",
"*ownr_fn^8",
"*ownr_co_nm^8",
"*ownr_ph1^8",
"*ownr_ph2^8",
"*vendor.name^8",
"*comment^6"
// "*"
...getGlobalSearchQueryStringFields({ isReynoldsEnabled })
// "*"
]
}
}

View File

@@ -0,0 +1,69 @@
/**
* Fields to be included in the job document indexed in OpenSearch. These fields are used for both indexing and
* searching.
* @type {string[]}
*/
const JOB_DOCUMENT_FIELDS = [
"id",
"bodyshopid",
"clm_no",
"clm_total",
"comment",
"dms_id",
"ins_co_nm",
"owner_owing",
"ownr_co_nm",
"ownr_fn",
"ownr_ln",
"ownr_ph1",
"ownr_ph2",
"plate_no",
"ro_number",
"status",
"v_model_yr",
"v_make_desc",
"v_model_desc",
"v_vin"
];
/**
* Fields to be included in the global search query string. These fields are used for constructing the search query.
* @type {string[]}
*/
const BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS = [
"*ro_number^20",
"*clm_no^14",
"*v_vin^12",
"*plate_no^12",
"*ownr_ln^10",
"transactionid^10",
"paymentnum^10",
"invoice_number^10",
"*ownr_fn^8",
"*ownr_co_nm^8",
"*ownr_ph1^8",
"*ownr_ph2^8",
"*vendor.name^8",
"*comment^6"
];
/**
* Returns the fields to be included in the global search query string. If Reynolds is enabled, it includes the dms_id
* field with a higher boost.
* @param param0
* @param param0.isReynoldsEnabled
* @returns {string[]}
*/
const getGlobalSearchQueryStringFields = ({ isReynoldsEnabled = false } = {}) => {
if (!isReynoldsEnabled) {
return BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS;
}
return ["*dms_id^20", ...BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS];
};
module.exports = {
JOB_DOCUMENT_FIELDS,
BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS,
getGlobalSearchQueryStringFields
};

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { JOB_DOCUMENT_FIELDS, BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS, getGlobalSearchQueryStringFields } = require(
"../os-search-config"
);
describe("os-search-config", () => {
it("indexes dms_id on job documents", () => {
expect(JOB_DOCUMENT_FIELDS).toContain("dms_id");
});
it("includes dms_id in global search fields for Reynolds shops", () => {
expect(getGlobalSearchQueryStringFields({ isReynoldsEnabled: true })).toContain("*dms_id^20");
});
it("keeps the default search fields unchanged for non-Reynolds shops", () => {
expect(getGlobalSearchQueryStringFields()).toEqual(BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS);
});
});

View File

@@ -46,6 +46,11 @@ const summarizeAllocationsArray = (arr) =>
cost: summarizeMoney(a.cost)
}));
const toFiniteNumber = (value) => {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
};
/**
* Internal per-center bucket shape for *sales*.
* We keep separate buckets for RR so we can split
@@ -62,6 +67,8 @@ function emptyCenterBucket() {
// Labor
laborTaxableSale: zero, // labor that should be taxed in RR
laborNonTaxableSale: zero, // labor that should NOT be taxed in RR
laborTaxableHours: 0,
laborNonTaxableHours: 0,
// Extras (MAPA/MASH/towing/PAO/etc)
extrasSale: zero, // total extras (taxable + non-taxable)
@@ -453,6 +460,7 @@ function buildProfitCenterHash(job, debugLog, taxContext) {
const rateKey = `rate_${val.mod_lbr_ty.toLowerCase()}`;
const rate = job[rateKey];
const lineHours = toFiniteNumber(val.mod_lb_hrs);
const laborAmount = Dinero({
amount: Math.round(rate * 100)
@@ -460,8 +468,10 @@ function buildProfitCenterHash(job, debugLog, taxContext) {
if (isLaborTaxable(val, taxContext)) {
bucket.laborTaxableSale = bucket.laborTaxableSale.add(laborAmount);
bucket.laborTaxableHours += lineHours;
} else {
bucket.laborNonTaxableSale = bucket.laborNonTaxableSale.add(laborAmount);
bucket.laborNonTaxableHours += lineHours;
}
}
@@ -478,6 +488,8 @@ function buildProfitCenterHash(job, debugLog, taxContext) {
partsNonTaxable: summarizeMoney(b.partsNonTaxableSale),
laborTaxable: summarizeMoney(b.laborTaxableSale),
laborNonTaxable: summarizeMoney(b.laborNonTaxableSale),
laborTaxableHours: b.laborTaxableHours,
laborNonTaxableHours: b.laborNonTaxableHours,
extras: summarizeMoney(b.extrasSale),
extrasTaxable: summarizeMoney(b.extrasTaxableSale),
extrasNonTaxable: summarizeMoney(b.extrasNonTaxableSale)
@@ -916,6 +928,8 @@ function buildJobAllocations(bodyshop, profitCenterHash, costCenterHash, debugLo
// Labor
laborTaxableSale: bucket.laborTaxableSale,
laborNonTaxableSale: bucket.laborNonTaxableSale,
laborTaxableHours: bucket.laborTaxableHours,
laborNonTaxableHours: bucket.laborNonTaxableHours,
// Extras
extrasSale,

View File

@@ -0,0 +1,187 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const mock = require("mock-require");
const graphqlRequestModuleId = require.resolve("graphql-request");
const queriesModuleId = require.resolve("../graphql-client/queries");
const rrLoggerModuleId = require.resolve("./rr-logger-event");
const rrExportLogsModuleId = require.resolve("./rr-export-logs");
const loadExportLogs = ({ requests }) => {
mock.stopAll();
mock(graphqlRequestModuleId, {
GraphQLClient: class MockGraphQLClient {
constructor(endpoint) {
this.endpoint = endpoint;
this.headers = {};
}
setHeaders(headers) {
this.headers = headers;
return this;
}
async request(query, variables) {
requests.push({
endpoint: this.endpoint,
headers: this.headers,
query,
variables
});
return {};
}
}
});
mock(queriesModuleId, {
INSERT_EXPORT_LOG: "INSERT_EXPORT_LOG",
MARK_JOB_EXPORTED: "MARK_JOB_EXPORTED"
});
mock(rrLoggerModuleId, () => {});
delete require.cache[rrExportLogsModuleId];
return require(rrExportLogsModuleId);
};
const socket = {
data: { authToken: "socket-token" },
user: { email: "tech@example.com" }
};
const job = {
id: "job-1",
bodyshop: {
id: "bodyshop-1",
md_ro_statuses: {
default_exported: "Exported"
}
}
};
describe("server/rr/rr-export-logs", () => {
const originalEndpoint = process.env.GRAPHQL_ENDPOINT;
afterEach(() => {
mock.stopAll();
delete require.cache[rrExportLogsModuleId];
process.env.GRAPHQL_ENDPOINT = originalEndpoint;
});
it("marks Reynolds full exports as exported using the shared DMS export mutation", async () => {
process.env.GRAPHQL_ENDPOINT = "https://graphql.example.test/v1/graphql";
const requests = [];
const { markRRExportSuccess } = loadExportLogs({ requests });
await markRRExportSuccess({
socket,
jobId: job.id,
job,
bodyshop: job.bodyshop,
result: {
success: true,
roStatus: {
status: "SUCCESS",
statusCode: "0",
message: "Finalized"
}
}
});
expect(requests).toHaveLength(1);
expect(requests[0]).toMatchObject({
endpoint: "https://graphql.example.test/v1/graphql",
headers: { Authorization: "Bearer socket-token" },
query: "MARK_JOB_EXPORTED",
variables: {
jobId: "job-1",
job: {
status: "Exported",
date_exported: expect.any(Date)
},
log: {
bodyshopid: "bodyshop-1",
jobid: "job-1",
successful: true,
useremail: "tech@example.com"
},
bill: {
exported: true,
exported_at: expect.any(Date)
}
}
});
});
it("uses the separately loaded bodyshop statuses when job.bodyshop is missing", async () => {
process.env.GRAPHQL_ENDPOINT = "https://graphql.example.test/v1/graphql";
const requests = [];
const { markRRExportSuccess } = loadExportLogs({ requests });
await markRRExportSuccess({
socket,
jobId: job.id,
job: { id: job.id },
bodyshop: job.bodyshop,
result: {
success: true,
roStatus: {
status: "SUCCESS",
statusCode: "0",
message: "Finalized"
}
}
});
expect(requests).toHaveLength(1);
expect(requests[0]).toMatchObject({
query: "MARK_JOB_EXPORTED",
variables: {
jobId: "job-1",
job: {
status: "Exported",
date_exported: expect.any(Date)
},
log: {
bodyshopid: "bodyshop-1",
jobid: "job-1",
successful: true,
useremail: "tech@example.com"
}
}
});
});
it("does not mark Reynolds early RO creation as exported", async () => {
process.env.GRAPHQL_ENDPOINT = "https://graphql.example.test/v1/graphql";
const requests = [];
const { markRRExportSuccess } = loadExportLogs({ requests });
await markRRExportSuccess({
socket,
jobId: job.id,
job,
bodyshop: job.bodyshop,
result: { success: true },
isEarlyRo: true
});
expect(requests).toHaveLength(1);
expect(requests[0]).toMatchObject({
query: "INSERT_EXPORT_LOG",
variables: {
logs: [
{
bodyshopid: "bodyshop-1",
jobid: "job-1",
successful: true,
useremail: "tech@example.com"
}
]
}
});
});
});

View File

@@ -1,4 +1,4 @@
const { buildRRRepairOrderPayload } = require("./rr-job-helpers");
const { buildRRRepairOrderPayload, buildMinimalRolaborFromJob } = require("./rr-job-helpers");
const { buildClientAndOpts } = require("./rr-lookup");
const CreateRRLogEvent = require("./rr-logger-event");
const { withRRRequestXml } = require("./rr-log-xml");
@@ -56,6 +56,27 @@ const deriveRRStatus = (rrRes = {}) => {
};
};
const resolveRROpCode = (bodyshop, txEnvelope = {}) => {
const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop);
let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null;
if (!opCodeOverride) {
const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null;
const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null;
const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null;
if (opPrefix || opBase || opSuffix) {
const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim();
if (combined) {
opCodeOverride = combined;
}
}
}
if (!opCodeOverride && !resolvedBaseOpCode) return null;
return String(opCodeOverride || resolvedBaseOpCode).trim() || null;
};
/**
* Early RO Creation: Create a minimal RR Repair Order with basic info (customer, advisor, mileage, story).
* Used when creating RO from convert button or admin page before full job export.
@@ -93,7 +114,9 @@ const createMinimalRRRepairOrder = async (args) => {
const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null;
// Build minimal RO payload - just header, no allocations/parts/labor
// Build minimal RO payload for early review mode.
// We keep it lightweight, but include a single labor row when we can so Ignite
// exposes the labor subsection for editing.
const cleanVin =
(job?.v_vin || "")
.toString()
@@ -116,6 +139,12 @@ const createMinimalRRRepairOrder = async (args) => {
resolvedMileageIn: mileageIn
});
const earlyRoOpCode = resolveRROpCode(bodyshop, txEnvelope);
const earlyRoLabor = buildMinimalRolaborFromJob(job, {
opCode: earlyRoOpCode,
payType: "Cust"
});
const payload = {
customerNo: String(selected),
advisorNo: String(advisorNo),
@@ -141,9 +170,14 @@ const createMinimalRRRepairOrder = async (args) => {
if (makeOverride) {
payload.makeOverride = makeOverride;
}
if (earlyRoLabor) {
payload.rolabor = earlyRoLabor;
}
CreateRRLogEvent(socket, "INFO", "Creating minimal RR Repair Order (early creation)", {
payload
payload,
earlyRoOpCode,
hasRolabor: !!earlyRoLabor
});
const response = await client.createRepairOrder(payload, finalOpts);
@@ -221,15 +255,10 @@ const updateRRRepairOrderWithFullData = async (args) => {
const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null;
// Optional RR OpCode segments coming from the FE (RRPostForm)
const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null;
const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null;
const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null;
// RR-only extras
let rrCentersConfig = null;
let allocations = null;
let opCode = null;
const opCode = resolveRROpCode(bodyshop, txEnvelope);
// 1) Responsibility center config (for visibility / debugging)
try {
@@ -280,28 +309,9 @@ const updateRRRepairOrderWithFullData = async (args) => {
allocations = [];
}
const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop);
let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null;
// If the FE only sends segments, combine them here.
if (!opCodeOverride && (opPrefix || opBase || opSuffix)) {
const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim();
if (combined) {
opCodeOverride = combined;
}
}
if (opCodeOverride || resolvedBaseOpCode) {
opCode = String(opCodeOverride || resolvedBaseOpCode).trim() || null;
}
CreateRRLogEvent(socket, "SILLY", "RR OP config resolved", {
opCode,
baseFromConfig: resolvedBaseOpCode,
opPrefix,
opBase,
opSuffix
baseFromConfig: resolveRROpCodeFromBodyshop(bodyshop)
});
// Build full RO payload for update with allocations
@@ -426,15 +436,10 @@ const exportJobToRR = async (args) => {
const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null;
// Optional RR OpCode segments coming from the FE (RRPostForm)
const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null;
const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null;
const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null;
// RR-only extras
let rrCentersConfig = null;
let allocations = null;
let opCode = null;
const opCode = resolveRROpCode(bodyshop, txEnvelope);
// 1) Responsibility center config (for visibility / debugging)
try {
@@ -477,28 +482,9 @@ const exportJobToRR = async (args) => {
allocations = [];
}
const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop);
let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null;
// If the FE only sends segments, combine them here.
if (!opCodeOverride && (opPrefix || opBase || opSuffix)) {
const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim();
if (combined) {
opCodeOverride = combined;
}
}
if (opCodeOverride || resolvedBaseOpCode) {
opCode = String(opCodeOverride || resolvedBaseOpCode).trim() || null;
}
CreateRRLogEvent(socket, "SILLY", "RR OP config resolved", {
opCode,
baseFromConfig: resolvedBaseOpCode,
opPrefix,
opBase,
opSuffix
baseFromConfig: resolveRROpCodeFromBodyshop(bodyshop)
});
// Build RO payload for create.

View File

@@ -52,6 +52,19 @@ const asN2 = (dineroLike) => {
return amount.toFixed(2);
};
const toFiniteNumber = (value) => {
if (typeof value === "number") {
return Number.isFinite(value) ? value : 0;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
};
/**
* Normalize various "money-like" shapes to integer cents.
* Supports:
@@ -100,6 +113,100 @@ const toMoneyCents = (value) => {
const asN2FromCents = (cents) => asN2({ amount: Number.isFinite(cents) ? cents : 0, precision: 2 });
const formatDecimal = (value, maxDecimals = 2) => {
const factor = Math.pow(10, maxDecimals);
const rounded = Math.round(Math.max(0, toFiniteNumber(value)) * factor) / factor;
if (!Number.isFinite(rounded)) return "0";
return rounded.toFixed(maxDecimals).replace(/\.?0+$/, "") || "0";
};
const buildRolaborBillFields = ({ amountUnits = 0, hours = 0, rate = 0 } = {}) => {
const normalizedAmount = toFiniteNumber(amountUnits);
if (normalizedAmount <= 0) {
return {
jobTotalHrs: "0",
billTime: "0",
billRate: "0"
};
}
let resolvedHours = toFiniteNumber(hours);
let resolvedRate = toFiniteNumber(rate);
if (resolvedHours > 0 && resolvedRate <= 0) {
resolvedRate = normalizedAmount / resolvedHours;
} else if (resolvedRate > 0 && resolvedHours <= 0) {
resolvedHours = normalizedAmount / resolvedRate;
} else if (resolvedHours <= 0 && resolvedRate <= 0) {
// Keep the math internally consistent even if the source job has dollars but no usable hours.
resolvedHours = 1;
resolvedRate = normalizedAmount;
}
return {
jobTotalHrs: formatDecimal(resolvedHours),
billTime: formatDecimal(resolvedHours),
billRate: resolvedRate.toFixed(2)
};
};
const buildMinimalRolaborFromJob = (job, { opCode, payType = "Cust" } = {}) => {
const trimmedOpCode = opCode != null ? String(opCode).trim() : "";
if (!job || !trimmedOpCode) return null;
let totalHours = 0;
let totalAmountUnits = 0;
for (const line of job?.joblines || []) {
const laborType = typeof line?.mod_lbr_ty === "string" ? line.mod_lbr_ty.trim() : "";
if (!laborType) continue;
const lineHours = toFiniteNumber(line?.mod_lb_hrs ?? line?.db_hrs);
const configuredRate = toFiniteNumber(job?.[`rate_${laborType.toLowerCase()}`]);
let lineAmountUnits = toFiniteNumber(line?.lbr_amt);
if (lineAmountUnits <= 0 && lineHours > 0 && configuredRate > 0) {
lineAmountUnits = lineHours * configuredRate;
}
if (lineAmountUnits <= 0 && lineHours <= 0) continue;
totalHours += lineHours;
totalAmountUnits += lineAmountUnits;
}
if (totalAmountUnits <= 0 && totalHours <= 0) return null;
const bill = buildRolaborBillFields({
amountUnits: totalAmountUnits,
hours: totalHours,
rate: totalHours > 0 ? totalAmountUnits / totalHours : 0
});
const formattedAmount = totalAmountUnits.toFixed(2);
return {
ops: [
{
opCode: trimmedOpCode,
jobNo: "1",
custPayTypeFlag: "C",
custTxblNtxblFlag: toFiniteNumber(job?.tax_lbr_rt) > 0 ? "T" : "N",
bill: {
payType,
...bill
},
amount: {
payType,
amtType: "Job",
custPrice: formattedAmount,
totalAmt: formattedAmount
}
}
]
};
};
/**
* Build RR estimate block from allocation totals.
* @param {Array} allocations
@@ -326,6 +433,13 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
// Each segment becomes its own op / JobNo with a single line
segments.forEach((seg, idx) => {
const jobNo = String(ops.length + 1); // global, 1-based JobNo across all centers/segments
const isLaborSegment = seg.kind === "laborTaxable" || seg.kind === "laborNonTaxable";
const segmentHours = isLaborSegment
? seg.kind === "laborTaxable"
? toFiniteNumber(alloc.laborTaxableHours)
: toFiniteNumber(alloc.laborNonTaxableHours)
: 0;
const segmentBillRate = isLaborSegment && segmentHours > 0 ? seg.saleCents / 100 / segmentHours : 0;
const line = {
breakOut,
@@ -349,7 +463,9 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
// Extra metadata for UI / debugging
segmentKind: seg.kind,
segmentIndex: idx,
segmentCount
segmentCount,
segmentHours,
segmentBillRate
});
});
}
@@ -368,9 +484,9 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
*
* We still keep a 1:1 mapping with GOG ops: each op gets a corresponding
* OpCodeLaborInfo entry using the same JobNo and the same tax flag as its
* GOG line. Labor-specific hours/rate remain zeroed out, but actual labor
* sale amounts are mirrored into ROLABOR for labor segments so RR receives
* the expected labor pricing on updates. Non-labor ops remain zeroed.
* GOG line. Labor sale amounts are mirrored into ROLABOR and, when hours
* are available from allocations, weighted bill hours/rates are also
* populated so the labor subsection is editable in Ignite.
*
* @param {Object} rogg - result of buildRogogFromAllocations
* @param {Object} opts
@@ -391,6 +507,17 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => {
const linePayType = firstLine.custPayTypeFlag || "C";
const isLaborSegment = op.segmentKind === "laborTaxable" || op.segmentKind === "laborNonTaxable";
const laborAmount = isLaborSegment ? String(firstLine?.amount?.custPrice ?? "0") : "0";
const laborBill = isLaborSegment
? buildRolaborBillFields({
amountUnits: laborAmount,
hours: op.segmentHours,
rate: op.segmentBillRate
})
: {
jobTotalHrs: "0",
billTime: "0",
billRate: "0"
};
return {
opCode: op.opCode,
@@ -399,9 +526,7 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => {
custTxblNtxblFlag: txFlag,
bill: {
payType,
jobTotalHrs: "0",
billTime: "0",
billRate: "0"
...laborBill
},
amount: {
payType,
@@ -686,5 +811,6 @@ module.exports = {
normalizeCustomerCandidates,
normalizeVehicleCandidates,
buildRogogFromAllocations,
buildRolaborFromRogog
buildRolaborFromRogog,
buildMinimalRolaborFromJob
};

View File

@@ -0,0 +1,118 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const mock = require("mock-require");
const graphClientModuleId = require.resolve("../graphql-client/graphql-client");
const queriesModuleId = require.resolve("../graphql-client/queries");
const helpersModuleId = require.resolve("./rr-job-helpers");
const loadHelpers = () => {
mock.stopAll();
mock(graphClientModuleId, { client: { request: async () => ({}) } });
mock(queriesModuleId, { GET_JOB_BY_PK: "GET_JOB_BY_PK" });
delete require.cache[helpersModuleId];
return require(helpersModuleId);
};
afterEach(() => {
mock.stopAll();
delete require.cache[helpersModuleId];
});
describe("server/rr/rr-job-helpers", () => {
it("builds a single early-RO labor row from aggregated job labor", () => {
const { buildMinimalRolaborFromJob } = loadHelpers();
const rolabor = buildMinimalRolaborFromJob(
{
tax_lbr_rt: 13,
joblines: [
{ mod_lbr_ty: "LAB", mod_lb_hrs: 2, lbr_amt: 200 },
{ mod_lbr_ty: "LAD", mod_lb_hrs: 1.5, lbr_amt: 180 }
]
},
{ opCode: "51DOZ" }
);
expect(rolabor).toEqual({
ops: [
{
opCode: "51DOZ",
jobNo: "1",
custPayTypeFlag: "C",
custTxblNtxblFlag: "T",
bill: {
payType: "Cust",
jobTotalHrs: "3.5",
billTime: "3.5",
billRate: "108.57"
},
amount: {
payType: "Cust",
amtType: "Job",
custPrice: "380.00",
totalAmt: "380.00"
}
}
]
});
});
it("populates labor bill fields from allocation hours on the full RR payload", () => {
const { buildRRRepairOrderPayload } = loadHelpers();
const payload = buildRRRepairOrderPayload({
job: {
id: "job-1",
ro_number: "RO-123",
v_vin: "1HGBH41JXMN109186"
},
selectedCustomer: { customerNo: "1134485" },
advisorNo: "70754",
allocations: [
{
center: "Body Labor",
partsSale: { amount: 0, precision: 2 },
laborTaxableSale: { amount: 24000, precision: 2 },
laborNonTaxableSale: { amount: 0, precision: 2 },
extrasSale: { amount: 0, precision: 2 },
totalSale: { amount: 24000, precision: 2 },
cost: { amount: 12000, precision: 2 },
laborTaxableHours: 2,
laborNonTaxableHours: 0,
profitCenter: {
rr_gogcode: "BL",
rr_item_type: "G",
accountdesc: "BODY LABOR"
}
}
],
opCode: "51DOZ"
});
expect(payload.rolabor).toEqual({
ops: [
{
opCode: "51DOZ",
jobNo: "1",
custPayTypeFlag: "C",
custTxblNtxblFlag: "T",
bill: {
payType: "Cust",
jobTotalHrs: "2",
billTime: "2",
billRate: "120.00"
},
amount: {
payType: "Cust",
amtType: "Job",
custPrice: "240.00",
totalAmt: "240.00"
}
}
]
});
});
});