Merged in feature/IO-3624-Shop-Config-UX-Refresh (pull request #3180)

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

View File

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

View File

@@ -4,20 +4,203 @@ import AlertComponent from "../alert/alert.component";
import "./form-fields-changed.styles.scss"; import "./form-fields-changed.styles.scss";
import Prompt from "../../utils/prompt"; import Prompt from "../../utils/prompt";
export default function FormsFieldChanged({ form, skipPrompt }) { export default function FormsFieldChanged({ form, skipPrompt, onErrorNavigate, onReset, onDirtyChange }) {
const { t } = useTranslation(); 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 = () => { 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 <></>; //if (!form.isFieldsTouched()) return <></>;
return ( return (
<Form.Item className="form-fields-changed" shouldUpdate style={{ margin: 0, padding: 0, minHeight: "unset" }}> <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()) if (form.isFieldsTouched())
return ( 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")} /> <Prompt when={!skipPrompt} beforeUnload={true} message={t("general.messages.unsavedchangespopup")} />
<AlertComponent <AlertComponent
type="warning" type="warning"
@@ -39,10 +222,35 @@ export default function FormsFieldChanged({ form, skipPrompt }) {
{errors.length > 0 && ( {errors.length > 0 && (
<AlertComponent <AlertComponent
type="error" type="error"
message={t("general.labels.validationerror")} title={t("general.labels.validationerror")}
description={ description={
<div> <div className="form-fields-changed__error-groups">
<ul>{errors.map((e, idx) => e.errors.map((e2, idx2) => <li key={`${idx}${idx2}`}>{e2}</li>))}</ul> {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> </div>
} }
showIcon showIcon

View File

@@ -4,4 +4,47 @@
min-height: unset !important; 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 i18n from "i18next";
import parsePhoneNumber from "libphonenumber-js"; import parsePhoneNumber from "libphonenumber-js";
import { forwardRef, useMemo, useState } from "react";
import "./phone-form-item.styles.scss"; 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; 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

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

View File

@@ -13,6 +13,12 @@
--imex-form-surface: #fafafa; /* subtle contrast vs white page */ --imex-form-surface: #fafafa; /* subtle contrast vs white page */
--imex-form-surface-head: #f5f5f5; /* header strip */ --imex-form-surface-head: #f5f5f5; /* header strip */
--imex-form-surface-border: #d9d9d9; /* matches AntD-ish border */ --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 */ /* 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: 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-head: rgba(255, 255, 255, 0.06); /* slightly stronger for header strip */
--imex-form-surface-border: rgba(5, 5, 5, 0.12); --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 { .imex-form-row {
@@ -38,18 +50,111 @@ html[data-theme="dark"] {
border-color: var(--imex-form-surface-border); 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 { .ant-card-head {
background: var(--imex-form-surface-head); background: var(--imex-form-surface-head);
border-bottom-color: var(--imex-form-surface-border); 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 { .ant-card-body {
background: var(--imex-form-surface); 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 */ /* Optional: tighter spacing on phones for better space usage */
@media (max-width: 575px) { @media (max-width: 575px) {
.ant-card-head { &:not(.imex-form-row--title-only) .ant-card-head {
padding-inline: 12px; padding-inline: 12px;
padding-block: 12px; padding-block: 12px;
} }
@@ -70,6 +175,14 @@ html[data-theme="dark"] {
width: 100%; 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 */ /* Better form item spacing on mobile */
@media (max-width: 575px) { @media (max-width: 575px) {
.ant-form-item { .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 { DeleteFilled, DownOutlined, WarningFilled } from "@ant-design/icons";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; 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 { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import CurrencyInput from "../form-items-formatted/currency-form-item.component"; import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.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 LayoutFormRow from "../layout-form-row/layout-form-row.component";
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component"; import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
import PartsOrderModalPriceChange from "./parts-order-modal-price-change.component"; import PartsOrderModalPriceChange from "./parts-order-modal-price-change.component";
@@ -50,6 +51,7 @@ export function PartsOrderModalComponent({
}); });
const { t } = useTranslation(); const { t } = useTranslation();
const partsOrderLines = Form.useWatch(["parts_order_lines", "data"], form) || [];
const handleClick = ({ item }) => { const handleClick = ({ item }) => {
form.setFieldsValue({ comments: item.props.value }); form.setFieldsValue({ comments: item.props.value });
}; };
@@ -128,10 +130,38 @@ export function PartsOrderModalComponent({
{(fields, { remove, move }) => { {(fields, { remove, move }) => {
return ( return (
<div> <div>
{fields.map((field, index) => ( {fields.map((field, index) => {
<Form.Item required={false} key={field.key}> const partsOrderLine = partsOrderLines[field.name] || {};
<div style={{ display: "flex" }}>
<LayoutFormRow grow noDivider style={{ flex: 1 }}> 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 <Form.Item
//span={8} //span={8}
label={t("parts_orders.fields.line_desc")} label={t("parts_orders.fields.line_desc")}
@@ -220,20 +250,9 @@ export function PartsOrderModalComponent({
</Form.Item> </Form.Item>
)} )}
</LayoutFormRow> </LayoutFormRow>
<Space wrap size="small" align="center"> </Form.Item>
<div> );
<DeleteFilled })}
style={{ margin: "1rem" }}
onClick={() => {
remove(field.name);
}}
/>
</div>
<FormListMoveArrows move={move} index={index} total={fields.length} />
</Space>
</div>
</Form.Item>
))}
</div> </div>
); );
}} }}

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,13 @@ import { DeleteFilled } from "@ant-design/icons";
import { Button, Form, Input, Space } from "antd"; import { Button, Form, Input, Space } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.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 LayoutFormRow from "../layout-form-row/layout-form-row.component";
export default function PartsOrderCommentsComponent() { export default function PartsOrderCommentsComponent() {
const { t } = useTranslation(); const { t } = useTranslation();
const form = Form.useFormInstance();
const orderComments = Form.useWatch(["md_parts_order_comment"], form) || [];
return ( return (
<div> <div>
@@ -14,45 +17,65 @@ export default function PartsOrderCommentsComponent() {
{(fields, { add, remove, move }) => { {(fields, { add, remove, move }) => {
return ( return (
<div> <div>
{fields.map((field, index) => ( {fields.map((field, index) => {
<Form.Item key={field.key}> const comment = orderComments[field.name] || {};
<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>
<Space wrap> return (
<DeleteFilled <Form.Item key={field.key}>
onClick={() => { <LayoutFormRow
remove(field.name); noDivider
}} title={getFormListItemTitle(
/> t("parts_orders.fields.comments"),
<FormListMoveArrows move={move} index={index} total={fields.length} /> index,
</Space> comment.label,
</LayoutFormRow> comment.comment
</Form.Item> )}
))} 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> <Form.Item>
<Button <Button
type="dashed" 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 DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
export default function ShopEmployeeAddVacation({ employee }) { export default function ShopEmployeeAddVacation({ employee, buttonProps }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [insertVacation] = useMutation(INSERT_VACATION); const [insertVacation] = useMutation(INSERT_VACATION);
@@ -117,7 +117,7 @@ export default function ShopEmployeeAddVacation({ employee }) {
return ( return (
<Popover content={overlay} open={visibility}> <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")} {t("employees.actions.addvacation")}
</Button> </Button>
</Popover> </Popover>

View File

@@ -1,11 +1,10 @@
import { DeleteFilled } from "@ant-design/icons"; import { DeleteFilled } from "@ant-design/icons";
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react"; import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-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 ResponsiveTable from "../responsive-table/responsive-table.component";
import { useForm } from "antd/es/form/Form";
import queryString from "query-string"; import queryString from "query-string";
import { useEffect } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
@@ -26,9 +25,24 @@ import { DateFormatter } from "../../utils/DateFormatter";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx"; 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 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 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 ShopEmployeeAddVacation from "./shop-employees-add-vacation.component";
import FormItemEmail from "../form-items-formatted/email-form-item.component.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
@@ -37,19 +51,37 @@ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export function ShopEmployeesFormComponent({ bodyshop }) { export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDirty }) {
const { t } = useTranslation(); 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 history = useNavigate();
const search = queryString.parse(useLocation().search); const search = queryString.parse(useLocation().search);
const [deleteVacation] = useMutation(DELETE_VACATION); 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 }, variables: { id: search.employeeId },
skip: !search.employeeId || search.employeeId === "new", skip: !search.employeeId || search.employeeId === "new",
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only" nextFetchPolicy: "network-only"
}); });
const notification = useNotification(); 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 { const {
treatments: { Enhanced_Payroll } treatments: { Enhanced_Payroll }
@@ -59,13 +91,46 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
splitKey: bodyshop.imexshopid splitKey: bodyshop.imexshopid
}); });
const updateDirtyState = useCallback(
(nextDirtyState) => {
setInternalIsDirty(nextDirtyState);
onDirtyChange?.(nextDirtyState);
},
[onDirtyChange]
);
const client = useApolloClient(); const client = useApolloClient();
useEffect(() => { const clearEmployeeFormMeta = useCallback(() => {
if (data && data.employees_by_pk) form.setFieldsValue(data.employees_by_pk); const fieldMeta = form.getFieldsError().map(({ name }) => ({
else { name,
form.resetFields(); 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]);
useEffect(() => {
resetEmployeeFormToCurrentData();
}, [resetEmployeeFormToCurrentData, search.employeeId]);
const [updateEmployee] = useMutation(UPDATE_EMPLOYEE); const [updateEmployee] = useMutation(UPDATE_EMPLOYEE);
const [insertEmployees] = useMutation(INSERT_EMPLOYEES); const [insertEmployees] = useMutation(INSERT_EMPLOYEES);
@@ -85,6 +150,8 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
} }
}) })
.then(() => { .then(() => {
updateDirtyState(false);
void refetch();
notification.success({ notification.success({
title: t("employees.successes.save") title: t("employees.successes.save")
}); });
@@ -104,6 +171,7 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
variables: { employees: [{ ...values, shopid: bodyshop.id }] }, variables: { employees: [{ ...values, shopid: bodyshop.id }] },
refetchQueries: ["QUERY_EMPLOYEES"] refetchQueries: ["QUERY_EMPLOYEES"]
}).then((r) => { }).then((r) => {
updateDirtyState(false);
search.employeeId = r.data.insert_employees.returning[0].id; search.employeeId = r.data.insert_employees.returning[0].id;
history({ search: queryString.stringify(search) }); history({ search: queryString.stringify(search) });
notification.success({ notification.success({
@@ -141,6 +209,8 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
key: "actions", key: "actions",
render: (text, record) => ( render: (text, record) => (
<Button <Button
type="text"
danger
onClick={async () => { onClick={async () => {
await deleteVacation({ await deleteVacation({
variables: { id: record.id }, variables: { id: record.id },
@@ -168,225 +238,354 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
return ( return (
<Card <Card
title={employeeCardTitle}
extra={ extra={
<Button type="primary" onClick={() => form.submit()}> <Button type="primary" onClick={() => form.submit()} disabled={!resolvedIsDirty} style={{ minWidth: 170 }}>
{t("general.actions.save")} {t("employees.actions.save_employee")}
</Button> </Button>
} }
> >
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}> <Form
<LayoutFormRow> onFinish={handleFinish}
<Form.Item autoComplete={"off"}
name="first_name" layout="vertical"
label={t("employees.fields.first_name")} form={form}
rules={[ onValuesChange={() => {
{ updateDirtyState(form.isFieldsTouched());
required: true }}
//message: t("general.validation.required"), >
} <FormsFieldChanged form={form} onReset={resetEmployeeFormToCurrentData} onDirtyChange={updateDirtyState} />
]} <LayoutFormRow
> title={
<Input /> <div
</Form.Item> style={{
<Form.Item ...INLINE_TITLE_ROW_STYLE,
label={t("employees.fields.last_name")} justifyContent: "space-between"
name="last_name" }}
rules={[ >
{ <div
required: true style={{
//message: t("general.validation.required"), ...INLINE_TITLE_TEXT_STYLE,
} marginRight: "auto"
]} }}
> >
<Input /> {t("bodyshop.labels.employee_options")}
</Form.Item> </div>
<Form.Item <div
name="employee_number" style={{
label={t("employees.fields.employee_number")} display: "flex",
validateTrigger="onBlur" alignItems: "center",
hasFeedback gap: 4,
rules={[ flexWrap: "wrap",
{ marginLeft: "auto"
required: true }}
//message: t("general.validation.required"), >
}, <div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
() => ({ <div
async validator(rule, value) { style={{
if (value) { ...INLINE_TITLE_SWITCH_GROUP_STYLE
const response = await client.query({ }}
query: CHECK_EMPLOYEE_NUMBER, >
variables: { <div style={INLINE_TITLE_LABEL_STYLE}>{t("employees.labels.active")}</div>
employeenumber: value <Form.Item noStyle valuePropName="checked" name="active">
} <Switch />
}); </Form.Item>
</div>
if (response.data.employees_aggregate.aggregate.count === 0) { <div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
return Promise.resolve(); <div
} else if ( style={{
response.data.employees_aggregate.nodes.length === 1 && ...INLINE_TITLE_SWITCH_GROUP_STYLE
response.data.employees_aggregate.nodes[0].id === form.getFieldValue("id") }}
) { >
return Promise.resolve(); <div style={INLINE_TITLE_LABEL_STYLE}>{t("employees.fields.flat_rate")}</div>
} <Form.Item noStyle valuePropName="checked" name="flat_rate">
return Promise.reject(t("employees.validation.unique_employee_number")); <Switch />
} else { </Form.Item>
return Promise.resolve(); </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>
<Input /> </Col>
</Form.Item> <Col {...employeeOptionsColProps}>
<Form.Item <Form.Item
label={t("employees.fields.pin")} label={t("employees.fields.last_name")}
name="pin" name="last_name"
rules={[ rules={[
{ {
required: true required: true
//message: t("general.validation.required"), //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>
<Input /> </Col>
</Form.Item> <Col {...employeeOptionsColProps}>
<Form.Item label={t("employees.fields.external_id")} name="external_id"> <Form.Item
<Input /> name="employee_number"
</Form.Item> 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> </LayoutFormRow>
<Form.List name={["rates"]}> <Form.List name={["rates"]}>
{(fields, { add, remove, move }) => { {(fields, { add, remove, move }) => {
return ( return (
<div> <LayoutFormRow
{fields.map((field, index) => ( title={t("bodyshop.labels.employee_rates")}
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}> actions={[
<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>
<Button <Button
type="dashed" key="add-rate"
type="primary"
block
onClick={() => { onClick={() => {
add(); add();
}} }}
style={{ width: "100%" }}
id="add-employee-rate-button" 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> </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.List>
</Form> </Form>
<ResponsiveTable <LayoutFormRow
title={() => <ShopEmployeeAddVacation employee={data && data.employees_by_pk} />} title={t("bodyshop.labels.employee_vacation")}
columns={columns} actions={[
mobileColumnKeys={["start", "length", "actions"]} <ShopEmployeeAddVacation
rowKey={"id"} key="add-vacation"
dataSource={data?.employees_by_pk?.employee_vacations ?? []} 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> </Card>
); );
} }

View File

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

View File

@@ -1,29 +1,101 @@
import { Drawer, Form, Grid } from "antd";
import { useQuery } from "@apollo/client/react"; import { useQuery } from "@apollo/client/react";
import queryString from "query-string";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { QUERY_EMPLOYEES } from "../../graphql/employees.queries"; import { QUERY_EMPLOYEES } from "../../graphql/employees.queries";
import useConfirmDirtyFormNavigation from "../../hooks/useConfirmDirtyFormNavigation.jsx";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import ShopEmployeesFormComponent from "./shop-employees-form.component"; import ShopEmployeesFormComponent from "./shop-employees-form.component";
import ShopEmployeesListComponent from "./shop-employees-list.component"; import ShopEmployeesListComponent from "./shop-employees-list.component";
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component"; import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
import "./shop-employees.styles.scss";
const mapStateToProps = createStructuredSelector({}); const mapStateToProps = createStructuredSelector({});
function ShopEmployeesContainer() { 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, { const { loading, error, data } = useQuery(QUERY_EMPLOYEES, {
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "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" />; if (error) return <AlertComponent title={error.message} type="error" />;
return ( return (
<div> <RbacWrapper action="employees:page">
<RbacWrapper action="employees:page"> <div className="shop-employees-layout">
<ShopEmployeesListComponent employees={data ? data.employees : []} loading={loading} /> <div className="shop-employees-layout__list">
<ShopEmployeesFormComponent /> <ShopEmployeesListComponent
</RbacWrapper> employees={data ? data.employees : []}
</div> 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 { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Card, Tabs } from "antd"; import { Button, Card, Tabs } from "antd";
import queryString from "query-string"; import queryString from "query-string";
import { useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom"; 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 ShopInfoRoGuard from "./shop-info.roguard.component";
import ShopInfoROStatusComponent from "./shop-info.rostatus.component"; import ShopInfoROStatusComponent from "./shop-info.rostatus.component";
import ShopInfoSchedulingComponent from "./shop-info.scheduling.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 ShopInfoSpeedPrint from "./shop-info.speedprint.component";
import ShopInfoTaskPresets from "./shop-info.task-presets.component"; import ShopInfoTaskPresets from "./shop-info.task-presets.component";
import ShopInfoIntellipay from "./shop-intellipay-config.component"; import ShopInfoIntellipay from "./shop-intellipay-config.component";
@@ -33,7 +35,7 @@ const mapDispatchToProps = () => ({
}); });
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoComponent); export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoComponent);
export function ShopInfoComponent({ bodyshop, form, saveLoading }) { export function ShopInfoComponent({ bodyshop, form, saveLoading, isDirty }) {
const { const {
treatments: { CriticalPartsScanning, Enhanced_Payroll } treatments: { CriticalPartsScanning, Enhanced_Payroll }
} = useTreatmentsWithConfig({ } = useTreatmentsWithConfig({
@@ -47,6 +49,7 @@ export function ShopInfoComponent({ bodyshop, form, saveLoading }) {
const history = useNavigate(); const history = useNavigate();
const location = useLocation(); const location = useLocation();
const search = queryString.parse(location.search); const search = queryString.parse(location.search);
const tabsRef = useRef(null);
const tabItems = [ const tabItems = [
{ {
@@ -154,23 +157,35 @@ export function ShopInfoComponent({ bodyshop, form, saveLoading }) {
] ]
: []) : [])
]; ];
const activeTabKey = search.subtab || tabItems[0]?.key;
return ( return (
<Card <Card
title={<ShopInfoSectionNavigator tabsRef={tabsRef} activeTabKey={activeTabKey} />}
extra={ extra={
<Button type="primary" loading={saveLoading} onClick={() => form.submit()} id="shop-info-save-button"> <Button
{t("general.actions.save")} 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> </Button>
} }
> >
<Tabs <div ref={tabsRef}>
defaultActiveKey={search.subtab} <Tabs
onChange={(key) => activeKey={activeTabKey}
history({ onChange={(key) =>
search: `?tab=${search.tab}&subtab=${key}` history({
}) search: `?tab=${search.tab}&subtab=${key}`
} })
items={tabItems} }
/> items={tabItems}
/>
</div>
</Card> </Card>
); );
} }

View File

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

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery } from "@apollo/client/react"; import { useMutation, useQuery } from "@apollo/client/react";
import { Form } from "antd"; import { Form } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
@@ -15,6 +15,7 @@ import { FEATURE_CONFIGS, useFormDataPreservation } from "./useFormDataPreservat
export default function ShopInfoContainer() { export default function ShopInfoContainer() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const { t } = useTranslation(); const { t } = useTranslation();
const [isShopInfoDirty, setIsShopInfoDirty] = useState(false);
const [saveLoading, setSaveLoading] = useState(false); const [saveLoading, setSaveLoading] = useState(false);
const [updateBodyshop] = useMutation(UPDATE_SHOP); const [updateBodyshop] = useMutation(UPDATE_SHOP);
const { loading, error, data, refetch } = useQuery(QUERY_BODYSHOP, { const { loading, error, data, refetch } = useQuery(QUERY_BODYSHOP, {
@@ -33,7 +34,10 @@ export default function ShopInfoContainer() {
return acc; 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 // Use form data preservation for all shop-info features
const { createSubmissionHandler, preserveHiddenFormData } = useFormDataPreservation( const { createSubmissionHandler, preserveHiddenFormData } = useFormDataPreservation(
@@ -51,7 +55,10 @@ export default function ShopInfoContainer() {
}) })
.then(() => { .then(() => {
notification.success({ title: t("bodyshop.successes.save") }); notification.success({ title: t("bodyshop.successes.save") });
refetch().then(() => form.resetFields()); refetch().then(() => {
form.resetFields();
setIsShopInfoDirty(false);
});
}) })
.catch((error) => { .catch((error) => {
notification.error({ notification.error({
@@ -66,6 +73,7 @@ export default function ShopInfoContainer() {
form.resetFields(); form.resetFields();
// After reset, re-apply hidden field preservation so values aren't wiped // After reset, re-apply hidden field preservation so values aren't wiped
preserveHiddenFormData(); preserveHiddenFormData();
setIsShopInfoDirty(false);
}, [data, form, preserveHiddenFormData]); }, [data, form, preserveHiddenFormData]);
if (error) return <AlertComponent title={error.message} type="error" />; if (error) return <AlertComponent title={error.message} type="error" />;
@@ -76,6 +84,9 @@ export default function ShopInfoContainer() {
layout="vertical" layout="vertical"
autoComplete="new-password" autoComplete="new-password"
onFinish={handleFinish} onFinish={handleFinish}
onValuesChange={() => {
setIsShopInfoDirty(form.isFieldsTouched());
}}
initialValues={ initialValues={
data data
? data?.bodyshops?.[0]?.accountingconfig?.ClosingPeriod ? data?.bodyshops?.[0]?.accountingconfig?.ClosingPeriod
@@ -99,8 +110,8 @@ export default function ShopInfoContainer() {
: null : null
} }
> >
<FormsFieldChanged form={form} /> <FormsFieldChanged form={form} onDirtyChange={setIsShopInfoDirty} />
<ShopInfoComponent form={form} saveLoading={saveLoading} /> <ShopInfoComponent form={form} saveLoading={saveLoading} isDirty={isShopInfoDirty} />
</Form> </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 { TemplateList } from "../../utils/TemplateConstants";
import ConfigFormTypes from "../config-form-components/config-form-types"; import ConfigFormTypes from "../config-form-components/config-form-types";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.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 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` const SelectorDiv = styled.div`
.ant-form-item .ant-select { .ant-form-item .ant-select {
@@ -19,306 +31,386 @@ export default function ShopInfoIntakeChecklistComponent({ form }) {
const TemplateListGenerated = TemplateList(); const TemplateListGenerated = TemplateList();
return ( return (
<div> <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> <SelectorDiv>
<Form.Item <LayoutFormRow header={t("bodyshop.labels.intake_delivery")} id="intake-delivery">
name={["intakechecklist", "templates"]} <Form.Item
label={t("bodyshop.fields.intake.templates")} col={{ xs: 24, sm: 24, md: 24, lg: 24, xl: 24, xxl: 24 }}
rules={[ name={["intakechecklist", "templates"]}
{ label={t("bodyshop.fields.intake.templates")}
required: true, rules={[
//message: t("general.validation.required"), {
type: "array" required: true,
} //message: t("general.validation.required"),
]} type: "array"
> }
<Select ]}
mode="multiple" >
options={Object.keys(TemplateListGenerated).map((i) => ({ <Select
value: TemplateListGenerated[i].key, mode="multiple"
label: TemplateListGenerated[i].title options={Object.keys(TemplateListGenerated).map((i) => ({
}))} value: TemplateListGenerated[i].key,
/> label: TemplateListGenerated[i].title
</Form.Item> }))}
<Form.Item />
name={["intakechecklist", "next_contact_hours"]} </Form.Item>
label={t("bodyshop.fields.intake.next_contact_hours")} <Form.Item
> col={{ xs: 24, sm: 24, md: 24, lg: 24, xl: 24, xxl: 24 }}
<InputNumber min={0} precision={0} /> name={["deliverchecklist", "templates"]}
</Form.Item> 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> </SelectorDiv>
<Form.List name={["intakechecklist", "form"]}>
<LayoutFormRow header={t("bodyshop.labels.deliverchecklist")} id="deliverchecklist"> {(fields, { add, remove, move }) => {
<Form.List name={["deliverchecklist", "form"]}> return (
{(fields, { add, remove, move }) => { <LayoutFormRow
return ( 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> <div>
{fields.map((field, index) => ( {fields.length === 0 ? (
<Form.Item key={field.key}> <ConfigListEmptyState actionLabel={t("bodyshop.actions.add_intake_checklist_item")} />
<LayoutFormRow noDivider> ) : (
<Form.Item fields.map((field, index) => {
label={t("jobs.fields.intake.name")} return (
key={`${index}named`} <Form.Item noStyle key={field.key}>
name={[field.name, "name"]} <InlineValidatedFormRow
rules={[ form={form}
{ errorNames={[["intakechecklist", "form", field.name, "name"]]}
required: true noDivider
//message: t("general.validation.required"), 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={
<Input /> <Space align="center" size="small">
</Form.Item> <Button
type="text"
<Form.Item danger
label={t("jobs.fields.intake.type")} icon={<DeleteFilled />}
key={`${index}typed`} onClick={() => {
name={[field.name, "type"]} remove(field.name);
rules={[ }}
{ />
required: true <FormListMoveArrows
//message: t("general.validation.required"), move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
} }
]} >
> <Form.Item
<Select options={Object.keys(ConfigFormTypes).map((i) => ({ value: i, label: i }))} /> label={t("jobs.fields.intake.type")}
</Form.Item> 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 <Form.Item shouldUpdate>
label={t("jobs.fields.intake.label")} {() => {
key={`${index}labeld`} if (form.getFieldValue(["intakechecklist", "form", index, "type"]) !== "slider")
name={[field.name, "label"]} return null;
rules={[ return (
{ <>
required: true <Form.Item
//message: t("general.validation.required"), label={t("jobs.fields.intake.min")}
} key={`${index}min`}
]} name={[field.name, "min"]}
> dependencies={[[field.name, "type"]]}
<Input /> 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>
);
<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> </div>
); </LayoutFormRow>
}} );
</Form.List> }}
</LayoutFormRow> </Form.List>
<SelectorDiv> <Form.List name={["deliverchecklist", "form"]}>
<Form.Item {(fields, { add, remove, move }) => {
name={["deliverchecklist", "templates"]} return (
label={t("bodyshop.fields.deliver.templates")} <LayoutFormRow
rules={[ header={t("bodyshop.labels.deliverchecklist")}
{ id="deliverchecklist"
required: true, actions={[
//message: t("general.validation.required"), <Button
type: "array" key="add-delivery-checklist-item"
} type="primary"
]} block
> onClick={() => {
<Select add();
mode="multiple" }}
options={Object.keys(TemplateListGenerated).map((i) => ({ >
value: TemplateListGenerated[i].key, {t("bodyshop.actions.add_delivery_checklist_item")}
label: TemplateListGenerated[i].title </Button>
}))} ]}
/> >
</Form.Item> <div>
<Form.Item {fields.length === 0 ? (
name={["deliverchecklist", "actual_delivery"]} <ConfigListEmptyState actionLabel={t("bodyshop.actions.add_delivery_checklist_item")} />
label={t("bodyshop.fields.deliver.require_actual_delivery_date")} ) : (
rules={[ fields.map((field, index) => {
{ return (
required: true <Form.Item noStyle key={field.key}>
//message: t("general.validation.required"), <InlineValidatedFormRow
} form={form}
]} errorNames={[["deliverchecklist", "form", field.name, "name"]]}
> noDivider
<Switch /> title={
</Form.Item> <div style={INLINE_TITLE_ROW_STYLE}>
</SelectorDiv> <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> </div>
); );
} }

View File

@@ -3,344 +3,392 @@ import { Button, Form, Input, Space } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import CurrencyInput from "../form-items-formatted/currency-form-item.component"; import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.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 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() { export default function ShopInfoLaborRates() {
const { t } = useTranslation(); const { t } = useTranslation();
const form = Form.useFormInstance();
return ( return (
<> <>
<LayoutFormRow header={t("bodyshop.labels.shoprates")}> <LayoutFormRow header={t("bodyshop.labels.shoprates")}>
<Form.Item label={t("jobs.fields.rate_ats")} name={["shoprates", "rate_ats"]}> <Form.Item label={t("jobs.fields.rate_ats")} name={["shoprates", "rate_ats"]}>
<CurrencyInput min={0} /> <CurrencyInput prefix="$" min={0} />
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.rate_ats_flat")} name={["shoprates", "rate_ats_flat"]}> <Form.Item label={t("jobs.fields.rate_ats_flat")} name={["shoprates", "rate_ats_flat"]}>
<CurrencyInput min={0} /> <CurrencyInput prefix="$" min={0} />
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow header={t("bodyshop.labels.laborrates")}> <Form.List name={["md_labor_rates"]}>
<Form.List name={["md_labor_rates"]}> {(fields, { add, remove, move }) => {
{(fields, { add, remove, move }) => { return (
return ( <LayoutFormRow
header={t("bodyshop.labels.laborrates")}
actions={[
<Button
key="add-labor-rate"
type="primary"
block
onClick={() => {
add();
}}
>
{t("bodyshop.actions.newlaborrate")}
</Button>
]}
>
<div> <div>
{fields.map((field, index) => ( {fields.length === 0 ? (
<Form.Item key={field.key}> <ConfigListEmptyState actionLabel={t("bodyshop.actions.newlaborrate")} />
<LayoutFormRow noDivider={index === 0}> ) : (
<Form.Item fields.map((field, index) => {
label={t("jobs.fields.labor_rate_desc")} return (
key={`${index}rate_label`} <Form.Item noStyle key={field.key}>
name={[field.name, "rate_label"]} <InlineValidatedFormRow
rules={[ form={form}
{ errorNames={[["md_labor_rates", field.name, "rate_label"]]}
required: true noDivider={index === 0}
//message: t("general.validation.required"), 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>
} }
]} wrapTitle
> extra={
<Input /> <Space align="center" size="small">
</Form.Item> <Button
<Form.Item type="text"
label={t("jobs.fields.rate_laa")} danger
key={`${index}rate_laa`} icon={<DeleteFilled />}
name={[field.name, "rate_laa"]} onClick={() => {
rules={[ remove(field.name);
{ }}
required: true />
//message: t("general.validation.required"), <FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
} }
]} >
> <Form.Item
<CurrencyInput min={0} /> label={t("jobs.fields.rate_laa")}
</Form.Item> key={`${index}rate_laa`}
<Form.Item name={[field.name, "rate_laa"]}
label={t("jobs.fields.rate_lab")} rules={[
key={`${index}rate_lab`} {
name={[field.name, "rate_lab"]} required: true
rules={[ //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 // <Form.Item
//message: t("general.validation.required"), // 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")}
<CurrencyInput min={0} /> 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>
<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> </div>
); </LayoutFormRow>
}} );
</Form.List> }}
</LayoutFormRow> </Form.List>
</> </>
); );
} }

View File

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

View File

@@ -3,7 +3,19 @@ import { Button, Col, Form, Input, Row, Select, Space, Switch } from "antd";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.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 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"; import i18n from "i18next";
const predefinedPartTypes = ["PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG"]; const predefinedPartTypes = ["PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG"];
@@ -68,195 +80,223 @@ export default function ShopInfoPartsScan({ form }) {
return ( return (
<div> <div>
<LayoutFormRow header={t("bodyshop.labels.md_parts_scan")}> <Form.List name={["md_parts_scan"]}>
<Form.List name={["md_parts_scan"]}> {(fields, { add, remove, move }) => (
{(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> <div>
{fields.map((field, index) => { {fields.length === 0 ? (
const selectedField = watchedFields?.[index]?.field || "line_desc"; <ConfigListEmptyState actionLabel={t("bodyshop.actions.addpartsrule")} />
const fieldType = getFieldType(selectedField); ) : (
fields.map((field, index) => {
const selectedField = watchedFields?.[index]?.field || "line_desc";
const fieldType = getFieldType(selectedField);
return ( return (
<Form.Item key={field.key}> <Form.Item noStyle key={field.key}>
<Row gutter={[16, 16]} align="middle"> <InlineValidatedFormRow
{/* Select Field */} form={form}
<Col span={6}> errorNames={[["md_parts_scan", field.name, "field"]]}
<Form.Item noDivider
label={t("bodyshop.fields.md_parts_scan.field")} title={
name={[field.name, "field"]} <div style={INLINE_TITLE_ROW_STYLE}>
rules={[ <InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />
{ <div style={INLINE_TITLE_GROUP_STYLE}>
required: true, <div style={INLINE_TITLE_LABEL_STYLE}>{t("bodyshop.fields.md_parts_scan.field")}</div>
message: t("general.validation.required", { <Form.Item
label: t("bodyshop.fields.md_parts_scan.field") noStyle
}) name={[field.name, "field"]}
} rules={[
]} {
> required: true,
<Select message: t("general.validation.required", {
options={fieldSelectOptions} label: t("bodyshop.fields.md_parts_scan.field")
onChange={() => { })
form.setFields([ }
{ name: ["md_parts_scan", index, "operation"], value: "contains" }, ]}
{ name: ["md_parts_scan", index, "value"], value: undefined } >
]); <Select
}} options={fieldSelectOptions}
/> onChange={() => {
</Form.Item> form.setFields([
</Col> { 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 */} {/* Value */}
{fieldType !== "predefined" && fieldType && ( {fieldType && (
<Col span={6}> <Col span={6}>
<Form.Item <Form.Item
label={t("bodyshop.fields.md_parts_scan.operation")} label={t("bodyshop.fields.md_parts_scan.value")}
name={[field.name, "operation"]} name={[field.name, "value"]}
rules={[ rules={[
{ {
required: true, required: true,
message: t("general.validation.required", { message: t("general.validation.required", {
label: t("bodyshop.fields.md_parts_scan.operation") label: t("bodyshop.fields.md_parts_scan.value")
}) })
} }
]} ]}
> >
<Select options={operationOptions[fieldType]} /> {fieldType === "predefined" ? (
</Form.Item> <Select
</Col> options={
)} selectedField === "part_type"
? predefinedPartTypes.map((type) => ({
label: type,
value: type
}))
: predefinedModLbrTypes.map((type) => ({
label: type,
value: type
}))
}
/>
) : (
<Input />
)}
</Form.Item>
</Col>
)}
{/* Value */} {/* Update Field */}
{fieldType && ( <Col span={4}>
<Col span={6}> <Form.Item
<Form.Item label={t("bodyshop.fields.md_parts_scan.update_field")}
label={t("bodyshop.fields.md_parts_scan.value")} name={[field.name, "update_field"]}
name={[field.name, "value"]} >
rules={[
{
required: true,
message: t("general.validation.required", {
label: t("bodyshop.fields.md_parts_scan.value")
})
}
]}
>
{fieldType === "predefined" ? (
<Select <Select
options={ options={fieldSelectOptions}
selectedField === "part_type" allowClear
? predefinedPartTypes.map((type) => ({ onClear={() =>
label: type, form.setFields([{ name: ["md_parts_scan", index, "update_field"], value: null }])
value: type
}))
: predefinedModLbrTypes.map((type) => ({
label: type,
value: type
}))
} }
/> />
) : ( </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 /> <Input />
)} </Form.Item>
</Form.Item> </Col>
</Col> </Row>
)} </InlineValidatedFormRow>
</Form.Item>
{/* 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>
</div> </div>
)} </LayoutFormRow>
</Form.List> )}
</LayoutFormRow> </Form.List>
</div> </div>
); );
} }

View File

@@ -27,7 +27,7 @@ export function ShopInfoRbacComponent({ bodyshop }) {
}); });
return ( return (
<RbacWrapper action="shop:rbac"> <RbacWrapper action="shop:rbac">
<LayoutFormRow> <LayoutFormRow header={t("bodyshop.labels.rbac_options")}>
{[ {[
...(HasFeatureAccess({ featureName: "export", bodyshop }) ...(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 { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -6,6 +6,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr"; import InstanceRenderManager from "../../utils/instanceRenderMgr";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js"; import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
import "./shop-info.responsibilitycenters.taxes.styles.scss";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser //currentUser: selectCurrentUser
@@ -16,53 +17,102 @@ const mapDispatchToProps = () => ({
}); });
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoResponsibilityCenters); 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 }) { export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
const { t } = useTranslation(); const { t } = useTranslation();
//Iteratively build the form items. const profileTaxCards = [];
const formItems = []; for (let typeNum = 1; typeNum <= 5; typeNum++) {
for (let tyCounter = 1; tyCounter <= 5; tyCounter++) { const rootTaxItems = getRootTaxFormItems({ typeNum, bodyshop, t });
const section = [];
section.push( profileTaxCards.push(
TaxFormItems({ <LayoutFormRow key={`profile-tax-type-${typeNum}`} header={t("bodyshop.labels.responsibilitycenters.tax_type_card", { typeNum })}>
typeNum: tyCounter, <div style={{ display: "grid", rowGap: 12 }}>
rootElements: true, <Row gutter={[16, 16]} wrap>
bodyshop {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 ( return (
<> <>
<Divider titlePlacement="left" orientation="horizontal" style={{ marginTop: ".8rem" }}> <LayoutFormRow header={t("jobs.labels.cieca_pft")}>
{t("jobs.labels.cieca_pft")} <div>{profileTaxCards}</div>
</Divider> </LayoutFormRow>
{formItems}
<Collapse <LayoutFormRow header={t("bodyshop.labels.responsibilitycenters.default_tax_setup")}>
items={[ <Collapse
{ items={[
key: "cieca_pfl", {
label: t("jobs.labels.cieca_pfl"), key: "cieca_pfl",
forceRender: true, label: t("jobs.labels.cieca_pfl"),
children: ( forceRender: true,
<> children: (
<>
<LayoutFormRow header={t("joblines.fields.lbr_types.LAB")}> <LayoutFormRow header={t("joblines.fields.lbr_types.LAB")}>
<Form.Item <Form.Item
label={t("jobs.fields.cieca_pfl.lbr_adjp")} label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAB", "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>
<Form.Item <Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")} 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> </Form.Item>
); );
}} }}
@@ -135,7 +185,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")} label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAD", "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>
<Form.Item <Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")} 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> </Form.Item>
); );
}} }}
@@ -208,7 +258,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")} label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAE", "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>
<Form.Item <Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")} 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> </Form.Item>
); );
}} }}
@@ -281,7 +331,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")} label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAF", "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>
<Form.Item <Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")} 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> </Form.Item>
); );
}} }}
@@ -354,7 +404,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")} label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAG", "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>
<Form.Item <Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")} 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> </Form.Item>
); );
}} }}
@@ -427,7 +477,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")} label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAM", "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>
<Form.Item <Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")} 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> </Form.Item>
); );
}} }}
@@ -500,7 +550,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")} label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAR", "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>
<Form.Item <Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")} 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> </Form.Item>
); );
}} }}
@@ -573,7 +623,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")} label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAS", "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>
<Form.Item <Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")} 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> </Form.Item>
); );
}} }}
@@ -740,7 +790,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.materials.mat_adjp")} label={t("jobs.fields.materials.mat_adjp")}
name={["md_responsibility_centers", "cieca_pfm", "MAPA", "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>
<Form.Item <Form.Item
label={t("jobs.fields.materials.tax_ind")} 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> </Form.Item>
); );
}} }}
@@ -825,7 +875,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.materials.mat_adjp")} label={t("jobs.fields.materials.mat_adjp")}
name={["md_responsibility_centers", "cieca_pfm", "MASH", "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>
<Form.Item <Form.Item
label={t("jobs.fields.materials.tax_ind")} 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> </Form.Item>
); );
}} }}
@@ -893,15 +943,15 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
<Switch /> <Switch />
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
</> </>
) )
}, },
{ {
key: "cieca_pfo", key: "cieca_pfo",
label: t("jobs.labels.cieca_pfo"), label: t("jobs.labels.cieca_pfo"),
forceRender: true, forceRender: true,
children: ( children: (
<> <>
<LayoutFormRow noDivider> <LayoutFormRow noDivider>
<Form.Item <Form.Item
label={t("jobs.fields.cieca_pfo.tow_t_in1")} 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} /> <InputNumber min={0} max={100} precision={4} />
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
</> </>
) )
} }
]} ]}
/> />
</LayoutFormRow>
</> </>
); );
} }
function TaxFormItems({ typeNum, typeNumIterator, rootElements, bodyshop }) { function getRootTaxFormItems({ typeNum, bodyshop, t }) {
const { t } = useTranslation(); return [
<Form.Item
if (rootElements) key={`tax_type_${typeNum}_type`}
return ( label={t("bodyshop.fields.responsibilitycenter_tax_type", { typeNum })}
<> rules={[
<Form.Item {
label={t("bodyshop.fields.responsibilitycenter_tax_type", { required: true
typeNum, //message: t("general.validation.required"),
typeNumIterator }
})} ]}
rules={[ name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `tax_type${typeNum}`]}
{ >
required: true <Input />
//message: t("general.validation.required"), </Form.Item>,
} <Form.Item
]} key={`tax_type_${typeNum}_name`}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `tax_type${typeNum}`]} label={t("bodyshop.fields.responsibilitycenters.state_tax")}
> rules={[
<Input /> {
</Form.Item> required: true
//message: t("general.validation.required"),
<Form.Item }
label={t("bodyshop.fields.responsibilitycenters.state_tax")} ]}
rules={[ name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "name"]}
{ >
required: true <Input />
//message: t("general.validation.required"), </Form.Item>,
} <Form.Item
]} key={`tax_type_${typeNum}_accountdesc`}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "name"]} label={t("bodyshop.fields.responsibilitycenter_accountdesc")}
> rules={[
<Input /> {
</Form.Item> required: true
//message: t("general.validation.required"),
<Form.Item }
label={t("bodyshop.fields.responsibilitycenter_accountdesc")} ]}
rules={[ name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "accountdesc"]}
{ >
required: true <Input />
//message: t("general.validation.required"), </Form.Item>,
} <Form.Item
]} key={`tax_type_${typeNum}_accountitem`}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "accountdesc"]} label={t("bodyshop.fields.responsibilitycenter_accountitem")}
> rules={[
<Input /> {
</Form.Item> required: true
<Form.Item //message: t("general.validation.required"),
label={t("bodyshop.fields.responsibilitycenter_accountitem")} }
rules={[ ]}
{ name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "accountitem"]}
required: true >
//message: t("general.validation.required"), <Input />
} </Form.Item>,
]} ...(bodyshopHasDmsKey(bodyshop)
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, "accountitem"]} ? [
>
<Input />
</Form.Item>
{bodyshopHasDmsKey(bodyshop) && (
<Form.Item <Form.Item
key={`tax_type_${typeNum}_dms_acctnumber`}
label={t("bodyshop.fields.dms.dms_acctnumber")} label={t("bodyshop.fields.dms.dms_acctnumber")}
rules={[ rules={[
{ {
@@ -2226,71 +2274,64 @@ function TaxFormItems({ typeNum, typeNumIterator, rootElements, bodyshop }) {
> >
<Input /> <Input />
</Form.Item> </Form.Item>
)} ]
</> : [])
); ];
return ( }
<>
<Form.Item function getTierTaxFormItems({ typeNum, typeNumIterator, t }) {
label={t("bodyshop.fields.responsibilitycenter_tax_tier", { return [
typeNum, <Form.Item
typeNumIterator key={`tax_type_${typeNum}_tier_${typeNumIterator}`}
})} label={t("bodyshop.labels.responsibilitycenters.tax_tier_short")}
rules={[ rules={[
{ {
required: true required: true
//message: t("general.validation.required"), //message: t("general.validation.required"),
} }
]} ]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_tier${typeNumIterator}`]} name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_tier${typeNumIterator}`]}
> >
<InputNumber precision={0} min={0} /> <InputNumber precision={0} min={0} />
</Form.Item> </Form.Item>,
<Form.Item <Form.Item
label={t("bodyshop.fields.responsibilitycenter_tax_thres", { key={`tax_type_${typeNum}_threshold_${typeNumIterator}`}
typeNum, label={t("bodyshop.labels.responsibilitycenters.tax_threshold_short")}
typeNumIterator rules={[
})} {
rules={[ required: true
{ //message: t("general.validation.required"),
required: true }
//message: t("general.validation.required"), ]}
} name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_thres${typeNumIterator}`]}
]} >
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_thres${typeNumIterator}`]} <InputNumber min={0} precision={2} />
> </Form.Item>,
<InputNumber min={0} precision={2} /> <Form.Item
</Form.Item> key={`tax_type_${typeNum}_rate_${typeNumIterator}`}
<Form.Item label={t("bodyshop.labels.responsibilitycenters.tax_rate_short")}
label={t("bodyshop.fields.responsibilitycenter_tax_rate", { rules={[
typeNum, {
typeNumIterator required: true
})} //message: t("general.validation.required"),
rules={[ }
{ ]}
required: true name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_rate${typeNumIterator}`]}
//message: t("general.validation.required"), >
} <InputNumber min={0} precision={2} suffix="%" />
]} </Form.Item>,
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_rate${typeNumIterator}`]} <Form.Item
> key={`tax_type_${typeNum}_surcharge_${typeNumIterator}`}
<InputNumber min={0} precision={2} /> label={t("bodyshop.labels.responsibilitycenters.tax_surcharge_short")}
</Form.Item> rules={[
<Form.Item {
label={t("bodyshop.fields.responsibilitycenter_tax_sur", { required: true
typeNum, //message: t("general.validation.required"),
typeNumIterator }
})} ]}
rules={[ name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_sur${typeNumIterator}`]}
{ >
required: true <InputNumber min={0} precision={2} suffix="%" />
//message: t("general.validation.required"), </Form.Item>
} ];
]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_sur${typeNumIterator}`]}
>
<InputNumber min={0} precision={2} />
</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"]); const disabled = !form.getFieldValue(["md_ro_guard", "enabled"]);
return ( return (
<LayoutFormRow noDivider> <LayoutFormRow header={t("bodyshop.labels.md_ro_guard_options")}>
<Form.Item <Form.Item
label={t("bodyshop.fields.md_ro_guard.totalgppercent_minimum")} label={t("bodyshop.fields.md_ro_guard.totalgppercent_minimum")}
name={["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>
<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 { Button, Form, Select, Space } from "antd";
import { useState } from "react";
import { ChromePicker } from "react-color"; import { ChromePicker } from "react-color";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import styled from "styled-components"; 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 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 { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -24,10 +31,341 @@ const SelectorDiv = styled.div`
.ant-form-item .ant-select { .ant-form-item .ant-select {
width: 200px; 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 }) { export function ShopInfoROStatusComponent({ bodyshop, form }) {
const { t } = useTranslation(); 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 { const {
treatments: { Production_List_Status_Colors } treatments: { Production_List_Status_Colors }
@@ -37,117 +375,119 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
splitKey: bodyshop.imexshopid 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 ( return (
<SelectorDiv id="jobstatus"> <SelectorDiv id="jobstatus">
<Form.Item <LayoutFormRow grow header={t("bodyshop.labels.job_status_options")}>
name={["md_ro_statuses", "statuses"]} <div>
label={t("bodyshop.labels.alljobstatuses")} <Form.Item
rules={[ name={["md_ro_statuses", "statuses"]}
{ label={t("bodyshop.labels.alljobstatuses")}
required: true, required
//message: t("general.validation.required"), rules={[
type: "array" {
} validator: async (_, value) => {
]} const populatedStatuses = normalizeStatuses(value);
>
<Select mode="tags" onBlur={handleBlur} /> if (populatedStatuses.length === 0) {
</Form.Item> return Promise.reject(
<Form.Item new Error(
name={["md_ro_statuses", "active_statuses"]} t("general.validation.required", {
label={t("bodyshop.fields.statuses.active_statuses")} label: t("bodyshop.labels.alljobstatuses")
rules={[ })
{ )
required: true, );
//message: t("general.validation.required"), }
type: "array"
} if (populatedStatuses.length !== (value || []).filter(Boolean).length) {
]} return Promise.reject(new Error(t("bodyshop.errors.duplicate_job_status")));
> }
<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")} <SortableStatusesSelect />
rules={[ </Form.Item>
{ <Form.Item
required: true, name={["md_ro_statuses", "active_statuses"]}
//message: t("general.validation.required"), label={t("bodyshop.fields.statuses.active_statuses")}
type: "array" rules={[
} {
]} required: true,
> //message: t("general.validation.required"),
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} /> type: "array"
</Form.Item> }
<Form.Item ]}
name={["md_ro_statuses", "production_statuses"]} >
label={t("bodyshop.fields.statuses.production_statuses")} <SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
rules={[ </Form.Item>
{ <Form.Item
required: true, name={["md_ro_statuses", "pre_production_statuses"]}
//message: t("general.validation.required"), label={t("bodyshop.fields.statuses.pre_production_statuses")}
type: "array" rules={[
} {
]} required: true,
> //message: t("general.validation.required"),
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} /> type: "array"
</Form.Item> }
<Form.Item ]}
name={["md_ro_statuses", "post_production_statuses"]} >
label={t("bodyshop.fields.statuses.post_production_statuses")} <SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
rules={[ </Form.Item>
{ <Form.Item
required: true, name={["md_ro_statuses", "production_statuses"]}
//message: t("general.validation.required"), label={t("bodyshop.fields.statuses.production_statuses")}
type: "array" rules={[
} {
]} required: true,
> //message: t("general.validation.required"),
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} /> type: "array"
</Form.Item> }
<Form.Item ]}
name={["md_ro_statuses", "ready_statuses"]} >
label={t("bodyshop.fields.statuses.ready_statuses")} <SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
rules={[ </Form.Item>
{ <Form.Item
//required: true, name={["md_ro_statuses", "post_production_statuses"]}
//message: t("general.validation.required"), label={t("bodyshop.fields.statuses.post_production_statuses")}
type: "array" rules={[
} {
]} required: true,
> //message: t("general.validation.required"),
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} /> type: "array"
</Form.Item> }
<Form.Item ]}
name={["md_ro_statuses", "additional_board_statuses"]} >
label={t("bodyshop.fields.statuses.additional_board_statuses")} <SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
rules={[ </Form.Item>
{ <Form.Item
//required: true, name={["md_ro_statuses", "ready_statuses"]}
//message: t("general.validation.required"), label={t("bodyshop.fields.statuses.ready_statuses")}
type: "array" rules={[
} {
]} //required: true,
> //message: t("general.validation.required"),
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} /> type: "array"
</Form.Item> }
<LayoutFormRow noDivider> ]}
>
<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 <Form.Item
label={t("bodyshop.fields.statuses.default_scheduled")} label={t("bodyshop.fields.statuses.default_scheduled")}
rules={[ rules={[
@@ -158,7 +498,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]} ]}
name={["md_ro_statuses", "default_scheduled"]} name={["md_ro_statuses", "default_scheduled"]}
> >
<Select options={options.map((item) => ({ value: item, label: item }))} /> <Select options={statusSelectOptions} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.statuses.default_arrived")} label={t("bodyshop.fields.statuses.default_arrived")}
@@ -170,7 +510,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]} ]}
name={["md_ro_statuses", "default_arrived"]} name={["md_ro_statuses", "default_arrived"]}
> >
<Select options={options.map((item) => ({ value: item, label: item }))} /> <Select options={statusSelectOptions} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.statuses.default_exported")} label={t("bodyshop.fields.statuses.default_exported")}
@@ -182,7 +522,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]} ]}
name={["md_ro_statuses", "default_exported"]} name={["md_ro_statuses", "default_exported"]}
> >
<Select options={options.map((item) => ({ value: item, label: item }))} /> <Select options={statusSelectOptions} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.statuses.default_imported")} label={t("bodyshop.fields.statuses.default_imported")}
@@ -194,7 +534,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]} ]}
name={["md_ro_statuses", "default_imported"]} name={["md_ro_statuses", "default_imported"]}
> >
<Select options={options.map((item) => ({ value: item, label: item }))} /> <Select options={statusSelectOptions} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.statuses.default_invoiced")} label={t("bodyshop.fields.statuses.default_invoiced")}
@@ -206,7 +546,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]} ]}
name={["md_ro_statuses", "default_invoiced"]} name={["md_ro_statuses", "default_invoiced"]}
> >
<Select options={options.map((item) => ({ value: item, label: item }))} /> <Select options={statusSelectOptions} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.statuses.default_completed")} label={t("bodyshop.fields.statuses.default_completed")}
@@ -218,7 +558,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]} ]}
name={["md_ro_statuses", "default_completed"]} name={["md_ro_statuses", "default_completed"]}
> >
<Select options={options.map((item) => ({ value: item, label: item }))} /> <Select options={statusSelectOptions} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.statuses.default_delivered")} label={t("bodyshop.fields.statuses.default_delivered")}
@@ -230,7 +570,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]} ]}
name={["md_ro_statuses", "default_delivered"]} name={["md_ro_statuses", "default_delivered"]}
> >
<Select options={options.map((item) => ({ value: item, label: item }))} /> <Select options={statusSelectOptions} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.statuses.default_void")} label={t("bodyshop.fields.statuses.default_void")}
@@ -242,73 +582,122 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
]} ]}
name={["md_ro_statuses", "default_void"]} name={["md_ro_statuses", "default_void"]}
> >
<Select options={options.map((item) => ({ value: item, label: item }))} /> <Select options={statusSelectOptions} />
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
{Production_List_Status_Colors.treatment === "on" && ( {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"]}>
<Form.List name={["md_ro_statuses", "production_colors"]}> {(fields, { add, remove }) => {
{(fields, { add, remove }) => { return (
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> <div>
<Space size="large" wrap> {fields.length === 0 ? (
{fields.map((field, index) => ( <ConfigListEmptyState actionLabel={t("bodyshop.actions.add_production_status_color")} />
<Form.Item key={field.key}> ) : (
<Space orientation="vertical"> <Space size="large" wrap align="start">
<div style={{ display: "flex" }}> {fields.map((field, index) => {
<Form.Item const productionColor = productionColors[field.name] || {};
style={{ flex: 1 }} const productionColorSurfaceStyles = getTintedCardSurfaceStyles(productionColor.color);
label={t("jobs.fields.status")} const selectedProductionColorStatuses = productionColors
key={`${index}status`} .map((item) => item?.status)
name={[field.name, "status"]} .filter(Boolean);
rules={[ const productionColorStatusOptions = [
{ ...new Set([productionColor.status, ...availableProductionStatuses])
required: true ]
//message: t("general.validation.required"), .filter(Boolean)
} .filter(
]} (status) =>
> status === productionColor.status || !selectedProductionColorStatuses.includes(status)
<Select options={productionStatus.map((item) => ({ value: item, label: item }))} /> );
</Form.Item>
<DeleteFilled return (
onClick={() => { <InlineValidatedFormRow
remove(field.name); form={form}
}} errorNames={[["md_ro_statuses", "production_colors", field.name, "status"]]}
/> key={field.key}
</div> noDivider
<Form.Item title={
label={t("bodyshop.fields.statuses.color")} <Form.Item
key={`${index}color`} noStyle
name={[field.name, "color"]} key={`${index}status`}
rules={[ name={[field.name, "status"]}
{ rules={[
required: true {
//message: t("general.validation.required"), 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 /> <div>
</Form.Item> <Form.Item
</Space> key={`${index}color`}
</Form.Item> name={[field.name, "color"]}
))} rules={[
</Space> {
<Form.Item> required: true
<Button //message: t("general.validation.required"),
type="dashed" }
onClick={() => { ]}
add(); >
}} <ColorPicker />
style={{ width: "100%" }} </Form.Item>
> </div>
{t("general.actions.add")} </InlineValidatedFormRow>
</Button> );
</Form.Item> })}
</Space>
)}
</div> </div>
); </LayoutFormRow>
}} );
</Form.List> }}
</LayoutFormRow> </Form.List>
)} )}
</SelectorDiv> </SelectorDiv>
); );

View File

@@ -1,5 +1,5 @@
import { DeleteFilled } from "@ant-design/icons"; import { DeleteFilled, ReloadOutlined } from "@ant-design/icons";
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch, TimePicker } from "antd"; import { Button, Col, Form, Input, InputNumber, Row, Select, Space, Switch, TimePicker, Tooltip } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -7,8 +7,16 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import ColorpickerFormItemComponent from "../form-items-formatted/colorpicker-form-item.component"; import ColorpickerFormItemComponent from "../form-items-formatted/colorpicker-form-item.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.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 LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { ColorPicker } from "./shop-info.rostatus.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({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
@@ -17,301 +25,514 @@ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //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 }) { export function ShopInfoSchedulingComponent({ form, bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
const appointmentColors = Form.useWatch(["appt_colors"], form) || form.getFieldValue(["appt_colors"]) || [];
const schedulingBuckets = Form.useWatch(["ssbuckets"], form) || form.getFieldValue(["ssbuckets"]) || [];
return ( return (
<div> <div>
<LayoutFormRow id="shopinfo-scheduling"> <LayoutFormRow grow header={t("bodyshop.labels.scheduling")} id="shopinfo-scheduling">
<Form.Item <>
label={t("bodyshop.fields.appt_length")} <Form.Item
name={"appt_length"} name={["appt_alt_transport"]}
rules={[ label={t("bodyshop.fields.appt_alt_transport")}
{ rules={[
required: true {
//message: t("general.validation.required"), //message: t("general.validation.required"),
} type: "array"
]} }
> ]}
<InputNumber min={15} precision={0} /> >
</Form.Item> <Select mode="tags" />
<Form.Item </Form.Item>
label={t("bodyshop.fields.schedule_start_time")} <Form.Item
name={"schedule_start_time"} name={["md_lost_sale_reasons"]}
rules={[ label={t("bodyshop.fields.md_lost_sale_reasons")}
{ rules={[
required: true {
//message: t("general.validation.required"), // required: true,
} //message: t("general.validation.required"),
]} type: "array"
id="schedule_start_time" }
> ]}
<TimePicker disableSeconds={true} format="HH:mm" /> >
</Form.Item> <Select mode="tags" />
<Form.Item </Form.Item>
label={t("bodyshop.fields.schedule_end_time")} <Row gutter={[16, 0]} wrap>
name={"schedule_end_time"} <Col xs={24} sm={12} xl={6}>
rules={[ <Form.Item
{ label={t("bodyshop.fields.appt_length")}
required: true name={"appt_length"}
//message: t("general.validation.required"), rules={[
} {
]} required: true
id="schedule_end_time" //message: t("general.validation.required"),
> }
<TimePicker disableSeconds={true} format="HH:mm" /> ]}
</Form.Item> >
<Form.Item <InputNumber min={15} precision={0} suffix="min" />
name={["appt_alt_transport"]} </Form.Item>
label={t("bodyshop.fields.appt_alt_transport")} </Col>
rules={[ <Col xs={24} sm={12} xl={6}>
{ <Form.Item
//message: t("general.validation.required"), label={t("bodyshop.fields.schedule_start_time")}
type: "array" name={"schedule_start_time"}
} rules={[
]} {
> required: true
<Select mode="tags" /> //message: t("general.validation.required"),
</Form.Item> }
<Form.Item ]}
name={["ss_configuration", "dailyhrslimit"]} id="schedule_start_time"
label={t("bodyshop.fields.ss_configuration.dailyhrslimit")} >
> <TimePicker disableSeconds={true} format="HH:mm" />
<InputNumber min={0} /> </Form.Item>
</Form.Item> </Col>
<Form.Item <Col xs={24} sm={12} xl={6}>
name={["ss_configuration", "nobusinessdays"]} <Form.Item
label={t("bodyshop.fields.ss_configuration.nobusinessdays")} label={t("bodyshop.fields.schedule_end_time")}
valuePropName="checked" name={"schedule_end_time"}
> rules={[
<Switch /> {
</Form.Item> required: true
<Form.Item //message: t("general.validation.required"),
name={["md_lost_sale_reasons"]} }
label={t("bodyshop.fields.md_lost_sale_reasons")} ]}
rules={[ id="schedule_end_time"
{ >
// required: true, <TimePicker disableSeconds={true} format="HH:mm" />
//message: t("general.validation.required"), </Form.Item>
type: "array" </Col>
} <Col xs={24} sm={12} xl={6}>
]} <Form.Item
> name={["ss_configuration", "dailyhrslimit"]}
<Select mode="tags" /> label={t("bodyshop.fields.ss_configuration.dailyhrslimit")}
</Form.Item> >
<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> </LayoutFormRow>
<Divider titlePlacement="left">{t("bodyshop.labels.workingdays")}</Divider> <LayoutFormRow header={t("bodyshop.labels.workingdays")} id="workingdays">
<Space wrap size="large" id="workingdays"> <Space wrap size="middle">
<Form.Item label={t("general.labels.sunday")} name={["workingdays", "sunday"]} valuePropName="checked"> {WORKING_DAYS.map(({ key, labelKey }) => (
<Switch /> <Form.Item key={key} label={t(labelKey)} name={["workingdays", key]} valuePropName="checked">
</Form.Item> <Switch />
<Form.Item label={t("general.labels.monday")} name={["workingdays", "monday"]} valuePropName="checked"> </Form.Item>
<Switch /> ))}
</Form.Item> </Space>
<Form.Item label={t("general.labels.tuesday")} name={["workingdays", "tuesday"]} valuePropName="checked"> </LayoutFormRow>
<Switch /> <Form.List name={["appt_colors"]}>
</Form.Item> {(fields, { add, remove, move }) => {
<Form.Item label={t("general.labels.wednesday")} name={["workingdays", "wednesday"]} valuePropName="checked"> return (
<Switch /> <LayoutFormRow
</Form.Item> header={t("bodyshop.labels.apptcolors")}
<Form.Item label={t("general.labels.thursday")} name={["workingdays", "thursday"]} valuePropName="checked"> id="apptcolors"
<Switch /> actions={[
</Form.Item> <Button
<Form.Item label={t("general.labels.friday")} name={["workingdays", "friday"]} valuePropName="checked"> key="add-appointment-color"
<Switch /> type="primary"
</Form.Item> block
<Form.Item label={t("general.labels.saturday")} name={["workingdays", "saturday"]} valuePropName="checked"> onClick={() => {
<Switch /> add({
</Form.Item> color: {
</Space> ...DEFAULT_TRANSLUCENT_PICKER_COLOR,
<LayoutFormRow header={t("bodyshop.labels.apptcolors")} id="apptcolors"> rgb: { ...DEFAULT_TRANSLUCENT_PICKER_COLOR.rgb }
<Form.List name={["appt_colors"]}> }
});
}}
>
{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 }) => { {(fields, { add, remove, move }) => {
return ( return (
<div> <LayoutFormRow
{fields.map((field, index) => ( header={t("bodyshop.labels.ssbuckets")}
<Form.Item key={field.key}> id="ssbuckets"
<LayoutFormRow noDivider> actions={[
<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>
<Button <Button
type="dashed" key="add-job-size-definition"
type="primary"
block
onClick={() => { onClick={() => {
add(); add({
color: { ...DEFAULT_TRANSLUCENT_CARD_COLOR }
});
}} }}
style={{ width: "100%" }}
> >
{t("bodyshop.actions.addapptcolor")} {t("bodyshop.actions.addbucket")}
</Button> </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> </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> </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 { useTranslation } from "react-i18next";
import { TemplateList } from "../../utils/TemplateConstants"; import { TemplateList } from "../../utils/TemplateConstants";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.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 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"; import InstanceRenderManager from "../../utils/instanceRenderMgr";
export default function ShopInfoSpeedPrint() { export default function ShopInfoSpeedPrint() {
const { t } = useTranslation(); const { t } = useTranslation();
const form = Form.useFormInstance();
const allTemplates = TemplateList("job"); const allTemplates = TemplateList("job");
const TemplateListGenerated = InstanceRenderManager({ const TemplateListGenerated = InstanceRenderManager({
imex: Object.fromEntries(Object.entries(allTemplates).filter(([, { enhanced_payroll }]) => !enhanced_payroll)), imex: Object.fromEntries(Object.entries(allTemplates).filter(([, { enhanced_payroll }]) => !enhanced_payroll)),
@@ -18,80 +30,131 @@ export default function ShopInfoSpeedPrint() {
<Form.List name={["speedprint"]}> <Form.List name={["speedprint"]}>
{(fields, { add, remove, move }) => { {(fields, { add, remove, move }) => {
return ( return (
<div> <LayoutFormRow
{fields.map((field, index) => ( header={t("bodyshop.labels.speedprint_configurations")}
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}> actions={[
<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>
<Button <Button
type="dashed" key="add-speedprint"
type="primary"
block
onClick={() => { onClick={() => {
add(); add();
}} }}
style={{ width: "100%" }}
> >
{t("bodyshop.actions.addspeedprint")} {t("bodyshop.actions.addspeedprint")}
</Button> </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> </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 { Button, Checkbox, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.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 ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { connect } from "react-redux"; import { connect } from "react-redux";
@@ -55,10 +57,12 @@ const getTaskPresetAllocationErrors = (presets = [], t) => {
export function ShopInfoTaskPresets({ bodyshop }) { export function ShopInfoTaskPresets({ bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
const form = Form.useFormInstance();
const taskPresets = Form.useWatch(["md_tasks_presets", "presets"], form) || [];
return ( return (
<> <>
<LayoutFormRow noDivider> <LayoutFormRow header={t("bodyshop.labels.task_preset_options")}>
<Form.Item <Form.Item
label={t("bodyshop.fields.md_tasks_presets.enable_tasks")} label={t("bodyshop.fields.md_tasks_presets.enable_tasks")}
valuePropName="checked" valuePropName="checked"
@@ -75,187 +79,216 @@ export function ShopInfoTaskPresets({ bodyshop }) {
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow header={t("bodyshop.labels.md_tasks_presets")}> <Form.List
<Form.List name={["md_tasks_presets", "presets"]}
name={["md_tasks_presets", "presets"]} rules={[
rules={[ {
{ validator: async (_, presets) => {
validator: async (_, presets) => { const allocationErrors = getTaskPresetAllocationErrors(presets, t);
const allocationErrors = getTaskPresetAllocationErrors(presets, t);
if (allocationErrors.length > 0) { if (allocationErrors.length > 0) {
throw new Error(allocationErrors.join(" ")); 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> <div>
{fields.map((field, index) => ( {fields.length === 0 ? (
<Form.Item key={field.key}> <ConfigListEmptyState actionLabel={t("bodyshop.actions.add_task_preset")} />
<LayoutFormRow noDivider> ) : (
<Form.Item fields.map((field, index) => {
label={t("bodyshop.fields.md_tasks_presets.name")} const taskPreset = taskPresets[field.name] || {};
key={`${index}name`}
name={[field.name, "name"]} return (
rules={[ <Form.Item key={field.key}>
{ <LayoutFormRow
required: true noDivider
//message: t("general.validation.required"), 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>
} }
]} >
> <Form.Item
<Input /> 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>
<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.ErrorList errors={errors} />
<Form.Item>
<Button
type="dashed"
onClick={() => {
add();
}}
style={{ width: "100%" }}
>
{t("bodyshop.actions.add_task_preset")}
</Button>
</Form.Item>
</div> </div>
); </LayoutFormRow>
}} );
</Form.List> }}
</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 { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
@@ -17,19 +18,22 @@ export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoIntellipay);
// noinspection JSUnusedLocalSymbols // noinspection JSUnusedLocalSymbols
export function ShopInfoIntellipay({ bodyshop, form }) { export function ShopInfoIntellipay({ bodyshop, form }) {
const { t } = useTranslation(); const { t } = useTranslation();
const cashDiscountEnabled = Form.useWatch(["intellipay_config", "enable_cash_discount"], form);
return ( return (
<> <>
<Form.Item dependencies={[["intellipay_config", "enable_cash_discount"]]}> {cashDiscountEnabled && (
{() => { <div style={{ marginBottom: 12 }}>
const { intellipay_config } = form.getFieldsValue(); <Alert title={t("bodyshop.labels.intellipay_cash_discount")} />
</div>
)}
if (intellipay_config?.enable_cash_discount) <LayoutFormRow
return <Alert title={t("bodyshop.labels.intellipay_cash_discount")} />; header={InstanceRenderManager({
}} rome: t("bodyshop.labels.romepay"),
</Form.Item> imex: t("bodyshop.labels.imexpay")
})}
<LayoutFormRow noDivider> >
<Form.Item <Form.Item
label={t("bodyshop.fields.intellipay_config.enable_cash_discount")} label={t("bodyshop.fields.intellipay_config.enable_cash_discount")}
valuePropName="checked" valuePropName="checked"

View File

@@ -1,23 +1,9 @@
import { DeleteFilled } from "@ant-design/icons"; import { DeleteFilled } from "@ant-design/icons";
import { useMutation, useQuery } from "@apollo/client/react"; import { useMutation, useQuery } from "@apollo/client/react";
import { import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Skeleton, Space, Switch, Typography } from "antd";
Button,
Card,
Col,
Form,
Input,
InputNumber,
Row,
Select,
Skeleton,
Space,
Switch,
Tag,
Typography
} from "antd";
import querystring from "query-string"; import querystring from "query-string";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
@@ -25,9 +11,22 @@ import { createStructuredSelector } from "reselect";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component"; 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 CurrencyInput from "../form-items-formatted/currency-form-item.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.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 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 { import {
INSERT_EMPLOYEE_TEAM, INSERT_EMPLOYEE_TEAM,
@@ -37,11 +36,10 @@ import {
import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component"; import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { import {
LABOR_TYPES,
getSplitTotal, getSplitTotal,
hasExactSplitTotal, hasExactSplitTotal,
LABOR_TYPES,
normalizeEmployeeTeam, normalizeEmployeeTeam,
normalizeTeamMember,
validateEmployeeTeamMembers validateEmployeeTeamMembers
} from "./shop-employee-teams.form.utils.js"; } from "./shop-employee-teams.form.utils.js";
@@ -55,24 +53,8 @@ const PAYOUT_METHOD_OPTIONS = [
{ labelKey: "employee_teams.options.commission_percentage", value: "commission" } { 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 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) => { const formatAllocationPercentage = (percentage) => {
if (percentage === null || percentage === undefined || percentage === "") return null; if (percentage === null || percentage === undefined || percentage === "") return null;
@@ -82,16 +64,19 @@ const formatAllocationPercentage = (percentage) => {
return `${numericValue.toFixed(2).replace(/\.?0+$/, "")}%`; return `${numericValue.toFixed(2).replace(/\.?0+$/, "")}%`;
}; };
export function ShopEmployeeTeamsFormComponent({ bodyshop }) { export function ShopEmployeeTeamsFormComponent({ bodyshop, form, onDirtyChange, isDirty }) {
const { t } = useTranslation(); 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 history = useNavigate();
const search = querystring.parse(useLocation().search); const search = querystring.parse(useLocation().search);
const notification = useNotification(); const notification = useNotification();
const [hydratedTeamId, setHydratedTeamId] = useState(search.employeeTeamId === "new" ? "new" : null); const [hydratedTeamId, setHydratedTeamId] = useState(search.employeeTeamId === "new" ? "new" : null);
const isNewTeam = search.employeeTeamId === "new"; 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 }, variables: { id: search.employeeTeamId },
skip: !search.employeeTeamId || isNewTeam, skip: !search.employeeTeamId || isNewTeam,
fetchPolicy: "network-only", fetchPolicy: "network-only",
@@ -99,29 +84,68 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
notifyOnNetworkStatusChange: true notifyOnNetworkStatusChange: true
}); });
useEffect(() => { const currentTeamData = data?.employee_teams_by_pk?.id === search.employeeTeamId ? data.employee_teams_by_pk : null;
if (!search.employeeTeamId) return;
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) { if (isNewTeam) {
form.resetFields();
setHydratedTeamId("new"); setHydratedTeamId("new");
return; hydrationFrameId = window.requestAnimationFrame(() => {
clearTeamFormMeta();
});
return () => {
if (hydrationFrameId) window.cancelAnimationFrame(hydrationFrameId);
};
} }
setHydratedTeamId(null); setHydratedTeamId(null);
}, [form, isNewTeam, search.employeeTeamId]);
useEffect(() => { if (loading) {
if (!search.employeeTeamId || isNewTeam || loading) return; return undefined;
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);
} }
}, [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 [updateEmployeeTeam] = useMutation(UPDATE_EMPLOYEE_TEAM);
const [insertEmployeeTeam] = useMutation(INSERT_EMPLOYEE_TEAM); const [insertEmployeeTeam] = useMutation(INSERT_EMPLOYEE_TEAM);
@@ -129,34 +153,25 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
label: t(labelKey), label: t(labelKey),
value value
})); }));
const teamName = Form.useWatch("name", form); const teamName = Form.useWatch("name", teamForm);
const teamMembers = Form.useWatch(["employee_team_members"], form) || []; const teamMembers = Form.useWatch(["employee_team_members"], teamForm) || [];
const isTeamHydrating = !isNewTeam && Boolean(search.employeeTeamId) && hydratedTeamId !== search.employeeTeamId; const isTeamHydrating = !isNewTeam && Boolean(search.employeeTeamId) && hydratedTeamId !== search.employeeTeamId;
const teamCardTitle = isTeamHydrating const isAllocationTotalExact = hasExactSplitTotal(teamMembers);
? t("employee_teams.fields.name") const allocationTotalValue = formatAllocationPercentage(getSplitTotal(teamMembers))?.replace("%", "") || "0";
: teamName?.trim() || t("employee_teams.fields.name"); const teamNameDisplay = teamName?.trim() || t("employee_teams.fields.name");
const teamCardTitle = isTeamHydrating ? (
const getTeamMemberTitle = (teamMember = {}) => { t("employee_teams.fields.name")
const employeeName = ) : (
getEmployeeDisplayName(bodyshop.employees, teamMember.employeeid) || t("employee_teams.fields.employeeid"); <span>
const allocation = formatAllocationPercentage(teamMember.percentage); <span>{teamNameDisplay}</span>
const payoutMethod = <span> - </span>
teamMember.payout_method === "commission" <Typography.Text type={isAllocationTotalExact ? undefined : "danger"}>
? t("employee_teams.options.commission") {t("employee_teams.labels.allocation_total", {
: t("employee_teams.options.hourly"); total: allocationTotalValue
})}
return ( </Typography.Text>
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8 }}> </span>
<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 handleFinish = async ({ employee_team_members = [], ...values }) => { const handleFinish = async ({ employee_team_members = [], ...values }) => {
const { normalizedTeamMembers, errorKey } = validateEmployeeTeamMembers(employee_team_members); const { normalizedTeamMembers, errorKey } = validateEmployeeTeamMembers(employee_team_members);
@@ -193,6 +208,8 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
}); });
if (!result.errors) { if (!result.errors) {
updateDirtyState(false);
void refetch();
notification.success({ notification.success({
title: t("employees.successes.save") title: t("employees.successes.save")
}); });
@@ -216,6 +233,7 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
}, },
refetchQueries: ["QUERY_TEAMS"] refetchQueries: ["QUERY_TEAMS"]
}).then((response) => { }).then((response) => {
updateDirtyState(false);
search.employeeTeamId = response.data.insert_employee_teams_one.id; search.employeeTeamId = response.data.insert_employee_teams_one.id;
history({ search: querystring.stringify(search) }); history({ search: querystring.stringify(search) });
notification.success({ notification.success({
@@ -230,18 +248,66 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
return ( return (
<Card <Card
title={teamCardTitle} title={isTeamHydrating ? undefined : teamCardTitle}
extra={ extra={
<Button type="primary" onClick={() => form.submit()} disabled={isTeamHydrating}> <Button
{t("general.actions.save")} type="primary"
onClick={() => teamForm.submit()}
disabled={isTeamHydrating || !resolvedIsDirty}
style={{ minWidth: 190 }}
>
{t("employee_teams.actions.save_team")}
</Button> </Button>
} }
> >
{isTeamHydrating ? ( {isTeamHydrating ? (
<Skeleton active title={false} paragraph={{ rows: 12 }} /> <Skeleton active title={false} paragraph={{ rows: 12 }} />
) : ( ) : (
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}> <Form
<LayoutFormRow> 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 <Form.Item
name="name" name="name"
label={t("employee_teams.fields.name")} label={t("employee_teams.fields.name")}
@@ -253,9 +319,6 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
> >
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item label={t("employee_teams.fields.active")} name="active" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item <Form.Item
label={t("employee_teams.fields.max_load")} label={t("employee_teams.fields.max_load")}
name="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> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<Form.List name={["employee_team_members"]}> <Form.List name={["employee_team_members"]}>
{(fields, { add, remove, move }) => { {(fields, { add, remove, move }) => {
return ( return (
<div> <LayoutFormRow
{fields.map((field, index) => { title={t("employee_teams.labels.members")}
const teamMember = normalizeTeamMember(teamMembers[field.name]); actions={[
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>
<Button <Button
type="dashed" key="add-team-member"
type="primary"
block
onClick={() => { onClick={() => {
add({ add({
percentage: 0, percentage: 0,
@@ -395,26 +349,166 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
commission_rates: {} commission_rates: {}
}); });
}} }}
style={{ width: "100%" }}
> >
{t("employee_teams.actions.newmember")} {t("employee_teams.actions.newmember")}
</Button> </Button>
</Form.Item> ]}
<Form.Item noStyle shouldUpdate> >
{() => { <div>
const teamMembers = form.getFieldValue(["employee_team_members"]) || []; {fields.length === 0 ? (
const splitTotal = getSplitTotal(teamMembers); <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 ( return (
<Typography.Text type={hasExactSplitTotal(teamMembers) ? undefined : "danger"}> <Row gutter={[16, 0]}>
{t("employee_teams.labels.allocation_total", { {LABOR_TYPES.map((laborType) => (
total: splitTotal.toFixed(2) <Col
})} {...TEAM_MEMBER_RATE_FIELD_COLS}
</Typography.Text> key={`${index}-${fieldName}-${laborType}`}
); >
}} <Form.Item
</Form.Item> label={t(`joblines.fields.lbr_types.${laborType}`)}
</div> 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> </Form.List>

View File

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

View File

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

View File

@@ -1,36 +1,70 @@
import { Form } from "antd";
import { useQuery } from "@apollo/client/react"; import { useQuery } from "@apollo/client/react";
import queryString from "query-string";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { QUERY_TEAMS } from "../../graphql/employee_teams.queries"; import { QUERY_TEAMS } from "../../graphql/employee_teams.queries";
import useConfirmDirtyFormNavigation from "../../hooks/useConfirmDirtyFormNavigation.jsx";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component"; import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
import ShopEmployeeTeamsListComponent from "./shop-employee-teams.list"; import ShopEmployeeTeamsListComponent from "./shop-employee-teams.list";
import ShopEmployeeTeamsFormComponent from "./shop-employee-teams.form.component"; import ShopEmployeeTeamsFormComponent from "./shop-employee-teams.form.component";
import { Col, Row } from "antd"; import "./shop-teams.styles.scss";
const mapStateToProps = createStructuredSelector({}); const mapStateToProps = createStructuredSelector({});
function ShopTeamsContainer() { 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, { const { loading, error, data } = useQuery(QUERY_TEAMS, {
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "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" />; if (error) return <AlertComponent title={error.message} type="error" />;
return ( return (
<div> <RbacWrapper action="employee_teams:page">
<RbacWrapper action="employee_teams:page"> <div
<Row gutter={[16, 16]}> className={["shop-teams-layout", hasSelectedTeam ? "shop-teams-layout--with-detail" : null]
<Col span={6}> .filter(Boolean)
<ShopEmployeeTeamsListComponent employee_teams={data ? data.employee_teams : []} loading={loading} /> .join(" ")}
</Col> >
<Col span={18}> <div className="shop-teams-layout__list">
<ShopEmployeeTeamsFormComponent /> <ShopEmployeeTeamsListComponent
</Col> employee_teams={data ? data.employee_teams : []}
</Row> loading={loading}
</RbacWrapper> onRequestTeamChange={navigateToTeam}
</div> 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 { QUERY_SHOP_ASSOCIATIONS } from "../../graphql/user.queries";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component"; 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 RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
import ResponsiveTable from "../responsive-table/responsive-table.component"; import ResponsiveTable from "../responsive-table/responsive-table.component";
import ShopUsersAuthEdit from "../shop-users-auth-edit/shop-users-auth-edit.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 <AlertComponent type="error" title={JSON.stringify(error)} />;
} }
return ( return (
<div> <LayoutFormRow title={t("bodyshop.labels.licensing")}>
<ResponsiveTable <ResponsiveTable
loading={loading} loading={loading}
pagination={{ placement: "top" }} pagination={{ placement: "top" }}
@@ -75,6 +76,6 @@ export function ShopInfoUsersComponent({ bodyshop }) {
rowKey="id" rowKey="id"
dataSource={data && data.associations} dataSource={data && data.associations}
/> />
</div> </LayoutFormRow>
); );
} }

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

@@ -293,7 +293,23 @@
}, },
"bodyshop": { "bodyshop": {
"actions": { "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_task_preset": "Add Task Preset",
"add_to_email_preset": "Add To Email Preset",
"addapptcolor": "Add Appointment Color", "addapptcolor": "Add Appointment Color",
"addbucket": "Add Definition", "addbucket": "Add Definition",
"addpartslocation": "Add Parts Location", "addpartslocation": "Add Parts Location",
@@ -302,11 +318,13 @@
"addtemplate": "Add Template", "addtemplate": "Add Template",
"newlaborrate": "New Labor Rate", "newlaborrate": "New Labor Rate",
"newsalestaxcode": "New Sales Tax Code", "newsalestaxcode": "New Sales Tax Code",
"save_shop_information": "Save Shop Information",
"newstatus": "Add Status", "newstatus": "Add Status",
"testrender": "Test Render" "testrender": "Test Render"
}, },
"errors": { "errors": {
"creatingdefaultview": "Error creating default view.", "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", "duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique",
"loading": "Unable to load shop details. Please call technical support.", "loading": "Unable to load shop details. Please call technical support.",
"saving": "Error encountered while saving. {{message}}", "saving": "Error encountered while saving. {{message}}",
@@ -404,6 +422,35 @@
"logo_img_path": "Shop Logo", "logo_img_path": "Shop Logo",
"logo_img_path_height": "Logo Image Height", "logo_img_path_height": "Logo Image Height",
"logo_img_path_width": "Logo Image Width", "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_categories": "Categories",
"md_ccc_rates": "Courtesy Car Contract Rate Presets", "md_ccc_rates": "Courtesy Car Contract Rate Presets",
"md_classes": "Classes", "md_classes": "Classes",
@@ -464,9 +511,13 @@
"use_approvals": "Use Time Ticket Approval Queue" "use_approvals": "Use Time Ticket Approval Queue"
}, },
"messaginglabel": "Messaging Preset Label", "messaginglabel": "Messaging Preset Label",
"messaginglabel_short": "Label",
"messagingtext": "Messaging Preset Text", "messagingtext": "Messaging Preset Text",
"messagingtext_short": "Text",
"noteslabel": "Note Label", "noteslabel": "Note Label",
"noteslabel_short": "Label",
"notestext": "Note Text", "notestext": "Note Text",
"notestext_short": "Text",
"notifications": { "notifications": {
"description": "Select employees to automatically follow new jobs and receive notifications for job updates.", "description": "Select employees to automatically follow new jobs and receive notifications for job updates.",
"invalid_followers": "Invalid selection. Please select valid employees.", "invalid_followers": "Invalid selection. Please select valid employees.",
@@ -600,12 +651,17 @@
"federal_tax_itc": "Federal Tax Credit", "federal_tax_itc": "Federal Tax Credit",
"gogcode": "GOG Code (BreakOut)", "gogcode": "GOG Code (BreakOut)",
"gst_override": "GST Override Account #", "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": "QuickBooks US - Invoice Tax Exempt Code",
"invoiceexemptcode_short": "Invoice Tax Exempt Code",
"item_type": "Item Type", "item_type": "Item Type",
"item_type_freight": "Freight", "item_type_freight": "Freight",
"item_type_gog": "GOG", "item_type_gog": "GOG",
"item_type_paint": "Paint Materials", "item_type_paint": "Paint Materials",
"itemexemptcode": "QuickBooks US - Line Item Tax Exempt Code", "itemexemptcode": "QuickBooks US - Line Item Tax Exempt Code",
"itemexemptcode_short": "Line Item Tax Exempt Code",
"la1": "LA1", "la1": "LA1",
"la2": "LA2", "la2": "LA2",
"la3": "LA3", "la3": "LA3",
@@ -722,6 +778,7 @@
"customtemplates": "Custom Templates", "customtemplates": "Custom Templates",
"defaultcostsmapping": "Default Costs Mapping", "defaultcostsmapping": "Default Costs Mapping",
"defaultprofitsmapping": "Default Profits Mapping", "defaultprofitsmapping": "Default Profits Mapping",
"dms_setup": "DMS Setup",
"deliverchecklist": "Delivery Checklist", "deliverchecklist": "Delivery Checklist",
"dms": { "dms": {
"cdk": { "cdk": {
@@ -738,24 +795,33 @@
}, },
"emaillater": "Email Later", "emaillater": "Email Later",
"employee_teams": "Employee Teams", "employee_teams": "Employee Teams",
"employee_options": "Employee Options",
"employee_rates": "Employee Rates",
"employee_vacation": "Employee Vacation",
"employees": "Employees", "employees": "Employees",
"estimators": "Estimators", "estimators": "Estimators",
"filehandlers": "Adjusters", "filehandlers": "Adjusters",
"imexpay": "ImEX Pay", "imexpay": "ImEX Pay",
"insurancecos": "Insurance Companies", "insurancecos": "Insurance Companies",
"intake_delivery": "Intake / Delivery Options",
"intakechecklist": "Intake Checklist", "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. ", "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", "jobstatuses": "Job Statuses",
"laborrates": "Labor Rates", "laborrates": "Labor Rates",
"licensing": "Licensing", "licensing": "Licensing",
"md_parts_scan": "Parts Scan Rules", "md_parts_scan": "Parts Scan Rules",
"md_ro_guard": "RO Guard", "md_ro_guard": "RO Guard",
"md_ro_guard_options": "RO Guard Options",
"md_tasks_presets": "Tasks Presets", "md_tasks_presets": "Tasks Presets",
"task_preset_options": "Task Preset Options",
"md_to_emails": "Preset To Emails", "md_to_emails": "Preset To Emails",
"md_to_emails_emails": "Emails", "md_to_emails_emails": "Emails",
"messagingpresets": "Messaging Presets", "messagingpresets": "Messaging Presets",
"notification_options": "Notification Options",
"notemplatesavailable": "No templates available to add.", "notemplatesavailable": "No templates available to add.",
"notespresets": "Notes Presets", "notespresets": "Notes Presets",
"jump_to_section": "Jump to section",
"notifications": { "notifications": {
"followers": "Notifications" "followers": "Notifications"
}, },
@@ -769,11 +835,22 @@
"qbo_departmentid": "QBO Department ID", "qbo_departmentid": "QBO Department ID",
"qbo_usa": "QBO USA Compatibility", "qbo_usa": "QBO USA Compatibility",
"rbac": "Role Based Access Control", "rbac": "Role Based Access Control",
"rbac_options": "Role Based Access Control Options",
"responsibilitycenters": { "responsibilitycenters": {
"costs": "Cost Centers", "costs": "Cost Centers",
"default_tax_setup": "Default Tax Setup",
"invoices": "Invoices",
"profits": "Profit Centers", "profits": "Profit Centers",
"quickbooks_qbd": "QuickBooks / QBD",
"quickbooks_us": "QuickBooks US",
"sales_tax_codes": "Sales Tax Codes", "sales_tax_codes": "Sales Tax Codes",
"tax_accounts": "Tax Accounts", "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", "title": "Responsibility Centers",
"ttl_adjustment": "Subtotal Adjustment Account", "ttl_adjustment": "Subtotal Adjustment Account",
"ttl_tax_adjustment": "Tax Adjustment Account" "ttl_tax_adjustment": "Tax Adjustment Account"
@@ -781,6 +858,9 @@
"roguard": { "roguard": {
"title": "RO Guard" "title": "RO Guard"
}, },
"autoemail": "Auto Email",
"jobcosting": "Job Costing",
"localmediaserver": "Local Media Server",
"romepay": "Rome Pay", "romepay": "Rome Pay",
"scheduling": "SMART Scheduling", "scheduling": "SMART Scheduling",
"scoreboardsetup": "Scoreboard Setup", "scoreboardsetup": "Scoreboard Setup",
@@ -788,6 +868,7 @@
"shopinfo": "Shop Information", "shopinfo": "Shop Information",
"shoprates": "Shop Rates", "shoprates": "Shop Rates",
"speedprint": "Speed Print Configuration", "speedprint": "Speed Print Configuration",
"speedprint_configurations": "Speed Print Configurations",
"ssbuckets": "Job Size Definitions", "ssbuckets": "Job Size Definitions",
"systemsettings": "System Settings", "systemsettings": "System Settings",
"task-presets": "Task Presets", "task-presets": "Task Presets",
@@ -811,7 +892,8 @@
"tooltips": { "tooltips": {
"md_parts_scan": { "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." "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": { "validation": {
"centermustexist": "The chosen responsibility center does not exist.", "centermustexist": "The chosen responsibility center does not exist.",
@@ -1179,7 +1261,8 @@
"employee_teams": { "employee_teams": {
"actions": { "actions": {
"new": "New Team", "new": "New Team",
"newmember": "New Team Member" "newmember": "New Team Member",
"save_team": "Save Employee Team"
}, },
"errors": { "errors": {
"allocation_total_exact": "Team allocation must total exactly 100%.", "allocation_total_exact": "Team allocation must total exactly 100%.",
@@ -1197,7 +1280,9 @@
"percentage": "Percent" "percentage": "Percent"
}, },
"labels": { "labels": {
"allocation_total": "Allocation Total: {{total}}%" "allocation_total": "Allocation Total: {{total}}%",
"members": "Members",
"team_options": "Team Options"
}, },
"options": { "options": {
"commission": "Commission", "commission": "Commission",
@@ -1207,9 +1292,11 @@
}, },
"employees": { "employees": {
"actions": { "actions": {
"addrate": "Add Rate",
"addvacation": "Add Vacation", "addvacation": "Add Vacation",
"new": "New Employee", "new": "New Employee",
"newrate": "New Rate", "newrate": "New Rate",
"save_employee": "Save Employee",
"select": "Select Employee" "select": "Select Employee"
}, },
"errors": { "errors": {
@@ -1241,6 +1328,7 @@
"labels": { "labels": {
"actions": "Actions", "actions": "Actions",
"active": "Active", "active": "Active",
"employee_number_short": "Employee #",
"endmustbeafterstart": "End date must be after start date.", "endmustbeafterstart": "End date must be after start date.",
"flat_rate": "Flat Rate", "flat_rate": "Flat Rate",
"inactive": "Inactive", "inactive": "Inactive",
@@ -1373,6 +1461,7 @@
"beta": "BETA", "beta": "BETA",
"cancel": "Are you sure you want to cancel? Your changes will not be saved.", "cancel": "Are you sure you want to cancel? Your changes will not be saved.",
"changelog": "Change Log", "changelog": "Change Log",
"click_to_begin": "Click {{action}} to begin",
"clear": "Clear", "clear": "Clear",
"confirmpassword": "Confirm Password", "confirmpassword": "Confirm Password",
"created_at": "Created At", "created_at": "Created At",
@@ -1918,10 +2007,15 @@
"employee_refinish": "Refinish", "employee_refinish": "Refinish",
"est_addr1": "Estimator Address", "est_addr1": "Estimator Address",
"est_co_nm": "Estimator Company", "est_co_nm": "Estimator Company",
"est_co_nm_short": "Company",
"est_ct_fn": "Estimator First Name", "est_ct_fn": "Estimator First Name",
"est_ct_fn_short": "First Name",
"est_ct_ln": "Estimator Last Name", "est_ct_ln": "Estimator Last Name",
"est_ct_ln_short": "Last Name",
"est_ea": "Estimator Email", "est_ea": "Estimator Email",
"est_ea_short": "Email",
"est_ph1": "Estimator Phone #", "est_ph1": "Estimator Phone #",
"est_ph1_short": "Phone #",
"estimate_approved": "Estimate Approved", "estimate_approved": "Estimate Approved",
"estimate_sent_approval": "Estimate Sent for Approval", "estimate_sent_approval": "Estimate Sent for Approval",
"federal_tax_payable": "Federal Tax Payable", "federal_tax_payable": "Federal Tax Payable",
@@ -1934,9 +2028,13 @@
"ins_co_nm": "Insurance Company Name", "ins_co_nm": "Insurance Company Name",
"ins_co_nm_short": "Ins. Co.", "ins_co_nm_short": "Ins. Co.",
"ins_ct_fn": "Adjuster First Name", "ins_ct_fn": "Adjuster First Name",
"ins_ct_fn_short": "First Name",
"ins_ct_ln": "Adjuster Last Name", "ins_ct_ln": "Adjuster Last Name",
"ins_ct_ln_short": "Last Name",
"ins_ea": "Adjuster Email", "ins_ea": "Adjuster Email",
"ins_ea_short": "Email",
"ins_ph1": "Adjuster Phone #", "ins_ph1": "Adjuster Phone #",
"ins_ph1_short": "Phone #",
"intake": { "intake": {
"label": "Label", "label": "Label",
"max": "Maximum", "max": "Maximum",

View File

@@ -293,7 +293,23 @@
}, },
"bodyshop": { "bodyshop": {
"actions": { "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_task_preset": "",
"add_to_email_preset": "",
"addapptcolor": "", "addapptcolor": "",
"addbucket": "", "addbucket": "",
"addpartslocation": "", "addpartslocation": "",
@@ -302,11 +318,13 @@
"addtemplate": "", "addtemplate": "",
"newlaborrate": "", "newlaborrate": "",
"newsalestaxcode": "", "newsalestaxcode": "",
"save_shop_information": "",
"newstatus": "", "newstatus": "",
"testrender": "" "testrender": ""
}, },
"errors": { "errors": {
"creatingdefaultview": "", "creatingdefaultview": "",
"duplicate_job_status": "",
"duplicate_insurance_company": "", "duplicate_insurance_company": "",
"loading": "No se pueden cargar los detalles de la tienda. Por favor llame al soporte técnico.", "loading": "No se pueden cargar los detalles de la tienda. Por favor llame al soporte técnico.",
"saving": "", "saving": "",
@@ -404,6 +422,35 @@
"logo_img_path": "", "logo_img_path": "",
"logo_img_path_height": "", "logo_img_path_height": "",
"logo_img_path_width": "", "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_categories": "",
"md_ccc_rates": "", "md_ccc_rates": "",
"md_classes": "", "md_classes": "",
@@ -464,9 +511,13 @@
"use_approvals": "" "use_approvals": ""
}, },
"messaginglabel": "", "messaginglabel": "",
"messaginglabel_short": "",
"messagingtext": "", "messagingtext": "",
"messagingtext_short": "",
"noteslabel": "", "noteslabel": "",
"noteslabel_short": "",
"notestext": "", "notestext": "",
"notestext_short": "",
"notifications": { "notifications": {
"description": "", "description": "",
"invalid_followers": "", "invalid_followers": "",
@@ -600,12 +651,17 @@
"federal_tax_itc": "", "federal_tax_itc": "",
"gogcode": "", "gogcode": "",
"gst_override": "", "gst_override": "",
"invoice_federal_tax_rate_short": "",
"invoice_local_tax_rate_short": "",
"invoice_state_tax_rate_short": "",
"invoiceexemptcode": "", "invoiceexemptcode": "",
"invoiceexemptcode_short": "",
"item_type": "Item Type", "item_type": "Item Type",
"item_type_freight": "", "item_type_freight": "",
"item_type_gog": "", "item_type_gog": "",
"item_type_paint": "", "item_type_paint": "",
"itemexemptcode": "", "itemexemptcode": "",
"itemexemptcode_short": "",
"la1": "", "la1": "",
"la2": "", "la2": "",
"la3": "", "la3": "",
@@ -722,6 +778,7 @@
"customtemplates": "", "customtemplates": "",
"defaultcostsmapping": "", "defaultcostsmapping": "",
"defaultprofitsmapping": "", "defaultprofitsmapping": "",
"dms_setup": "",
"deliverchecklist": "", "deliverchecklist": "",
"dms": { "dms": {
"cdk": { "cdk": {
@@ -738,24 +795,33 @@
}, },
"emaillater": "", "emaillater": "",
"employee_teams": "", "employee_teams": "",
"employee_options": "",
"employee_rates": "",
"employee_vacation": "",
"employees": "", "employees": "",
"estimators": "", "estimators": "",
"filehandlers": "", "filehandlers": "",
"imexpay": "", "imexpay": "",
"insurancecos": "", "insurancecos": "",
"intake_delivery": "",
"intakechecklist": "", "intakechecklist": "",
"intellipay_cash_discount": "", "intellipay_cash_discount": "",
"job_status_options": "",
"jobstatuses": "", "jobstatuses": "",
"laborrates": "", "laborrates": "",
"licensing": "", "licensing": "",
"md_parts_scan": "", "md_parts_scan": "",
"md_ro_guard": "", "md_ro_guard": "",
"md_ro_guard_options": "",
"md_tasks_presets": "", "md_tasks_presets": "",
"task_preset_options": "",
"md_to_emails": "", "md_to_emails": "",
"md_to_emails_emails": "", "md_to_emails_emails": "",
"messagingpresets": "", "messagingpresets": "",
"notification_options": "",
"notemplatesavailable": "", "notemplatesavailable": "",
"notespresets": "", "notespresets": "",
"jump_to_section": "",
"notifications": { "notifications": {
"followers": "" "followers": ""
}, },
@@ -769,11 +835,22 @@
"qbo_departmentid": "", "qbo_departmentid": "",
"qbo_usa": "", "qbo_usa": "",
"rbac": "", "rbac": "",
"rbac_options": "",
"responsibilitycenters": { "responsibilitycenters": {
"costs": "", "costs": "",
"default_tax_setup": "",
"invoices": "",
"profits": "", "profits": "",
"quickbooks_qbd": "",
"quickbooks_us": "",
"sales_tax_codes": "", "sales_tax_codes": "",
"tax_accounts": "", "tax_accounts": "",
"tax_rate_short": "",
"tax_surcharge_short": "",
"tax_threshold_short": "",
"tax_tier_card": "",
"tax_tier_short": "",
"tax_type_card": "",
"title": "", "title": "",
"ttl_adjustment": "", "ttl_adjustment": "",
"ttl_tax_adjustment": "" "ttl_tax_adjustment": ""
@@ -781,6 +858,9 @@
"roguard": { "roguard": {
"title": "" "title": ""
}, },
"autoemail": "",
"jobcosting": "",
"localmediaserver": "",
"romepay": "", "romepay": "",
"scheduling": "", "scheduling": "",
"scoreboardsetup": "", "scoreboardsetup": "",
@@ -788,6 +868,7 @@
"shopinfo": "", "shopinfo": "",
"shoprates": "", "shoprates": "",
"speedprint": "", "speedprint": "",
"speedprint_configurations": "",
"ssbuckets": "", "ssbuckets": "",
"systemsettings": "", "systemsettings": "",
"task-presets": "", "task-presets": "",
@@ -811,7 +892,8 @@
"tooltips": { "tooltips": {
"md_parts_scan": { "md_parts_scan": {
"update_value_tooltip": "" "update_value_tooltip": ""
} },
"reset-color": ""
}, },
"validation": { "validation": {
"centermustexist": "", "centermustexist": "",
@@ -1179,7 +1261,8 @@
"employee_teams": { "employee_teams": {
"actions": { "actions": {
"new": "", "new": "",
"newmember": "" "newmember": "",
"save_team": ""
}, },
"errors": { "errors": {
"allocation_total_exact": "", "allocation_total_exact": "",
@@ -1197,7 +1280,9 @@
"percentage": "" "percentage": ""
}, },
"labels": { "labels": {
"allocation_total": "" "allocation_total": "",
"members": "",
"team_options": ""
}, },
"options": { "options": {
"commission": "", "commission": "",
@@ -1207,9 +1292,11 @@
}, },
"employees": { "employees": {
"actions": { "actions": {
"addrate": "",
"addvacation": "", "addvacation": "",
"new": "Nuevo empleado", "new": "Nuevo empleado",
"newrate": "", "newrate": "",
"save_employee": "",
"select": "" "select": ""
}, },
"errors": { "errors": {
@@ -1241,6 +1328,7 @@
"labels": { "labels": {
"actions": "", "actions": "",
"active": "", "active": "",
"employee_number_short": "",
"endmustbeafterstart": "", "endmustbeafterstart": "",
"flat_rate": "", "flat_rate": "",
"inactive": "", "inactive": "",
@@ -1373,6 +1461,7 @@
"beta": "", "beta": "",
"cancel": "", "cancel": "",
"changelog": "", "changelog": "",
"click_to_begin": "",
"clear": "", "clear": "",
"confirmpassword": "", "confirmpassword": "",
"created_at": "", "created_at": "",
@@ -1918,10 +2007,15 @@
"employee_refinish": "", "employee_refinish": "",
"est_addr1": "Dirección del tasador", "est_addr1": "Dirección del tasador",
"est_co_nm": "Tasador", "est_co_nm": "Tasador",
"est_co_nm_short": "",
"est_ct_fn": "Nombre del tasador", "est_ct_fn": "Nombre del tasador",
"est_ct_fn_short": "",
"est_ct_ln": "Apellido del tasador", "est_ct_ln": "Apellido del tasador",
"est_ct_ln_short": "",
"est_ea": "Correo electrónico del tasador", "est_ea": "Correo electrónico del tasador",
"est_ea_short": "",
"est_ph1": "Número de teléfono del tasador", "est_ph1": "Número de teléfono del tasador",
"est_ph1_short": "",
"estimate_approved": "", "estimate_approved": "",
"estimate_sent_approval": "", "estimate_sent_approval": "",
"federal_tax_payable": "Impuesto federal por pagar", "federal_tax_payable": "Impuesto federal por pagar",
@@ -1934,9 +2028,13 @@
"ins_co_nm": "Nombre de la compañía de seguros", "ins_co_nm": "Nombre de la compañía de seguros",
"ins_co_nm_short": "", "ins_co_nm_short": "",
"ins_ct_fn": "Nombre del controlador de archivos", "ins_ct_fn": "Nombre del controlador de archivos",
"ins_ct_fn_short": "",
"ins_ct_ln": "Apellido del manejador de archivos", "ins_ct_ln": "Apellido del manejador de archivos",
"ins_ct_ln_short": "",
"ins_ea": "Correo electrónico del controlador de archivos", "ins_ea": "Correo electrónico del controlador de archivos",
"ins_ea_short": "",
"ins_ph1": "File Handler Phone #", "ins_ph1": "File Handler Phone #",
"ins_ph1_short": "",
"intake": { "intake": {
"label": "", "label": "",
"max": "", "max": "",

View File

@@ -293,7 +293,23 @@
}, },
"bodyshop": { "bodyshop": {
"actions": { "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_task_preset": "",
"add_to_email_preset": "",
"addapptcolor": "", "addapptcolor": "",
"addbucket": "", "addbucket": "",
"addpartslocation": "", "addpartslocation": "",
@@ -302,11 +318,13 @@
"addtemplate": "", "addtemplate": "",
"newlaborrate": "", "newlaborrate": "",
"newsalestaxcode": "", "newsalestaxcode": "",
"save_shop_information": "",
"newstatus": "", "newstatus": "",
"testrender": "" "testrender": ""
}, },
"errors": { "errors": {
"creatingdefaultview": "", "creatingdefaultview": "",
"duplicate_job_status": "",
"duplicate_insurance_company": "", "duplicate_insurance_company": "",
"loading": "Impossible de charger les détails de la boutique. Veuillez appeler le support technique.", "loading": "Impossible de charger les détails de la boutique. Veuillez appeler le support technique.",
"saving": "", "saving": "",
@@ -404,6 +422,35 @@
"logo_img_path": "", "logo_img_path": "",
"logo_img_path_height": "", "logo_img_path_height": "",
"logo_img_path_width": "", "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_categories": "",
"md_ccc_rates": "", "md_ccc_rates": "",
"md_classes": "", "md_classes": "",
@@ -464,9 +511,13 @@
"use_approvals": "" "use_approvals": ""
}, },
"messaginglabel": "", "messaginglabel": "",
"messaginglabel_short": "",
"messagingtext": "", "messagingtext": "",
"messagingtext_short": "",
"noteslabel": "", "noteslabel": "",
"noteslabel_short": "",
"notestext": "", "notestext": "",
"notestext_short": "",
"notifications": { "notifications": {
"description": "", "description": "",
"invalid_followers": "", "invalid_followers": "",
@@ -600,12 +651,17 @@
"federal_tax_itc": "", "federal_tax_itc": "",
"gogcode": "", "gogcode": "",
"gst_override": "", "gst_override": "",
"invoice_federal_tax_rate_short": "",
"invoice_local_tax_rate_short": "",
"invoice_state_tax_rate_short": "",
"invoiceexemptcode": "", "invoiceexemptcode": "",
"invoiceexemptcode_short": "",
"item_type": "Item Type", "item_type": "Item Type",
"item_type_freight": "", "item_type_freight": "",
"item_type_gog": "", "item_type_gog": "",
"item_type_paint": "", "item_type_paint": "",
"itemexemptcode": "", "itemexemptcode": "",
"itemexemptcode_short": "",
"la1": "", "la1": "",
"la2": "", "la2": "",
"la3": "", "la3": "",
@@ -722,6 +778,7 @@
"customtemplates": "", "customtemplates": "",
"defaultcostsmapping": "", "defaultcostsmapping": "",
"defaultprofitsmapping": "", "defaultprofitsmapping": "",
"dms_setup": "",
"deliverchecklist": "", "deliverchecklist": "",
"dms": { "dms": {
"cdk": { "cdk": {
@@ -738,24 +795,33 @@
}, },
"emaillater": "", "emaillater": "",
"employee_teams": "", "employee_teams": "",
"employee_options": "",
"employee_rates": "",
"employee_vacation": "",
"employees": "", "employees": "",
"estimators": "", "estimators": "",
"filehandlers": "", "filehandlers": "",
"imexpay": "", "imexpay": "",
"insurancecos": "", "insurancecos": "",
"intake_delivery": "",
"intakechecklist": "", "intakechecklist": "",
"intellipay_cash_discount": "", "intellipay_cash_discount": "",
"job_status_options": "",
"jobstatuses": "", "jobstatuses": "",
"laborrates": "", "laborrates": "",
"licensing": "", "licensing": "",
"md_parts_scan": "", "md_parts_scan": "",
"md_ro_guard": "", "md_ro_guard": "",
"md_ro_guard_options": "",
"md_tasks_presets": "", "md_tasks_presets": "",
"task_preset_options": "",
"md_to_emails": "", "md_to_emails": "",
"md_to_emails_emails": "", "md_to_emails_emails": "",
"messagingpresets": "", "messagingpresets": "",
"notification_options": "",
"notemplatesavailable": "", "notemplatesavailable": "",
"notespresets": "", "notespresets": "",
"jump_to_section": "",
"notifications": { "notifications": {
"followers": "" "followers": ""
}, },
@@ -769,11 +835,22 @@
"qbo_departmentid": "", "qbo_departmentid": "",
"qbo_usa": "", "qbo_usa": "",
"rbac": "", "rbac": "",
"rbac_options": "",
"responsibilitycenters": { "responsibilitycenters": {
"costs": "", "costs": "",
"default_tax_setup": "",
"invoices": "",
"profits": "", "profits": "",
"quickbooks_qbd": "",
"quickbooks_us": "",
"sales_tax_codes": "", "sales_tax_codes": "",
"tax_accounts": "", "tax_accounts": "",
"tax_rate_short": "",
"tax_surcharge_short": "",
"tax_threshold_short": "",
"tax_tier_card": "",
"tax_tier_short": "",
"tax_type_card": "",
"title": "", "title": "",
"ttl_adjustment": "", "ttl_adjustment": "",
"ttl_tax_adjustment": "" "ttl_tax_adjustment": ""
@@ -781,6 +858,9 @@
"roguard": { "roguard": {
"title": "" "title": ""
}, },
"autoemail": "",
"jobcosting": "",
"localmediaserver": "",
"romepay": "", "romepay": "",
"scheduling": "", "scheduling": "",
"scoreboardsetup": "", "scoreboardsetup": "",
@@ -788,6 +868,7 @@
"shopinfo": "", "shopinfo": "",
"shoprates": "", "shoprates": "",
"speedprint": "", "speedprint": "",
"speedprint_configurations": "",
"ssbuckets": "", "ssbuckets": "",
"systemsettings": "", "systemsettings": "",
"task-presets": "", "task-presets": "",
@@ -811,7 +892,8 @@
"tooltips": { "tooltips": {
"md_parts_scan": { "md_parts_scan": {
"update_value_tooltip": "" "update_value_tooltip": ""
} },
"reset-color": ""
}, },
"validation": { "validation": {
"centermustexist": "", "centermustexist": "",
@@ -1179,7 +1261,8 @@
"employee_teams": { "employee_teams": {
"actions": { "actions": {
"new": "", "new": "",
"newmember": "" "newmember": "",
"save_team": ""
}, },
"errors": { "errors": {
"allocation_total_exact": "", "allocation_total_exact": "",
@@ -1197,7 +1280,9 @@
"percentage": "" "percentage": ""
}, },
"labels": { "labels": {
"allocation_total": "" "allocation_total": "",
"members": "",
"team_options": ""
}, },
"options": { "options": {
"commission": "", "commission": "",
@@ -1207,9 +1292,11 @@
}, },
"employees": { "employees": {
"actions": { "actions": {
"addrate": "",
"addvacation": "", "addvacation": "",
"new": "Nouvel employé", "new": "Nouvel employé",
"newrate": "", "newrate": "",
"save_employee": "",
"select": "" "select": ""
}, },
"errors": { "errors": {
@@ -1241,6 +1328,7 @@
"labels": { "labels": {
"actions": "", "actions": "",
"active": "", "active": "",
"employee_number_short": "",
"endmustbeafterstart": "", "endmustbeafterstart": "",
"flat_rate": "", "flat_rate": "",
"inactive": "", "inactive": "",
@@ -1373,6 +1461,7 @@
"beta": "", "beta": "",
"cancel": "", "cancel": "",
"changelog": "", "changelog": "",
"click_to_begin": "",
"clear": "", "clear": "",
"confirmpassword": "", "confirmpassword": "",
"created_at": "", "created_at": "",
@@ -1918,10 +2007,15 @@
"employee_refinish": "", "employee_refinish": "",
"est_addr1": "Adresse de l'évaluateur", "est_addr1": "Adresse de l'évaluateur",
"est_co_nm": "Expert", "est_co_nm": "Expert",
"est_co_nm_short": "",
"est_ct_fn": "Prénom de l'évaluateur", "est_ct_fn": "Prénom de l'évaluateur",
"est_ct_fn_short": "",
"est_ct_ln": "Nom de l'évaluateur", "est_ct_ln": "Nom de l'évaluateur",
"est_ct_ln_short": "",
"est_ea": "Courriel de l'évaluateur", "est_ea": "Courriel de l'évaluateur",
"est_ea_short": "",
"est_ph1": "Numéro de téléphone de l'évaluateur", "est_ph1": "Numéro de téléphone de l'évaluateur",
"est_ph1_short": "",
"estimate_approved": "", "estimate_approved": "",
"estimate_sent_approval": "", "estimate_sent_approval": "",
"federal_tax_payable": "Impôt fédéral à payer", "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": "Nom de la compagnie d'assurance",
"ins_co_nm_short": "", "ins_co_nm_short": "",
"ins_ct_fn": "Prénom du gestionnaire de fichiers", "ins_ct_fn": "Prénom du gestionnaire de fichiers",
"ins_ct_fn_short": "",
"ins_ct_ln": "Nom du gestionnaire de fichiers", "ins_ct_ln": "Nom du gestionnaire de fichiers",
"ins_ct_ln_short": "",
"ins_ea": "Courriel du gestionnaire de fichiers", "ins_ea": "Courriel du gestionnaire de fichiers",
"ins_ea_short": "",
"ins_ph1": "Numéro de téléphone du gestionnaire de fichiers", "ins_ph1": "Numéro de téléphone du gestionnaire de fichiers",
"ins_ph1_short": "",
"intake": { "intake": {
"label": "", "label": "",
"max": "", "max": "",

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