IO-3624 Finalize admin config UX and validation polish

This commit is contained in:
Dave
2026-03-25 15:25:59 -04:00
parent b8246e03c1
commit e49500887d
33 changed files with 2223 additions and 960 deletions

View File

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

View File

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

View File

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

View File

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

@@ -50,11 +50,23 @@ html[data-theme="dark"] {
border-color: var(--imex-form-surface-border);
}
&.imex-form-row--error.ant-card {
border-color: var(--ant-color-error);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ant-color-error) 24%, transparent);
}
.ant-card-head {
background: var(--imex-form-surface-head);
border-bottom-color: var(--imex-form-surface-border);
}
&.imex-form-row--error {
.ant-card-head,
.ant-card-actions {
border-color: color-mix(in srgb, var(--ant-color-error) 34%, var(--imex-form-surface-border));
}
}
&.imex-form-row--compact {
.ant-card-head {
min-height: 40px;
@@ -189,3 +201,13 @@ html[data-theme="dark"] {
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

@@ -3,9 +3,8 @@ import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useForm } from "antd/es/form/Form";
import queryString from "query-string";
import { useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
@@ -26,8 +25,10 @@ import { DateFormatter } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import AlertComponent from "../alert/alert.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import FormsFieldChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import {
INLINE_TITLE_GROUP_STYLE,
@@ -49,9 +50,10 @@ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export function ShopEmployeesFormComponent({ bodyshop }) {
export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDirty }) {
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);
@@ -66,17 +68,19 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
const history = useNavigate();
const search = queryString.parse(useLocation().search);
const [deleteVacation] = useMutation(DELETE_VACATION);
const { error, data } = useQuery(QUERY_EMPLOYEE_BY_ID, {
const { error, data, refetch } = useQuery(QUERY_EMPLOYEE_BY_ID, {
variables: { id: search.employeeId },
skip: !search.employeeId || search.employeeId === "new",
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
const notification = useNotification();
const isNewEmployee = search.employeeId === "new";
const currentEmployeeData = data?.employees_by_pk?.id === search.employeeId ? data.employees_by_pk : null;
const employeeTitleName = [firstName, lastName].filter(Boolean).join(" ").trim();
const employeeCardTitle =
[employeeNumber, employeeTitleName].filter(Boolean).join(" - ") ||
(search.employeeId === "new" ? t("employees.actions.new") : t("bodyshop.labels.employees"));
(isNewEmployee ? t("employees.actions.new") : t("bodyshop.labels.employees"));
const {
treatments: { Enhanced_Payroll }
@@ -86,13 +90,49 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
splitKey: bodyshop.imexshopid
});
const updateDirtyState = useCallback(
(nextDirtyState) => {
if (typeof isDirty !== "boolean") {
setInternalIsDirty(nextDirtyState);
}
onDirtyChange?.(nextDirtyState);
},
[isDirty, onDirtyChange]
);
const client = useApolloClient();
useEffect(() => {
if (data && data.employees_by_pk) form.setFieldsValue(data.employees_by_pk);
else {
form.resetFields();
const clearEmployeeFormMeta = useCallback(() => {
const fieldMeta = form.getFieldsError().map(({ name }) => ({
name,
touched: false,
validating: false,
errors: [],
warnings: []
}));
if (fieldMeta.length > 0) {
form.setFields(fieldMeta);
}
}, [form, data, search.employeeId]);
updateDirtyState(false);
}, [form, updateDirtyState]);
const resetEmployeeFormToCurrentData = useCallback(() => {
form.resetFields();
if (currentEmployeeData) {
form.setFieldsValue(currentEmployeeData);
}
window.requestAnimationFrame(() => {
clearEmployeeFormMeta();
});
}, [clearEmployeeFormMeta, currentEmployeeData, form]);
useEffect(() => {
resetEmployeeFormToCurrentData();
}, [resetEmployeeFormToCurrentData, search.employeeId]);
const [updateEmployee] = useMutation(UPDATE_EMPLOYEE);
const [insertEmployees] = useMutation(INSERT_EMPLOYEES);
@@ -112,6 +152,8 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
}
})
.then(() => {
updateDirtyState(false);
void refetch();
notification.success({
title: t("employees.successes.save")
});
@@ -131,6 +173,7 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
variables: { employees: [{ ...values, shopid: bodyshop.id }] },
refetchQueries: ["QUERY_EMPLOYEES"]
}).then((r) => {
updateDirtyState(false);
search.employeeId = r.data.insert_employees.returning[0].id;
history({ search: queryString.stringify(search) });
notification.success({
@@ -199,12 +242,21 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
<Card
title={employeeCardTitle}
extra={
<Button type="primary" onClick={() => form.submit()} style={{ minWidth: 170 }}>
<Button type="primary" onClick={() => form.submit()} disabled={!resolvedIsDirty} style={{ minWidth: 170 }}>
{t("employees.actions.save_employee")}
</Button>
}
>
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
<Form
onFinish={handleFinish}
autoComplete={"off"}
layout="vertical"
form={form}
onValuesChange={() => {
updateDirtyState(form.isFieldsTouched());
}}
>
<FormsFieldChanged form={form} onReset={resetEmployeeFormToCurrentData} onDirtyChange={updateDirtyState} />
<LayoutFormRow
title={
<div
@@ -423,8 +475,10 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
) : (
fields.map((field, index) => {
return (
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
<LayoutFormRow
<Form.Item noStyle key={field.key}>
<InlineValidatedFormRow
form={form}
errorNames={[["rates", field.name, "cost_center"]]}
noDivider
title={
<div style={INLINE_TITLE_ROW_STYLE}>
@@ -495,7 +549,7 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
>
<InputNumber min={0} precision={2} style={{ width: "100%" }} />
</Form.Item>
</LayoutFormRow>
</InlineValidatedFormRow>
</Form.Item>
);
})

View File

@@ -8,7 +8,12 @@ import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.com
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import ResponsiveTable from "../responsive-table/responsive-table.component";
export default function ShopEmployeesListComponent({ loading, employees }) {
export default function ShopEmployeesListComponent({
loading,
employees,
onRequestEmployeeChange,
selectedEmployeeId
}) {
const { t } = useTranslation();
const history = useNavigate();
const search = queryString.parse(useLocation().search);
@@ -19,6 +24,11 @@ export default function ShopEmployeesListComponent({ loading, employees }) {
});
const navigateToEmployee = (employeeId) => {
if (onRequestEmployeeChange) {
onRequestEmployeeChange(employeeId);
return;
}
history({
search: queryString.stringify({
...search,
@@ -127,7 +137,7 @@ export default function ShopEmployeesListComponent({ loading, employees }) {
rowSelection={{
onSelect: (props) => navigateToEmployee(props.id),
type: "radio",
selectedRowKeys: [search.employeeId]
selectedRowKeys: [selectedEmployeeId || search.employeeId]
}}
onChange={handleTableChange}
onRow={(record) => {

View File

@@ -1,10 +1,12 @@
import { Drawer, Grid } from "antd";
import { Drawer, Form, Grid } from "antd";
import { useQuery } from "@apollo/client/react";
import queryString from "query-string";
import { connect } from "react-redux";
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { QUERY_EMPLOYEES } from "../../graphql/employees.queries";
import useConfirmDirtyFormNavigation from "../../hooks/useConfirmDirtyFormNavigation.jsx";
import AlertComponent from "../alert/alert.component";
import ShopEmployeesFormComponent from "./shop-employees-form.component";
import ShopEmployeesListComponent from "./shop-employees-list.component";
@@ -14,6 +16,8 @@ import "./shop-employees.styles.scss";
const mapStateToProps = createStructuredSelector({});
function ShopEmployeesContainer() {
const [form] = Form.useForm();
const [isEmployeeFormDirty, setIsEmployeeFormDirty] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const search = queryString.parse(location.search);
@@ -41,10 +45,28 @@ function ShopEmployeesContainer() {
else if (screens.sm) drawerPercentage = bpoints.sm;
else if (screens.xs) drawerPercentage = bpoints.xs;
const handleDrawerClose = () => {
delete search.employeeId;
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(search)
search: queryString.stringify(nextSearch)
});
};
const handleDrawerClose = () => {
if (!confirmCloseDirtyEmployee()) return;
const nextSearch = { ...search };
delete nextSearch.employeeId;
setIsEmployeeFormDirty(false);
navigate({
search: queryString.stringify(nextSearch)
});
};
@@ -54,7 +76,12 @@ function ShopEmployeesContainer() {
<RbacWrapper action="employees:page">
<div className="shop-employees-layout">
<div className="shop-employees-layout__list">
<ShopEmployeesListComponent employees={data ? data.employees : []} loading={loading} />
<ShopEmployeesListComponent
employees={data ? data.employees : []}
loading={loading}
onRequestEmployeeChange={navigateToEmployee}
selectedEmployeeId={search.employeeId}
/>
</div>
</div>
<Drawer
@@ -64,7 +91,9 @@ function ShopEmployeesContainer() {
size={drawerPercentage}
onClose={handleDrawerClose}
>
{hasSelectedEmployee ? <ShopEmployeesFormComponent /> : null}
{hasSelectedEmployee ? (
<ShopEmployeesFormComponent form={form} onDirtyChange={setIsEmployeeFormDirty} isDirty={isEmployeeFormDirty} />
) : null}
</Drawer>
</RbacWrapper>
);

View File

@@ -35,7 +35,7 @@ const mapDispatchToProps = () => ({
});
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoComponent);
export function ShopInfoComponent({ bodyshop, form, saveLoading }) {
export function ShopInfoComponent({ bodyshop, form, saveLoading, isDirty }) {
const {
treatments: { CriticalPartsScanning, Enhanced_Payroll }
} = useTreatmentsWithConfig({
@@ -165,6 +165,7 @@ export function ShopInfoComponent({ bodyshop, form, saveLoading }) {
extra={
<Button
type="primary"
disabled={!isDirty || saveLoading}
loading={saveLoading}
onClick={() => form.submit()}
id="shop-info-save-button"

View File

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

View File

@@ -6,6 +6,7 @@ import { TemplateList } from "../../utils/TemplateConstants";
import ConfigFormTypes from "../config-form-components/config-form-types";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import {
INLINE_TITLE_GROUP_STYLE,
@@ -32,6 +33,7 @@ export default function ShopInfoIntakeChecklistComponent({ form }) {
<SelectorDiv>
<LayoutFormRow header={t("bodyshop.labels.intake_delivery")} id="intake-delivery">
<Form.Item
col={{ xs: 24, sm: 24, md: 24, lg: 24, xl: 24, xxl: 24 }}
name={["intakechecklist", "templates"]}
label={t("bodyshop.fields.intake.templates")}
rules={[
@@ -41,22 +43,17 @@ export default function ShopInfoIntakeChecklistComponent({ form }) {
type: "array"
}
]}
>
<Select
mode="multiple"
options={Object.keys(TemplateListGenerated).map((i) => ({
value: TemplateListGenerated[i].key,
label: TemplateListGenerated[i].title
}))}
/>
</Form.Item>
<Form.Item
name={["intakechecklist", "next_contact_hours"]}
label={t("bodyshop.fields.intake.next_contact_hours")}
>
<InputNumber min={0} precision={0} />
</Form.Item>
>
<Select
mode="multiple"
options={Object.keys(TemplateListGenerated).map((i) => ({
value: TemplateListGenerated[i].key,
label: TemplateListGenerated[i].title
}))}
/>
</Form.Item>
<Form.Item
col={{ xs: 24, sm: 24, md: 24, lg: 24, xl: 24, xxl: 24 }}
name={["deliverchecklist", "templates"]}
label={t("bodyshop.fields.deliver.templates")}
rules={[
@@ -69,13 +66,21 @@ export default function ShopInfoIntakeChecklistComponent({ form }) {
>
<Select
mode="multiple"
options={Object.keys(TemplateListGenerated).map((i) => ({
value: TemplateListGenerated[i].key,
label: TemplateListGenerated[i].title
}))}
/>
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={[
@@ -114,8 +119,10 @@ export default function ShopInfoIntakeChecklistComponent({ form }) {
) : (
fields.map((field, index) => {
return (
<Form.Item key={field.key}>
<LayoutFormRow
<Form.Item noStyle key={field.key}>
<InlineValidatedFormRow
form={form}
errorNames={[["intakechecklist", "form", field.name, "name"]]}
noDivider
title={
<div style={INLINE_TITLE_ROW_STYLE}>
@@ -234,7 +241,7 @@ export default function ShopInfoIntakeChecklistComponent({ form }) {
);
}}
</Form.Item>
</LayoutFormRow>
</InlineValidatedFormRow>
</Form.Item>
);
})
@@ -269,8 +276,10 @@ export default function ShopInfoIntakeChecklistComponent({ form }) {
) : (
fields.map((field, index) => {
return (
<Form.Item key={field.key}>
<LayoutFormRow
<Form.Item noStyle key={field.key}>
<InlineValidatedFormRow
form={form}
errorNames={[["deliverchecklist", "form", field.name, "name"]]}
noDivider
title={
<div style={INLINE_TITLE_ROW_STYLE}>
@@ -391,7 +400,7 @@ export default function ShopInfoIntakeChecklistComponent({ form }) {
);
}}
</Form.Item>
</LayoutFormRow>
</InlineValidatedFormRow>
</Form.Item>
);
})

View File

@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import {
INLINE_TITLE_GROUP_STYLE,
@@ -15,15 +16,16 @@ import {
export default function ShopInfoLaborRates() {
const { t } = useTranslation();
const form = Form.useFormInstance();
return (
<>
<LayoutFormRow header={t("bodyshop.labels.shoprates")}>
<Form.Item label={t("jobs.fields.rate_ats")} name={["shoprates", "rate_ats"]}>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item label={t("jobs.fields.rate_ats_flat")} name={["shoprates", "rate_ats_flat"]}>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
</LayoutFormRow>
<Form.List name={["md_labor_rates"]}>
@@ -50,8 +52,10 @@ export default function ShopInfoLaborRates() {
) : (
fields.map((field, index) => {
return (
<Form.Item key={field.key}>
<LayoutFormRow
<Form.Item noStyle key={field.key}>
<InlineValidatedFormRow
form={form}
errorNames={[["md_labor_rates", field.name, "rate_label"]]}
noDivider={index === 0}
title={
<div style={INLINE_TITLE_ROW_STYLE}>
@@ -110,7 +114,7 @@ export default function ShopInfoLaborRates() {
}
]}
>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lab")}
@@ -123,7 +127,7 @@ export default function ShopInfoLaborRates() {
}
]}
>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lad")}
@@ -136,7 +140,7 @@ export default function ShopInfoLaborRates() {
}
]}
>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lae")}
@@ -149,7 +153,7 @@ export default function ShopInfoLaborRates() {
}
]}
>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_laf")}
@@ -162,7 +166,7 @@ export default function ShopInfoLaborRates() {
}
]}
>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lag")}
@@ -175,7 +179,7 @@ export default function ShopInfoLaborRates() {
}
]}
>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lam")}
@@ -188,7 +192,7 @@ export default function ShopInfoLaborRates() {
}
]}
>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_lar")}
@@ -201,7 +205,7 @@ export default function ShopInfoLaborRates() {
}
]}
>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_las")}
@@ -214,7 +218,7 @@ export default function ShopInfoLaborRates() {
}
]}
>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_la1")}
@@ -227,7 +231,7 @@ export default function ShopInfoLaborRates() {
}
]}
>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_la2")}
@@ -240,7 +244,7 @@ export default function ShopInfoLaborRates() {
}
]}
>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_la3")}
@@ -253,7 +257,7 @@ export default function ShopInfoLaborRates() {
}
]}
>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_la4")}
@@ -266,7 +270,7 @@ export default function ShopInfoLaborRates() {
}
]}
>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_mash")}
@@ -279,7 +283,7 @@ export default function ShopInfoLaborRates() {
}
]}
>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_mapa")}
@@ -292,7 +296,7 @@ export default function ShopInfoLaborRates() {
}
]}
>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_ma2s")}
@@ -305,7 +309,7 @@ export default function ShopInfoLaborRates() {
}
]}
>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_ma3s")}
@@ -318,7 +322,7 @@ export default function ShopInfoLaborRates() {
}
]}
>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
{
// <Form.Item
@@ -359,7 +363,7 @@ export default function ShopInfoLaborRates() {
}
]}
>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
<Form.Item
label={t("jobs.fields.rate_mahw")}
@@ -372,9 +376,9 @@ export default function ShopInfoLaborRates() {
}
]}
>
<CurrencyInput min={0} />
<CurrencyInput prefix="$" min={0} />
</Form.Item>
</LayoutFormRow>
</InlineValidatedFormRow>
</Form.Item>
);
})

View File

@@ -4,6 +4,7 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import {
INLINE_TITLE_GROUP_STYLE,
@@ -109,8 +110,10 @@ export default function ShopInfoPartsScan({ form }) {
const fieldType = getFieldType(selectedField);
return (
<Form.Item key={field.key}>
<LayoutFormRow
<Form.Item noStyle key={field.key}>
<InlineValidatedFormRow
form={form}
errorNames={[["md_parts_scan", field.name, "field"]]}
noDivider
title={
<div style={INLINE_TITLE_ROW_STYLE}>
@@ -284,7 +287,7 @@ export default function ShopInfoPartsScan({ form }) {
</Form.Item>
</Col>
</Row>
</LayoutFormRow>
</InlineValidatedFormRow>
</Form.Item>
);
})

View File

@@ -1,6 +1,6 @@
import { DeleteFilled, HolderOutlined } from "@ant-design/icons";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, DatePicker, Form, Input, InputNumber, Radio, Select, Space, Switch } from "antd";
import { Button, Col, DatePicker, Divider, Form, Input, InputNumber, Radio, Row, Select, Space, Switch } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
@@ -12,7 +12,8 @@ import DataLabel from "../data-label/data-label.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.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 { buildSectionActionButton, renderListOrEmpty } from "../layout-form-row/config-list-actions.utils.jsx";
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import {
INLINE_TITLE_GROUP_STYLE,
@@ -20,7 +21,8 @@ import {
INLINE_TITLE_INPUT_STYLE,
INLINE_TITLE_LABEL_STYLE,
INLINE_TITLE_ROW_STYLE,
INLINE_TITLE_SEPARATOR_STYLE
INLINE_TITLE_SEPARATOR_STYLE,
INLINE_TITLE_SWITCH_GROUP_STYLE
} from "../layout-form-row/inline-form-row-title.utils.js";
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
import ShopInfoResponsibilitycentersTaxesComponent from "./shop-info.responsibilitycenters.taxes.component";
@@ -42,14 +44,9 @@ export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoResponsibili
export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
const { t } = useTranslation();
const taxAccountRowCol = { xs: 24, sm: 12, md: 12, lg: 6, xl: 6, xxl: 6 };
const taxAccountFullRowCol = { xs: 24 };
const dmsPayers = Form.useWatch(["cdk_configuration", "payers"], form) || [];
const buildSectionActionButton = (key, label, onClick) => (
<Button key={key} type="primary" block onClick={onClick}>
{label}
</Button>
);
const renderListOrEmpty = (fields, actionLabel, renderItems) =>
fields.length === 0 ? <ConfigListEmptyState actionLabel={actionLabel} /> : renderItems();
const hasDMSKey = bodyshopHasDmsKey(bodyshop);
@@ -98,229 +95,7 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
<div>
<RbacWrapper action="shop:responsibilitycenter">
<LayoutFormRow header={t("bodyshop.labels.accountingsetup")} id="accountingsetup">
{[
...(HasFeatureAccess({ featureName: "export", bodyshop })
? !hasDMSKey
? [
<Form.Item
key="qbo"
label={t("bodyshop.labels.qbo")}
valuePropName="checked"
name={["accountingconfig", "qbo"]}
>
<Switch />
</Form.Item>,
InstanceRenderManager({
imex: (
<Form.Item key="qbo_usa_wrapper" shouldUpdate noStyle>
{() => (
<Form.Item
label={t("bodyshop.labels.qbo_usa")}
shouldUpdate
valuePropName="checked"
name={["accountingconfig", "qbo_usa"]}
>
<Switch disabled={!form.getFieldValue(["accountingconfig", "qbo"])} />
</Form.Item>
)}
</Form.Item>
)
}),
<Form.Item
key="qbo_departmentid"
label={t("bodyshop.labels.qbo_departmentid")}
name={["accountingconfig", "qbo_departmentid"]}
>
<Input />
</Form.Item>,
<Form.Item
key="accountingtiers"
label={t("bodyshop.labels.accountingtiers")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["accountingconfig", "tiers"]}
>
<Radio.Group>
<Radio value={2}>2</Radio>
<Radio value={3}>3</Radio>
</Radio.Group>
</Form.Item>,
<Form.Item key="twotierpref_wrapper" shouldUpdate>
{() => {
return (
<Form.Item
label={t("bodyshop.labels.2tiersetup")}
shouldUpdate
rules={[
{
required: form.getFieldValue(["accountingconfig", "tiers"]) === 2
//message: t("general.validation.required"),
}
]}
name={["accountingconfig", "twotierpref"]}
>
<Radio.Group disabled={form.getFieldValue(["accountingconfig", "tiers"]) === 3}>
<Radio value="name">{t("bodyshop.labels.2tiername")}</Radio>
<Radio value="source">{t("bodyshop.labels.2tiersource")}</Radio>
</Radio.Group>
</Form.Item>
);
}}
</Form.Item>,
<Form.Item
key="printlater"
label={t("bodyshop.labels.printlater")}
valuePropName="checked"
name={["accountingconfig", "printlater"]}
>
<Switch />
</Form.Item>,
<Form.Item
key="emaillater"
label={t("bodyshop.labels.emaillater")}
valuePropName="checked"
name={["accountingconfig", "emaillater"]}
>
<Switch />
</Form.Item>,
<Form.Item
key="ReceivableCustomField1"
name={["accountingconfig", "ReceivableCustomField1"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 1 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>,
<Form.Item
key="ReceivableCustomField2"
name={["accountingconfig", "ReceivableCustomField2"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 2 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>,
<Form.Item
key="ReceivableCustomField3"
name={["accountingconfig", "ReceivableCustomField3"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 3 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>,
<Form.Item
key="md_classes"
name={["md_classes"]}
label={t("bodyshop.fields.md_classes")}
rules={[
({ getFieldValue }) => {
return {
required: getFieldValue("enforce_class"),
//message: t("general.validation.required"),
type: "array"
};
}
]}
>
<Select mode="tags" />
</Form.Item>,
<Form.Item
key="enforce_class"
name={["enforce_class"]}
label={t("bodyshop.fields.enforce_class")}
valuePropName="checked"
>
<Switch />
</Form.Item>,
<Form.Item
key="accumulatePayableLines"
name={["accountingconfig", "accumulatePayableLines"]}
label={t("bodyshop.fields.accumulatePayableLines")}
valuePropName="checked"
>
<Switch />
</Form.Item>
]
: []
: []),
<Form.Item
key="inhousevendorid"
label={t("bodyshop.fields.inhousevendorid")}
name={"inhousevendorid"}
rules={[
{
required: true
}
]}
>
<Input />
</Form.Item>,
<Form.Item
key="default_adjustment_rate"
label={t("bodyshop.fields.default_adjustment_rate")}
name={"default_adjustment_rate"}
rules={[
{
required: true
}
]}
>
<InputNumber min={0} precision={2} />
</Form.Item>,
InstanceRenderManager({
imex: (
<Form.Item key="federal_tax_id" label={t("bodyshop.fields.federal_tax_id")} name="federal_tax_id">
<Input />
</Form.Item>
)
}),
<Form.Item key="state_tax_id" label={t("bodyshop.fields.state_tax_id")} name="state_tax_id">
<Input />
</Form.Item>,
...(HasFeatureAccess({ featureName: "bills", bodyshop })
? [
InstanceRenderManager({
imex: (
<Form.Item
key="invoice_federal_tax_rate"
label={t("bodyshop.fields.invoice_federal_tax_rate")}
name={["bill_tax_rates", "federal_tax_rate"]}
rules={[
{
required: true
}
]}
>
<InputNumber />
</Form.Item>
)
}),
<Form.Item
key="invoice_state_tax_rate"
label={t("bodyshop.fields.invoice_state_tax_rate")}
name={["bill_tax_rates", "state_tax_rate"]}
rules={[
{
required: true
}
]}
>
<InputNumber />
</Form.Item>,
<Form.Item
key="invoice_local_tax_rate"
label={t("bodyshop.fields.invoice_local_tax_rate")}
name={["bill_tax_rates", "local_tax_rate"]}
rules={[
{
required: true
}
]}
>
<InputNumber />
</Form.Item>
]
: []),
<>
<Form.Item
key="md_payment_types"
name={["md_payment_types"]}
@@ -333,85 +108,354 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
]}
>
<Select mode="tags" />
</Form.Item>,
...(HasFeatureAccess({ featureName: "timetickets", bodyshop })
? [
</Form.Item>
<div style={{ display: "grid", gap: 16, marginTop: 16 }}>
<Row gutter={[16, 0]} wrap>
<Col xs={24} sm={12} xl={8}>
<Form.Item
key="tt_allow_post_to_invoiced"
name={["tt_allow_post_to_invoiced"]}
label={t("bodyshop.fields.tt_allow_post_to_invoiced")}
valuePropName="checked"
key="inhousevendorid"
label={t("bodyshop.fields.inhousevendorid")}
name={"inhousevendorid"}
rules={[
{
required: true
}
]}
>
<Switch />
</Form.Item>,
<Form.Item
key="tt_enforce_hours_for_tech_console"
name={["tt_enforce_hours_for_tech_console"]}
label={t("bodyshop.fields.tt_enforce_hours_for_tech_console")}
valuePropName="checked"
>
<Switch />
<Input />
</Form.Item>
]
: []),
...(HasFeatureAccess({ featureName: "bills", bodyshop })
? [
</Col>
<Col xs={24} sm={12} xl={8}>
<Form.Item
key="bill_allow_post_to_closed"
name={["bill_allow_post_to_closed"]}
label={t("bodyshop.fields.bill_allow_post_to_closed")}
valuePropName="checked"
key="default_adjustment_rate"
label={t("bodyshop.fields.default_adjustment_rate")}
name={"default_adjustment_rate"}
rules={[
{
required: true
}
]}
>
<Switch />
</Form.Item>,
<Form.Item
key="disableBillCostCalculation"
name={["accountingconfig", "disableBillCostCalculation"]}
label={t("bodyshop.fields.disableBillCostCalculation")}
valuePropName="checked"
>
<Switch />
<InputNumber min={0} precision={2} />
</Form.Item>
]
: []),
...(HasFeatureAccess({ featureName: "export", bodyshop })
? [
...(ClosingPeriod.treatment === "on"
? [
<Form.Item
key="ClosingPeriod"
name={["accountingconfig", "ClosingPeriod"]}
label={t("bodyshop.fields.closingperiod")}
>
<DatePicker.RangePicker format="MM/DD/YYYY" presets={DatePickerRanges} />
</Col>
{InstanceRenderManager({
imex: (
<Col xs={24} sm={12} xl={8}>
<Form.Item key="federal_tax_id" label={t("bodyshop.fields.federal_tax_id")} name="federal_tax_id">
<Input />
</Form.Item>
</Col>
)
})}
<Col xs={24} sm={12} xl={8}>
<Form.Item key="state_tax_id" label={t("bodyshop.fields.state_tax_id")} name="state_tax_id">
<Input />
</Form.Item>
</Col>
{HasFeatureAccess({ featureName: "export", bodyshop }) &&
ClosingPeriod.treatment === "on" && (
<Col xs={24} sm={12} xl={8}>
<Form.Item
key="ClosingPeriod"
name={["accountingconfig", "ClosingPeriod"]}
label={t("bodyshop.fields.closingperiod")}
>
<DatePicker.RangePicker format="MM/DD/YYYY" presets={DatePickerRanges} />
</Form.Item>
</Col>
)}
{HasFeatureAccess({ featureName: "export", bodyshop }) &&
ADPPayroll.treatment === "on" && (
<Col xs={24} sm={12} xl={8}>
<Form.Item
key="companyCode"
name={["accountingconfig", "companyCode"]}
label={t("bodyshop.fields.companycode")}
>
<Input />
</Form.Item>
</Col>
)}
{HasFeatureAccess({ featureName: "export", bodyshop }) &&
ADPPayroll.treatment === "on" && (
<Col xs={24} sm={12} xl={8}>
<Form.Item key="batchID" name={["accountingconfig", "batchID"]} label={t("bodyshop.fields.batchid")}>
<Input />
</Form.Item>
</Col>
)}
{HasFeatureAccess({ featureName: "export", bodyshop }) && !hasDMSKey && (
<>
<Col xs={24} sm={12} xl={8}>
<Form.Item
key="accountingtiers"
label={t("bodyshop.labels.accountingtiers")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["accountingconfig", "tiers"]}
>
<Radio.Group>
<Radio value={2}>2</Radio>
<Radio value={3}>3</Radio>
</Radio.Group>
</Form.Item>
</Col>
<Col xs={24} sm={12} xl={8}>
<Form.Item key="twotierpref_wrapper" shouldUpdate noStyle>
{() => {
return (
<Form.Item
label={t("bodyshop.labels.2tiersetup")}
shouldUpdate
rules={[
{
required: form.getFieldValue(["accountingconfig", "tiers"]) === 2
//message: t("general.validation.required"),
}
]}
name={["accountingconfig", "twotierpref"]}
>
<Radio.Group disabled={form.getFieldValue(["accountingconfig", "tiers"]) === 3}>
<Radio value="name">{t("bodyshop.labels.2tiername")}</Radio>
<Radio value="source">{t("bodyshop.labels.2tiersource")}</Radio>
</Radio.Group>
</Form.Item>
);
}}
</Form.Item>
</Col>
<Col xs={24} sm={12} xl={8}>
<Form.Item
key="md_classes"
name={["md_classes"]}
label={t("bodyshop.fields.md_classes")}
rules={[
({ getFieldValue }) => {
return {
required: getFieldValue("enforce_class"),
//message: t("general.validation.required"),
type: "array"
};
}
]}
>
<Select mode="tags" />
</Form.Item>
</Col>
</>
)}
</Row>
<Divider style={{ margin: "0" }} />
<Row gutter={[16, 0]} wrap>
{HasFeatureAccess({ featureName: "export", bodyshop }) && !hasDMSKey && (
<>
<Col xs={24} sm={12} xl={8}>
<Form.Item
key="printlater"
label={t("bodyshop.labels.printlater")}
valuePropName="checked"
name={["accountingconfig", "printlater"]}
>
<Switch />
</Form.Item>
</Col>
<Col xs={24} sm={12} xl={8}>
<Form.Item
key="emaillater"
label={t("bodyshop.labels.emaillater")}
valuePropName="checked"
name={["accountingconfig", "emaillater"]}
>
<Switch />
</Form.Item>
</Col>
<Col xs={24} sm={12} xl={8}>
<Form.Item
key="enforce_class"
name={["enforce_class"]}
label={t("bodyshop.fields.enforce_class")}
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
<Col xs={24} sm={12} xl={8}>
<Form.Item
key="accumulatePayableLines"
name={["accountingconfig", "accumulatePayableLines"]}
label={t("bodyshop.fields.accumulatePayableLines")}
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
</>
)}
{HasFeatureAccess({ featureName: "timetickets", bodyshop }) && (
<>
<Col xs={24} sm={12} xl={8}>
<Form.Item
key="tt_allow_post_to_invoiced"
name={["tt_allow_post_to_invoiced"]}
label={t("bodyshop.fields.tt_allow_post_to_invoiced")}
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
<Col xs={24} sm={12} xl={8}>
<Form.Item
key="tt_enforce_hours_for_tech_console"
name={["tt_enforce_hours_for_tech_console"]}
label={t("bodyshop.fields.tt_enforce_hours_for_tech_console")}
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
</>
)}
{HasFeatureAccess({ featureName: "bills", bodyshop }) && (
<>
<Col xs={24} sm={12} xl={8}>
<Form.Item
key="bill_allow_post_to_closed"
name={["bill_allow_post_to_closed"]}
label={t("bodyshop.fields.bill_allow_post_to_closed")}
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
<Col xs={24} sm={12} xl={8}>
<Form.Item
key="disableBillCostCalculation"
name={["accountingconfig", "disableBillCostCalculation"]}
label={t("bodyshop.fields.disableBillCostCalculation")}
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
</>
)}
</Row>
{HasFeatureAccess({ featureName: "export", bodyshop }) && !hasDMSKey && (
<LayoutFormRow
header={t("bodyshop.labels.responsibilitycenters.quickbooks_qbd")}
grow
style={{ marginBottom: 0 }}
extra={
<Space size={12} align="center" split={<span style={INLINE_TITLE_SEPARATOR_STYLE} />}>
<div style={INLINE_TITLE_SWITCH_GROUP_STYLE}>
<span style={INLINE_TITLE_LABEL_STYLE}>{t("bodyshop.labels.qbo")}</span>
<Form.Item noStyle valuePropName="checked" name={["accountingconfig", "qbo"]}>
<Switch />
</Form.Item>
]
: []),
...(ADPPayroll.treatment === "on"
? [
<Form.Item
key="companyCode"
name={["accountingconfig", "companyCode"]}
label={t("bodyshop.fields.companycode")}
>
<Input />
</Form.Item>
]
: []),
...(ADPPayroll.treatment === "on"
? [
<Form.Item
key="batchID"
name={["accountingconfig", "batchID"]}
label={t("bodyshop.fields.batchid")}
>
<Input />
</Form.Item>
]
: [])
]
: [])
]}
</div>
{InstanceRenderManager({
imex: (
<div style={INLINE_TITLE_SWITCH_GROUP_STYLE}>
<span style={INLINE_TITLE_LABEL_STYLE}>{t("bodyshop.labels.qbo_usa")}</span>
<Form.Item key="qbo_usa_wrapper" shouldUpdate noStyle>
{() => (
<Form.Item noStyle valuePropName="checked" name={["accountingconfig", "qbo_usa"]}>
<Switch disabled={!form.getFieldValue(["accountingconfig", "qbo"])} />
</Form.Item>
)}
</Form.Item>
</div>
)
})}
</Space>
}
>
<Form.Item
key="qbo_departmentid"
label={t("bodyshop.labels.qbo_departmentid")}
name={["accountingconfig", "qbo_departmentid"]}
>
<Input />
</Form.Item>
<Form.Item
key="ReceivableCustomField1"
name={["accountingconfig", "ReceivableCustomField1"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 1 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
<Form.Item
key="ReceivableCustomField2"
name={["accountingconfig", "ReceivableCustomField2"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 2 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
<Form.Item
key="ReceivableCustomField3"
name={["accountingconfig", "ReceivableCustomField3"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 3 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
</LayoutFormRow>
)}
{HasFeatureAccess({ featureName: "bills", bodyshop }) && (
<LayoutFormRow header={t("bodyshop.labels.responsibilitycenters.invoices")} grow style={{ marginBottom: 0 }}>
{InstanceRenderManager({
imex: (
<Form.Item
key="invoice_federal_tax_rate"
label={t("bodyshop.fields.responsibilitycenters.invoice_federal_tax_rate_short")}
name={["bill_tax_rates", "federal_tax_rate"]}
rules={[
{
required: true
}
]}
>
<InputNumber suffix="%" />
</Form.Item>
)
})}
<Form.Item
key="invoice_state_tax_rate"
label={t("bodyshop.fields.responsibilitycenters.invoice_state_tax_rate_short")}
name={["bill_tax_rates", "state_tax_rate"]}
rules={[
{
required: true
}
]}
>
<InputNumber suffix="%" />
</Form.Item>
<Form.Item
key="invoice_local_tax_rate"
label={t("bodyshop.fields.responsibilitycenters.invoice_local_tax_rate_short")}
name={["bill_tax_rates", "local_tax_rate"]}
rules={[
{
required: true
}
]}
>
<InputNumber suffix="%" />
</Form.Item>
</LayoutFormRow>
)}
</div>
</>
</LayoutFormRow>
{hasDMSKey && (
<LayoutFormRow header={t("bodyshop.labels.dms_setup")} id="dms_setup">
@@ -465,7 +509,7 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
label={t("bodyshop.fields.dms.sendmaterialscosting")}
name={["cdk_configuration", "sendmaterialscosting"]}
>
<InputNumber min={0} max={100} />
<InputNumber min={0} max={100} suffix="%" />
</Form.Item>
{bodyshop.pbs_serialnumber && (
<Form.Item
@@ -687,8 +731,13 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
{renderListOrEmpty(fields, t("bodyshop.actions.add_cost_center"), () =>
fields.map((field, index) => {
return (
<Form.Item key={field.key}>
<LayoutFormRow
<Form.Item noStyle key={field.key}>
<InlineValidatedFormRow
form={form}
errorNames={[
["md_responsibility_centers", "costs", field.name, "name"],
["md_responsibility_centers", "costs", field.name, "accountdesc"]
]}
noDivider
title={
<div style={INLINE_TITLE_ROW_STYLE}>
@@ -804,7 +853,7 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
<Input onBlur={handleBlur} />
</Form.Item>
)}
</LayoutFormRow>
</InlineValidatedFormRow>
</Form.Item>
);
})
@@ -830,8 +879,13 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
{renderListOrEmpty(fields, t("bodyshop.actions.add_profit_center"), () =>
fields.map((field, index) => {
return (
<Form.Item key={field.key}>
<LayoutFormRow
<Form.Item noStyle key={field.key}>
<InlineValidatedFormRow
form={form}
errorNames={[
["md_responsibility_centers", "profits", field.name, "name"],
["md_responsibility_centers", "profits", field.name, "accountdesc"]
]}
noDivider
title={
<div style={INLINE_TITLE_ROW_STYLE}>
@@ -974,7 +1028,7 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
/>
</Form.Item>
]}
</LayoutFormRow>
</InlineValidatedFormRow>
</Form.Item>
);
})
@@ -3277,90 +3331,110 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
)}
<LayoutFormRow header={t("bodyshop.labels.responsibilitycenters.tax_accounts")} id="tax_accounts">
<Form.Item
label={t("bodyshop.fields.responsibilitycenters.federal_tax")}
rules={[{ required: true }]}
name={["md_responsibility_centers", "taxes", "federal", "name"]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.responsibilitycenter_accountdesc")}
rules={[{ required: true }]}
name={["md_responsibility_centers", "taxes", "federal", "accountdesc"]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.responsibilitycenter_accountitem")}
rules={[{ required: true }]}
name={["md_responsibility_centers", "taxes", "federal", "accountitem"]}
>
<Input />
</Form.Item>
{hasDMSKey && (
<Form.Item
label={t("bodyshop.fields.dms.dms_acctnumber")}
rules={[{ required: true }]}
name={["md_responsibility_centers", "taxes", "federal", "dms_acctnumber"]}
<div style={{ display: "grid", gap: 16 }}>
<LayoutFormRow
header={t("bodyshop.fields.responsibilitycenters.federal_tax")}
grow
style={{ marginBottom: 0 }}
>
<Input />
</Form.Item>
)}
<Form.Item
label={t("bodyshop.fields.responsibilitycenter_rate")}
rules={[{ required: true }]}
name={["md_responsibility_centers", "taxes", "federal", "rate"]}
>
<InputNumber precision={2} />
</Form.Item>
{InstanceRenderManager({
imex: [
<Form.Item
key="state_tax_name"
label={t("bodyshop.fields.responsibilitycenters.state_tax")}
label={t("bodyshop.fields.responsibilitycenters.federal_tax")}
rules={[{ required: true }]}
name={["md_responsibility_centers", "taxes", "state", "name"]}
name={["md_responsibility_centers", "taxes", "federal", "name"]}
col={taxAccountRowCol}
>
<Input />
</Form.Item>,
</Form.Item>
<Form.Item
key="state_tax_accountdesc"
label={t("bodyshop.fields.responsibilitycenter_accountdesc")}
rules={[{ required: true }]}
name={["md_responsibility_centers", "taxes", "state", "accountdesc"]}
name={["md_responsibility_centers", "taxes", "federal", "accountdesc"]}
col={taxAccountRowCol}
>
<Input />
</Form.Item>,
</Form.Item>
<Form.Item
key="state_tax_accountitem"
label={t("bodyshop.fields.responsibilitycenter_accountitem")}
rules={[{ required: true }]}
name={["md_responsibility_centers", "taxes", "state", "accountitem"]}
name={["md_responsibility_centers", "taxes", "federal", "accountitem"]}
col={taxAccountRowCol}
>
<Input />
</Form.Item>,
hasDMSKey ? (
</Form.Item>
<Form.Item
label={t("bodyshop.fields.responsibilitycenter_rate")}
rules={[{ required: true }]}
name={["md_responsibility_centers", "taxes", "federal", "rate"]}
col={taxAccountRowCol}
>
<InputNumber precision={2} suffix="%" />
</Form.Item>
{hasDMSKey && (
<Form.Item
key="state_tax_dms_acctnumber"
label={t("bodyshop.fields.dms.dms_acctnumber")}
rules={[{ required: true }]}
name={["md_responsibility_centers", "taxes", "state", "dms_acctnumber"]}
name={["md_responsibility_centers", "taxes", "federal", "dms_acctnumber"]}
col={taxAccountFullRowCol}
>
<Input />
</Form.Item>
) : null,
<Form.Item
key="state_tax_rate"
label={t("bodyshop.fields.responsibilitycenter_rate")}
rules={[{ required: true }]}
name={["md_responsibility_centers", "taxes", "state", "rate"]}
>
<InputNumber precision={2} />
</Form.Item>
],
rome: null
})}
)}
</LayoutFormRow>
{InstanceRenderManager({
imex: (
<LayoutFormRow
header={t("bodyshop.fields.responsibilitycenters.state_tax")}
grow
style={{ marginBottom: 0 }}
>
<Form.Item
label={t("bodyshop.fields.responsibilitycenters.state_tax")}
rules={[{ required: true }]}
name={["md_responsibility_centers", "taxes", "state", "name"]}
col={taxAccountRowCol}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.responsibilitycenter_accountdesc")}
rules={[{ required: true }]}
name={["md_responsibility_centers", "taxes", "state", "accountdesc"]}
col={taxAccountRowCol}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.responsibilitycenter_accountitem")}
rules={[{ required: true }]}
name={["md_responsibility_centers", "taxes", "state", "accountitem"]}
col={taxAccountRowCol}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.responsibilitycenter_rate")}
rules={[{ required: true }]}
name={["md_responsibility_centers", "taxes", "state", "rate"]}
col={taxAccountRowCol}
>
<InputNumber precision={2} suffix="%" />
</Form.Item>
{hasDMSKey && (
<Form.Item
label={t("bodyshop.fields.dms.dms_acctnumber")}
rules={[{ required: true }]}
name={["md_responsibility_centers", "taxes", "state", "dms_acctnumber"]}
col={taxAccountFullRowCol}
>
<Input />
</Form.Item>
)}
</LayoutFormRow>
),
rome: null
})}
</div>
</LayoutFormRow>
{DmsAp.treatment === "on" && (
@@ -3400,7 +3474,7 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
rules={[{ required: true }]}
name={["md_responsibility_centers", "taxes", "federal_itc", "rate"]}
>
<InputNumber precision={2} />
<InputNumber precision={2} suffix="%" />
</Form.Item>
</LayoutFormRow>
)}
@@ -3541,8 +3615,13 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
{renderListOrEmpty(fields, t("bodyshop.actions.newsalestaxcode"), () =>
fields.map((field, index) => {
return (
<Form.Item key={field.key}>
<LayoutFormRow
<Form.Item noStyle key={field.key}>
<InlineValidatedFormRow
form={form}
errorNames={[
["md_responsibility_centers", "sales_tax_codes", field.name, "description"],
["md_responsibility_centers", "sales_tax_codes", field.name, "code"]
]}
id="sales_tax_codes"
noDivider
title={
@@ -3623,7 +3702,7 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
>
<Switch />
</Form.Item>
</LayoutFormRow>
</InlineValidatedFormRow>
</Form.Item>
);
})

View File

@@ -112,7 +112,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAB", "lbr_adjp"]}
>
<InputNumber min={-100} max={100} precision={4} />
<InputNumber min={-100} max={100} precision={4} suffix="%" />
</Form.Item>
<Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
@@ -139,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>
);
}}
@@ -185,7 +185,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAD", "lbr_adjp"]}
>
<InputNumber min={-100} max={100} precision={4} />
<InputNumber min={-100} max={100} precision={4} suffix="%" />
</Form.Item>
<Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
@@ -212,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>
);
}}
@@ -258,7 +258,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAE", "lbr_adjp"]}
>
<InputNumber min={-100} max={100} precision={4} />
<InputNumber min={-100} max={100} precision={4} suffix="%" />
</Form.Item>
<Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
@@ -285,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>
);
}}
@@ -331,7 +331,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAF", "lbr_adjp"]}
>
<InputNumber min={-100} max={100} precision={4} />
<InputNumber min={-100} max={100} precision={4} suffix="%" />
</Form.Item>
<Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
@@ -358,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>
);
}}
@@ -404,7 +404,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAG", "lbr_adjp"]}
>
<InputNumber min={-100} max={100} precision={4} />
<InputNumber min={-100} max={100} precision={4} suffix="%" />
</Form.Item>
<Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
@@ -431,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>
);
}}
@@ -477,7 +477,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAM", "lbr_adjp"]}
>
<InputNumber min={-100} max={100} precision={4} />
<InputNumber min={-100} max={100} precision={4} suffix="%" />
</Form.Item>
<Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
@@ -504,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>
);
}}
@@ -550,7 +550,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAR", "lbr_adjp"]}
>
<InputNumber min={-100} max={100} precision={4} />
<InputNumber min={-100} max={100} precision={4} suffix="%" />
</Form.Item>
<Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
@@ -577,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>
);
}}
@@ -623,7 +623,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.cieca_pfl.lbr_adjp")}
name={["md_responsibility_centers", "cieca_pfl", "LAS", "lbr_adjp"]}
>
<InputNumber min={-100} max={100} precision={4} />
<InputNumber min={-100} max={100} precision={4} suffix="%" />
</Form.Item>
<Form.Item
label={t("jobs.fields.cieca_pfl.lbr_tax_in")}
@@ -723,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>
);
}}
@@ -790,7 +790,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.materials.mat_adjp")}
name={["md_responsibility_centers", "cieca_pfm", "MAPA", "mat_adjp"]}
>
<InputNumber min={-100} max={100} precision={4} />
<InputNumber min={-100} max={100} precision={4} suffix="%" />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.tax_ind")}
@@ -817,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>
);
}}
@@ -875,7 +875,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
label={t("jobs.fields.materials.mat_adjp")}
name={["md_responsibility_centers", "cieca_pfm", "MASH", "mat_adjp"]}
>
<InputNumber min={-100} max={100} precision={4} />
<InputNumber min={-100} max={100} precision={4} suffix="%" />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.tax_ind")}
@@ -902,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>
);
}}
@@ -2318,7 +2318,7 @@ function getTierTaxFormItems({ typeNum, typeNumIterator, t }) {
]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_rate${typeNumIterator}`]}
>
<InputNumber min={0} precision={2} />
<InputNumber min={0} precision={2} suffix="%" />
</Form.Item>,
<Form.Item
key={`tax_type_${typeNum}_surcharge_${typeNumIterator}`}
@@ -2331,7 +2331,7 @@ function getTierTaxFormItems({ typeNum, typeNumIterator, t }) {
]}
name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_sur${typeNumIterator}`]}
>
<InputNumber min={0} precision={2} />
<InputNumber min={0} precision={2} suffix="%" />
</Form.Item>
];
}

View File

@@ -32,7 +32,7 @@ export default function ShopInfoRoGuard({ form }) {
}
]}
>
<InputNumber min={0} max={100} precision={1} disabled={disabled} />
<InputNumber min={0} max={100} precision={1} suffix="%" disabled={disabled} />
</Form.Item>
<Form.Item

View File

@@ -1,13 +1,15 @@
import { CloseOutlined, 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 { ChromePicker } from "react-color";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { DEFAULT_TRANSLUCENT_CARD_COLOR, getTintedCardSurfaceStyles } from "./shop-info.color.utils";
@@ -58,20 +60,43 @@ const SelectorDiv = styled.div`
.job-statuses-source-tag-wrapper {
display: inline-flex;
max-width: 100%;
margin-inline-end: 4px;
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 {
@@ -79,7 +104,22 @@ const SelectorDiv = styled.div`
}
.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 {
@@ -89,6 +129,30 @@ const SelectorDiv = styled.div`
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
@@ -99,11 +163,13 @@ const DraggableStatusTag = ({ label, value, closable, onClose }) => {
<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 }}
onPointerDownCapture={(event) => {
if (event.target.closest(".ant-tag-close-icon")) {
event.stopPropagation();
}
onMouseDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
}}
{...attributes}
{...listeners}
@@ -117,9 +183,21 @@ const DraggableStatusTag = ({ label, value, closable, onClose }) => {
}
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
@@ -140,8 +218,12 @@ const DraggableStatusTag = ({ label, value, closable, onClose }) => {
);
};
const SortableStatusesSelect = ({ value, onChange }) => {
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: {
@@ -151,16 +233,75 @@ const SortableStatusesSelect = ({ value, onChange }) => {
);
const handleStatusesChange = (nextValues) => {
onChange?.(normalizeStatuses(nextValues));
const normalizedNextValues = normalizeStatuses(nextValues);
if (isTagsMode) {
setKnownStatuses((currentKnownStatuses) => normalizeStatuses([...currentKnownStatuses, ...normalizedNextValues]));
}
onChange?.(normalizedNextValues);
};
const handleStatusSortEnd = ({ active, over }) => {
if (!over || active.id === over.id) return;
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 (oldIndex < 0 || newIndex < 0) return;
if (newIndex < 0) return;
onChange?.(arrayMove(statuses, oldIndex, newIndex));
};
@@ -169,18 +310,50 @@ const SortableStatusesSelect = ({ value, onChange }) => {
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 (
<DndContext collisionDetection={closestCenter} onDragEnd={handleStatusSortEnd} sensors={tagSensors}>
<SortableContext items={statuses} strategy={rectSortingStrategy}>
<Select
className="job-statuses-source-select"
mode="tags"
onChange={handleStatusesChange}
tagRender={renderStatusTag}
value={statuses}
/>
</SortableContext>
</DndContext>
<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>
);
};
@@ -209,13 +382,20 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
<Form.Item
name={["md_ro_statuses", "statuses"]}
label={t("bodyshop.labels.alljobstatuses")}
required
rules={[
{
validator: async (_, value) => {
const populatedStatuses = normalizeStatuses(value);
if (populatedStatuses.length === 0) {
return Promise.reject(new Error(t("general.validation.required")));
return Promise.reject(
new Error(
t("general.validation.required", {
label: t("bodyshop.labels.alljobstatuses")
})
)
);
}
if (populatedStatuses.length !== (value || []).filter(Boolean).length) {
@@ -238,7 +418,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
}
]}
>
<Select mode="multiple" options={statusSelectOptions} />
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "pre_production_statuses"]}
@@ -251,7 +431,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
}
]}
>
<Select mode="multiple" options={statusSelectOptions} />
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "production_statuses"]}
@@ -264,7 +444,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
}
]}
>
<Select mode="multiple" options={statusSelectOptions} />
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "post_production_statuses"]}
@@ -277,7 +457,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
}
]}
>
<Select mode="multiple" options={statusSelectOptions} />
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "ready_statuses"]}
@@ -290,7 +470,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
}
]}
>
<Select mode="multiple" options={statusSelectOptions} />
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
</Form.Item>
<Form.Item
name={["md_ro_statuses", "additional_board_statuses"]}
@@ -303,7 +483,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
}
]}
>
<Select mode="multiple" options={statusSelectOptions} />
<SortableStatusesSelect mode="multiple" options={statusSelectOptions} />
</Form.Item>
</div>
</LayoutFormRow>
@@ -449,7 +629,9 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
);
return (
<LayoutFormRow
<InlineValidatedFormRow
form={form}
errorNames={[["md_ro_statuses", "production_colors", field.name, "status"]]}
key={field.key}
noDivider
title={
@@ -506,7 +688,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
<ColorPicker />
</Form.Item>
</div>
</LayoutFormRow>
</InlineValidatedFormRow>
);
})}
</Space>

View File

@@ -1,5 +1,5 @@
import { DeleteFilled, ReloadOutlined } from "@ant-design/icons";
import { Button, Form, Input, InputNumber, Select, Space, Switch, TimePicker, Tooltip } from "antd";
import { Button, Col, Form, Input, InputNumber, Row, Select, Space, Switch, TimePicker, Tooltip } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -8,6 +8,7 @@ import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import ColorpickerFormItemComponent from "../form-items-formatted/colorpicker-form-item.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { ColorPicker } from "./shop-info.rostatus.component";
import {
@@ -142,82 +143,96 @@ export function ShopInfoSchedulingComponent({ form, bodyshop }) {
return (
<div>
<LayoutFormRow grow header={t("bodyshop.labels.scheduling")} id="shopinfo-scheduling">
<Form.Item
label={t("bodyshop.fields.appt_length")}
name={"appt_length"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={15} precision={0} />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.schedule_start_time")}
name={"schedule_start_time"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
id="schedule_start_time"
>
<TimePicker disableSeconds={true} format="HH:mm" />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.schedule_end_time")}
name={"schedule_end_time"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
id="schedule_end_time"
>
<TimePicker disableSeconds={true} format="HH:mm" />
</Form.Item>
<Form.Item
name={["appt_alt_transport"]}
label={t("bodyshop.fields.appt_alt_transport")}
rules={[
{
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>
<Form.Item
name={["ss_configuration", "dailyhrslimit"]}
label={t("bodyshop.fields.ss_configuration.dailyhrslimit")}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={["ss_configuration", "nobusinessdays"]}
label={t("bodyshop.fields.ss_configuration.nobusinessdays")}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={["md_lost_sale_reasons"]}
label={t("bodyshop.fields.md_lost_sale_reasons")}
rules={[
{
// required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>
<>
<Form.Item
name={["appt_alt_transport"]}
label={t("bodyshop.fields.appt_alt_transport")}
rules={[
{
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>
<Form.Item
name={["md_lost_sale_reasons"]}
label={t("bodyshop.fields.md_lost_sale_reasons")}
rules={[
{
// required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>
<Row gutter={[16, 0]} wrap>
<Col xs={24} sm={12} xl={6}>
<Form.Item
label={t("bodyshop.fields.appt_length")}
name={"appt_length"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={15} precision={0} suffix="min" />
</Form.Item>
</Col>
<Col xs={24} sm={12} xl={6}>
<Form.Item
label={t("bodyshop.fields.schedule_start_time")}
name={"schedule_start_time"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
id="schedule_start_time"
>
<TimePicker disableSeconds={true} format="HH:mm" />
</Form.Item>
</Col>
<Col xs={24} sm={12} xl={6}>
<Form.Item
label={t("bodyshop.fields.schedule_end_time")}
name={"schedule_end_time"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
id="schedule_end_time"
>
<TimePicker disableSeconds={true} format="HH:mm" />
</Form.Item>
</Col>
<Col xs={24} sm={12} xl={6}>
<Form.Item
name={["ss_configuration", "dailyhrslimit"]}
label={t("bodyshop.fields.ss_configuration.dailyhrslimit")}
>
<InputNumber min={0} suffix="hrs" />
</Form.Item>
</Col>
<Col xs={24} sm={12} xl={6}>
<Form.Item
name={["ss_configuration", "nobusinessdays"]}
label={t("bodyshop.fields.ss_configuration.nobusinessdays")}
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
</Row>
</>
</LayoutFormRow>
<LayoutFormRow header={t("bodyshop.labels.workingdays")} id="workingdays">
<Space wrap size="middle">
@@ -262,8 +277,10 @@ export function ShopInfoSchedulingComponent({ form, bodyshop }) {
const appointmentColorSurfaceStyles = getTintedCardSurfaceStyles(appointmentColor.color);
return (
<Form.Item key={field.key}>
<LayoutFormRow
<Form.Item noStyle key={field.key}>
<InlineValidatedFormRow
form={form}
errorNames={[["appt_colors", field.name, "label"]]}
noDivider
title={
<div style={{ minWidth: 180, maxWidth: "100%" }}>
@@ -318,7 +335,7 @@ export function ShopInfoSchedulingComponent({ form, bodyshop }) {
>
<ColorpickerFormItemComponent styles={APPOINTMENT_COLOR_PICKER_STYLES} />
</Form.Item>
</LayoutFormRow>
</InlineValidatedFormRow>
</Form.Item>
);
})
@@ -360,8 +377,13 @@ export function ShopInfoSchedulingComponent({ form, bodyshop }) {
const schedulingBucketSurfaceStyles = getTintedCardSurfaceStyles(schedulingBucket.color);
return (
<Form.Item key={field.key}>
<LayoutFormRow
<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}>
@@ -470,7 +492,7 @@ export function ShopInfoSchedulingComponent({ form, bodyshop }) {
}
]}
>
<InputNumber />
<InputNumber suffix="hrs" />
</Form.Item>
<Form.Item
@@ -478,7 +500,7 @@ export function ShopInfoSchedulingComponent({ form, bodyshop }) {
key={`${index}lt`}
name={[field.name, "lt"]}
>
<InputNumber />
<InputNumber suffix="hrs" />
</Form.Item>
<Form.Item
@@ -501,7 +523,7 @@ export function ShopInfoSchedulingComponent({ form, bodyshop }) {
</Form.Item>
</div>
</div>
</LayoutFormRow>
</InlineValidatedFormRow>
</Form.Item>
);
})

View File

@@ -29,20 +29,19 @@ export default function ShopInfoSectionNavigator({ tabsRef, activeTabKey }) {
const nextTargetMap = new Map();
const nextOptions = Array.from(activePane.querySelectorAll(".imex-form-row"))
.filter((card) => {
const titleNode = getOwnCardTitleNode(card);
if (!titleNode?.textContent?.trim()) return false;
const ancestorCard = card.parentElement?.closest(".imex-form-row");
return !ancestorCard || !activePane.contains(ancestorCard);
return shouldIncludeCardInNavigator(card, activePane);
})
.map((card, index) => {
const label = getOwnCardTitleNode(card)?.textContent?.trim();
const { title, depth, searchLabel } = getCardNavigatorInfo(card, activePane);
const value = `${activeTabKey}-shop-info-section-${index}`;
nextTargetMap.set(value, card);
return {
label,
label: renderNavigatorOptionLabel(title, depth),
labelText: title,
searchLabel,
depth,
value
};
});
@@ -103,12 +102,13 @@ export default function ShopInfoSectionNavigator({ tabsRef, activeTabKey }) {
<div className="shop-info-section-navigator">
<Select
allowClear
showSearch={{ optionFilterProp: "label" }}
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>
@@ -120,6 +120,77 @@ function getOwnCardTitleNode(card) {
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);
@@ -132,6 +203,11 @@ function areOptionsEqual(currentOptions, nextOptions) {
return currentOptions.every((option, index) => {
const nextOption = nextOptions[index];
return option.label === nextOption.label && option.value === nextOption.value;
return (
option.labelText === nextOption.labelText &&
option.searchLabel === nextOption.searchLabel &&
option.depth === nextOption.depth &&
option.value === nextOption.value
);
});
}

View File

@@ -7,6 +7,32 @@
}
}
.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,

View File

@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { TemplateList } from "../../utils/TemplateConstants";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import {
INLINE_TITLE_GROUP_STYLE,
@@ -17,6 +18,7 @@ import InstanceRenderManager from "../../utils/instanceRenderMgr";
export default function ShopInfoSpeedPrint() {
const { t } = useTranslation();
const form = Form.useFormInstance();
const allTemplates = TemplateList("job");
const TemplateListGenerated = InstanceRenderManager({
imex: Object.fromEntries(Object.entries(allTemplates).filter(([, { enhanced_payroll }]) => !enhanced_payroll)),
@@ -48,8 +50,13 @@ export default function ShopInfoSpeedPrint() {
) : (
fields.map((field, index) => {
return (
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
<LayoutFormRow
<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}>
@@ -140,7 +147,7 @@ export default function ShopInfoSpeedPrint() {
}))}
/>
</Form.Item>
</LayoutFormRow>
</InlineValidatedFormRow>
</Form.Item>
);
})

View File

@@ -257,7 +257,7 @@ export function ShopInfoTaskPresets({ bodyshop }) {
]}
name={[field.name, "percent"]}
>
<InputNumber min={0} max={100} />
<InputNumber min={0} max={100} suffix="%" />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.md_tasks_presets.memo")}

View File

@@ -3,7 +3,7 @@ import { useMutation, useQuery } from "@apollo/client/react";
import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Skeleton, Space, Switch, Typography } from "antd";
import querystring from "query-string";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
@@ -11,9 +11,11 @@ import { createStructuredSelector } from "reselect";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component";
import FormsFieldChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import {
INLINE_TITLE_GROUP_STYLE,
@@ -61,16 +63,19 @@ const formatAllocationPercentage = (percentage) => {
return `${numericValue.toFixed(2).replace(/\.?0+$/, "")}%`;
};
export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
export function ShopEmployeeTeamsFormComponent({ bodyshop, form, onDirtyChange, isDirty }) {
const { t } = useTranslation();
const [form] = Form.useForm();
const [internalForm] = Form.useForm();
const [internalIsDirty, setInternalIsDirty] = useState(false);
const teamForm = form ?? internalForm;
const resolvedIsDirty = typeof isDirty === "boolean" ? isDirty : internalIsDirty;
const history = useNavigate();
const search = querystring.parse(useLocation().search);
const notification = useNotification();
const [hydratedTeamId, setHydratedTeamId] = useState(search.employeeTeamId === "new" ? "new" : null);
const isNewTeam = search.employeeTeamId === "new";
const { error, data, loading } = useQuery(QUERY_EMPLOYEE_TEAM_BY_ID, {
const { error, data, loading, refetch } = useQuery(QUERY_EMPLOYEE_TEAM_BY_ID, {
variables: { id: search.employeeTeamId },
skip: !search.employeeTeamId || isNewTeam,
fetchPolicy: "network-only",
@@ -78,38 +83,71 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
notifyOnNetworkStatusChange: true
});
useEffect(() => {
if (!search.employeeTeamId) return;
const currentTeamData = data?.employee_teams_by_pk?.id === search.employeeTeamId ? data.employee_teams_by_pk : null;
const updateDirtyState = useCallback(
(nextDirtyState) => {
if (typeof isDirty !== "boolean") {
setInternalIsDirty(nextDirtyState);
}
onDirtyChange?.(nextDirtyState);
},
[isDirty, onDirtyChange]
);
const clearTeamFormMeta = useCallback(() => {
const fieldMeta = teamForm.getFieldsError().map(({ name }) => ({
name,
touched: false,
validating: false,
errors: [],
warnings: []
}));
if (fieldMeta.length > 0) {
teamForm.setFields(fieldMeta);
}
updateDirtyState(false);
}, [teamForm, updateDirtyState]);
const resetTeamFormToCurrentData = useCallback(() => {
let hydrationFrameId;
teamForm.resetFields();
if (isNewTeam) {
form.resetFields();
setHydratedTeamId("new");
return;
hydrationFrameId = window.requestAnimationFrame(() => {
clearTeamFormMeta();
});
return () => {
if (hydrationFrameId) window.cancelAnimationFrame(hydrationFrameId);
};
}
setHydratedTeamId(null);
}, [form, isNewTeam, search.employeeTeamId]);
useEffect(() => {
if (!search.employeeTeamId || isNewTeam || loading) return;
let hydrationFrameId;
if (data?.employee_teams_by_pk?.id === search.employeeTeamId) {
form.setFieldsValue(normalizeEmployeeTeam(data.employee_teams_by_pk));
hydrationFrameId = window.requestAnimationFrame(() => {
setHydratedTeamId(search.employeeTeamId);
});
} else {
form.resetFields();
hydrationFrameId = window.requestAnimationFrame(() => {
setHydratedTeamId(search.employeeTeamId);
});
if (loading) {
return undefined;
}
if (currentTeamData) {
teamForm.setFieldsValue(normalizeEmployeeTeam(currentTeamData));
}
hydrationFrameId = window.requestAnimationFrame(() => {
setHydratedTeamId(search.employeeTeamId);
clearTeamFormMeta();
});
return () => {
if (hydrationFrameId) window.cancelAnimationFrame(hydrationFrameId);
};
}, [data, form, isNewTeam, loading, search.employeeTeamId]);
}, [clearTeamFormMeta, currentTeamData, isNewTeam, loading, search.employeeTeamId, teamForm]);
useEffect(() => resetTeamFormToCurrentData(), [resetTeamFormToCurrentData]);
const [updateEmployeeTeam] = useMutation(UPDATE_EMPLOYEE_TEAM);
const [insertEmployeeTeam] = useMutation(INSERT_EMPLOYEE_TEAM);
@@ -117,8 +155,8 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
label: t(labelKey),
value
}));
const teamName = Form.useWatch("name", form);
const teamMembers = Form.useWatch(["employee_team_members"], form) || [];
const teamName = Form.useWatch("name", teamForm);
const teamMembers = Form.useWatch(["employee_team_members"], teamForm) || [];
const isTeamHydrating = !isNewTeam && Boolean(search.employeeTeamId) && hydratedTeamId !== search.employeeTeamId;
const isAllocationTotalExact = hasExactSplitTotal(teamMembers);
const allocationTotalValue = formatAllocationPercentage(getSplitTotal(teamMembers))?.replace("%", "") || "0";
@@ -172,6 +210,8 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
});
if (!result.errors) {
updateDirtyState(false);
void refetch();
notification.success({
title: t("employees.successes.save")
});
@@ -195,6 +235,7 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
},
refetchQueries: ["QUERY_TEAMS"]
}).then((response) => {
updateDirtyState(false);
search.employeeTeamId = response.data.insert_employee_teams_one.id;
history({ search: querystring.stringify(search) });
notification.success({
@@ -211,7 +252,12 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
<Card
title={isTeamHydrating ? undefined : teamCardTitle}
extra={
<Button type="primary" onClick={() => form.submit()} disabled={isTeamHydrating} style={{ minWidth: 190 }}>
<Button
type="primary"
onClick={() => teamForm.submit()}
disabled={isTeamHydrating || !resolvedIsDirty}
style={{ minWidth: 190 }}
>
{t("employee_teams.actions.save_team")}
</Button>
}
@@ -219,7 +265,16 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
{isTeamHydrating ? (
<Skeleton active title={false} paragraph={{ rows: 12 }} />
) : (
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
<Form
onFinish={handleFinish}
autoComplete={"off"}
layout="vertical"
form={teamForm}
onValuesChange={() => {
updateDirtyState(teamForm.isFieldsTouched());
}}
>
<FormsFieldChanged form={teamForm} onReset={resetTeamFormToCurrentData} onDirtyChange={updateDirtyState} />
<LayoutFormRow
title={
<div
@@ -307,11 +362,17 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
) : (
fields.map((field, index) => {
return (
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
<Form.Item noStyle key={field.key}>
<Form.Item name={[field.name, "id"]} hidden>
<Input type="hidden" />
</Form.Item>
<LayoutFormRow
<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}>
@@ -410,7 +471,7 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
>
{() => {
const payoutMethod =
form.getFieldValue(["employee_team_members", field.name, "payout_method"]) ||
teamForm.getFieldValue(["employee_team_members", field.name, "payout_method"]) ||
"hourly";
const fieldName = payoutMethod === "commission" ? "commission_rates" : "labor_rates";
@@ -443,7 +504,7 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
}}
</Form.Item>
</div>
</LayoutFormRow>
</InlineValidatedFormRow>
</Form.Item>
);
})

View File

@@ -68,6 +68,10 @@ vi.mock("../../contexts/Notifications/notificationContext.jsx", () => ({
useNotification: () => notification
}));
vi.mock("../form-fields-changed-alert/form-fields-changed-alert.component.jsx", () => ({
default: () => null
}));
vi.mock("../../firebase/firebase.utils", () => ({
logImEXEvent: vi.fn()
}));

View File

@@ -6,12 +6,22 @@ import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.com
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import ResponsiveTable from "../responsive-table/responsive-table.component";
export default function ShopEmployeeTeamsListComponent({ loading, employee_teams }) {
export default function ShopEmployeeTeamsListComponent({
loading,
employee_teams,
onRequestTeamChange,
selectedTeamId
}) {
const { t } = useTranslation();
const history = useNavigate();
const search = queryString.parse(useLocation().search);
const navigateToTeam = (employeeTeamId) => {
if (onRequestTeamChange) {
onRequestTeamChange(employeeTeamId);
return;
}
history({
search: queryString.stringify({
...search,
@@ -65,7 +75,7 @@ export default function ShopEmployeeTeamsListComponent({ loading, employee_teams
rowSelection={{
onSelect: (props) => navigateToTeam(props.id),
type: "radio",
selectedRowKeys: [search.employeeTeamId]
selectedRowKeys: [selectedTeamId || search.employeeTeamId]
}}
onRow={(record) => {
return {

View File

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

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

@@ -421,6 +421,35 @@
"logo_img_path": "Shop Logo",
"logo_img_path_height": "Logo Image Height",
"logo_img_path_width": "Logo Image Width",
"scoreboard_setup": {
"daily_body_target": "Daily Body Target",
"daily_paint_target": "Daily Paint Target",
"ignore_blocked_days": "Ignore Blocked Days",
"last_number_working_days": "Last Number of Working Days",
"production_target_hours": "Production Target Hours"
},
"system_settings": {
"auto_email": {
"attach_pdf_to_email": "Attach PDF to Sent Emails?",
"from_emails": "Additional From Emails",
"parts_order_cc": "Parts Orders CC",
"parts_return_slip_cc": "Parts Returns CC"
},
"job_costing": {
"paint_hour_split": "Paint Hour Split",
"paint_materials_hourly_cost_rate": "Paint Materials Hourly Cost Rate",
"prep_hour_split": "Prep Hour Split",
"shop_materials_hourly_cost_rate": "Shop Materials Hourly Cost Rate",
"target_touch_time": "Target Touch Time",
"use_paint_scale_data": "Use Paint Scale Data"
},
"local_media_server": {
"enabled": "Enabled",
"http_path": "HTTP Path",
"network_path": "Network Path",
"token": "Token"
}
},
"md_categories": "Categories",
"md_ccc_rates": "Courtesy Car Contract Rate Presets",
"md_classes": "Classes",
@@ -621,6 +650,9 @@
"federal_tax_itc": "Federal Tax Credit",
"gogcode": "GOG Code (BreakOut)",
"gst_override": "GST Override Account #",
"invoice_federal_tax_rate_short": "Federal Tax Rate",
"invoice_local_tax_rate_short": "Local Tax Rate",
"invoice_state_tax_rate_short": "State Tax Rate",
"invoiceexemptcode": "QuickBooks US - Invoice Tax Exempt Code",
"invoiceexemptcode_short": "Invoice Tax Exempt Code",
"item_type": "Item Type",
@@ -806,7 +838,9 @@
"responsibilitycenters": {
"costs": "Cost Centers",
"default_tax_setup": "Default Tax Setup",
"invoices": "Invoices",
"profits": "Profit Centers",
"quickbooks_qbd": "QuickBooks / QBD",
"quickbooks_us": "QuickBooks US",
"sales_tax_codes": "Sales Tax Codes",
"tax_accounts": "Tax Accounts",
@@ -823,6 +857,9 @@
"roguard": {
"title": "RO Guard"
},
"autoemail": "Auto Email",
"jobcosting": "Job Costing",
"localmediaserver": "Local Media Server",
"romepay": "Rome Pay",
"scheduling": "SMART Scheduling",
"scoreboardsetup": "Scoreboard Setup",

View File

@@ -421,6 +421,35 @@
"logo_img_path": "",
"logo_img_path_height": "",
"logo_img_path_width": "",
"scoreboard_setup": {
"daily_body_target": "",
"daily_paint_target": "",
"ignore_blocked_days": "",
"last_number_working_days": "",
"production_target_hours": ""
},
"system_settings": {
"auto_email": {
"attach_pdf_to_email": "",
"from_emails": "",
"parts_order_cc": "",
"parts_return_slip_cc": ""
},
"job_costing": {
"paint_hour_split": "",
"paint_materials_hourly_cost_rate": "",
"prep_hour_split": "",
"shop_materials_hourly_cost_rate": "",
"target_touch_time": "",
"use_paint_scale_data": ""
},
"local_media_server": {
"enabled": "",
"http_path": "",
"network_path": "",
"token": ""
}
},
"md_categories": "",
"md_ccc_rates": "",
"md_classes": "",
@@ -621,6 +650,9 @@
"federal_tax_itc": "",
"gogcode": "",
"gst_override": "",
"invoice_federal_tax_rate_short": "",
"invoice_local_tax_rate_short": "",
"invoice_state_tax_rate_short": "",
"invoiceexemptcode": "",
"invoiceexemptcode_short": "",
"item_type": "Item Type",
@@ -806,7 +838,9 @@
"responsibilitycenters": {
"costs": "",
"default_tax_setup": "",
"invoices": "",
"profits": "",
"quickbooks_qbd": "",
"quickbooks_us": "",
"sales_tax_codes": "",
"tax_accounts": "",
@@ -823,6 +857,9 @@
"roguard": {
"title": ""
},
"autoemail": "",
"jobcosting": "",
"localmediaserver": "",
"romepay": "",
"scheduling": "",
"scoreboardsetup": "",

View File

@@ -421,6 +421,35 @@
"logo_img_path": "",
"logo_img_path_height": "",
"logo_img_path_width": "",
"scoreboard_setup": {
"daily_body_target": "",
"daily_paint_target": "",
"ignore_blocked_days": "",
"last_number_working_days": "",
"production_target_hours": ""
},
"system_settings": {
"auto_email": {
"attach_pdf_to_email": "",
"from_emails": "",
"parts_order_cc": "",
"parts_return_slip_cc": ""
},
"job_costing": {
"paint_hour_split": "",
"paint_materials_hourly_cost_rate": "",
"prep_hour_split": "",
"shop_materials_hourly_cost_rate": "",
"target_touch_time": "",
"use_paint_scale_data": ""
},
"local_media_server": {
"enabled": "",
"http_path": "",
"network_path": "",
"token": ""
}
},
"md_categories": "",
"md_ccc_rates": "",
"md_classes": "",
@@ -621,6 +650,9 @@
"federal_tax_itc": "",
"gogcode": "",
"gst_override": "",
"invoice_federal_tax_rate_short": "",
"invoice_local_tax_rate_short": "",
"invoice_state_tax_rate_short": "",
"invoiceexemptcode": "",
"invoiceexemptcode_short": "",
"item_type": "Item Type",
@@ -806,7 +838,9 @@
"responsibilitycenters": {
"costs": "",
"default_tax_setup": "",
"invoices": "",
"profits": "",
"quickbooks_qbd": "",
"quickbooks_us": "",
"sales_tax_codes": "",
"tax_accounts": "",
@@ -823,6 +857,9 @@
"roguard": {
"title": ""
},
"autoemail": "",
"jobcosting": "",
"localmediaserver": "",
"romepay": "",
"scheduling": "",
"scoreboardsetup": "",