diff --git a/client/src/components/alert/alert.component.jsx b/client/src/components/alert/alert.component.jsx index 439822f68..f57dcb5fd 100644 --- a/client/src/components/alert/alert.component.jsx +++ b/client/src/components/alert/alert.component.jsx @@ -1,5 +1,5 @@ import { Alert } from "antd"; -export default function AlertComponent(props) { - return ; +export default function AlertComponent({ title, message, ...props }) { + return ; } diff --git a/client/src/components/form-fields-changed-alert/form-fields-changed-alert.component.jsx b/client/src/components/form-fields-changed-alert/form-fields-changed-alert.component.jsx index 3254c9f74..69c5a1831 100644 --- a/client/src/components/form-fields-changed-alert/form-fields-changed-alert.component.jsx +++ b/client/src/components/form-fields-changed-alert/form-fields-changed-alert.component.jsx @@ -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 ( {() => { - 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 ( @@ -39,10 +222,35 @@ export default function FormsFieldChanged({ form, skipPrompt }) { {errors.length > 0 && ( -
    {errors.map((e, idx) => e.errors.map((e2, idx2) =>
  • {e2}
  • ))}
+
+ {errorGroups.map((group) => ( +
+ {hasTabbedErrorGroups && group.label ? ( +
{group.label}
+ ) : null} +
    + {group.errors.map((error) => ( +
  • + {Array.isArray(error.namePath) && error.namePath.length > 0 ? ( + + ) : ( + error.message + )} +
  • + ))} +
+
+ ))}
} showIcon diff --git a/client/src/components/form-fields-changed-alert/form-fields-changed.styles.scss b/client/src/components/form-fields-changed-alert/form-fields-changed.styles.scss index 155407907..cb3e3940f 100644 --- a/client/src/components/form-fields-changed-alert/form-fields-changed.styles.scss +++ b/client/src/components/form-fields-changed-alert/form-fields-changed.styles.scss @@ -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; + } + } } diff --git a/client/src/components/form-items-formatted/url-form-item.component.jsx b/client/src/components/form-items-formatted/url-form-item.component.jsx new file mode 100644 index 000000000..c038442fc --- /dev/null +++ b/client/src/components/form-items-formatted/url-form-item.component.jsx @@ -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 ( + + + {urlActionHref ? ( + +); + +export const renderConfigListOrEmpty = ({ fields, actionLabel, renderItems }) => + fields.length === 0 ? : renderItems(); + +export const buildSectionActionButton = (key, label, onClick, id) => + buildConfigListActionButton({ key, label, onClick, id }); + +export const renderListOrEmpty = (fields, actionLabel, renderItems) => + renderConfigListOrEmpty({ fields, actionLabel, renderItems }); diff --git a/client/src/components/layout-form-row/inline-validated-form-row.component.jsx b/client/src/components/layout-form-row/inline-validated-form-row.component.jsx new file mode 100644 index 000000000..70910856b --- /dev/null +++ b/client/src/components/layout-form-row/inline-validated-form-row.component.jsx @@ -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 ( + + {() => { + 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 + ? [ +
0 ? 8 : 0, + width: "100%", + textAlign: "left" + }} + > + + {normalizedActions.length > 0 ?
{normalizedActions}
: null} +
+ ] + : normalizedActions.length > 0 + ? normalizedActions + : undefined; + + return ; + }} +
+ ); +} diff --git a/client/src/components/layout-form-row/layout-form-row.styles.scss b/client/src/components/layout-form-row/layout-form-row.styles.scss index 3e8674362..2c95c7973 100644 --- a/client/src/components/layout-form-row/layout-form-row.styles.scss +++ b/client/src/components/layout-form-row/layout-form-row.styles.scss @@ -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); + } +} diff --git a/client/src/components/shop-employees/shop-employees-form.component.jsx b/client/src/components/shop-employees/shop-employees-form.component.jsx index cd677e6d1..a47e577b3 100644 --- a/client/src/components/shop-employees/shop-employees-form.component.jsx +++ b/client/src/components/shop-employees/shop-employees-form.component.jsx @@ -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 }) { form.submit()} style={{ minWidth: 170 }}> + } > -
+ { + updateDirtyState(form.isFieldsTouched()); + }} + > + { return ( - - + @@ -495,7 +549,7 @@ export function ShopEmployeesFormComponent({ bodyshop }) { > - + ); }) diff --git a/client/src/components/shop-employees/shop-employees-list.component.jsx b/client/src/components/shop-employees/shop-employees-list.component.jsx index 787554877..45e434b5a 100644 --- a/client/src/components/shop-employees/shop-employees-list.component.jsx +++ b/client/src/components/shop-employees/shop-employees-list.component.jsx @@ -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) => { diff --git a/client/src/components/shop-employees/shop-employees.container.jsx b/client/src/components/shop-employees/shop-employees.container.jsx index 59ab1a611..3582ee40c 100644 --- a/client/src/components/shop-employees/shop-employees.container.jsx +++ b/client/src/components/shop-employees/shop-employees.container.jsx @@ -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() {
- +
- {hasSelectedEmployee ? : null} + {hasSelectedEmployee ? ( + + ) : null}
); diff --git a/client/src/components/shop-info/shop-info.component.jsx b/client/src/components/shop-info/shop-info.component.jsx index ee7e40bd9..901a14afe 100644 --- a/client/src/components/shop-info/shop-info.component.jsx +++ b/client/src/components/shop-info/shop-info.component.jsx @@ -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={ - ); - const renderListOrEmpty = (fields, actionLabel, renderItems) => - fields.length === 0 ? : renderItems(); + const insuranceCompanies = Form.useWatch(["md_ins_cos"], form) || []; + const duplicateInsuranceCompanyIndexes = getDuplicateIndexSetByNormalizedName(insuranceCompanies, "name"); return (
@@ -99,7 +96,7 @@ export function ShopInfoGeneral({ form }) { - + - + - + - + - + - + - + null}> - + +
+ {t("bodyshop.labels.scoreboardsetup")} +
+
+
+
+
{t("bodyshop.fields.scoreboard_setup.ignore_blocked_days")}
+ + + +
+
+
+ } + wrapTitle + > - + - - - - +
- {[ + <> - , - - - , - - - , - - - , - - - , - - - , - ({ - validator(rule, value) { - if (!value && !getFieldValue(["md_hour_split", "paint"])) { - return Promise.resolve(); - } - if (value + getFieldValue(["md_hour_split", "paint"]) === 1) { - return Promise.resolve(); - } - return Promise.reject(t("bodyshop.validation.larsplit")); - } - }) - ]} - > - - , - ({ - validator(rule, value) { - if (!value && !getFieldValue(["md_hour_split", "paint"])) { - return Promise.resolve(); - } - if (value + getFieldValue(["md_hour_split", "prep"]) === 1) { - return Promise.resolve(); - } - return Promise.reject(t("bodyshop.validation.larsplit")); - } - }) - ]} - > - - , - - - , - - - , - - - , - - - , - - - , - - - , - - - , - - - , - - - , - - - , - - - , - - - ]} - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ {t("bodyshop.labels.localmediaserver")} +
+
+
+
+
+ {t("bodyshop.fields.system_settings.local_media_server.enabled")} +
+ + + +
+
+
+ } + wrapTitle + > + + + + + + + + + +
+ + + + + + + + + + - + ); }) @@ -882,8 +1001,14 @@ export function ShopInfoGeneral({ form }) { {renderListOrEmpty(fields, t("bodyshop.actions.add_estimator"), () => fields.map((field, index) => { return ( - - + @@ -974,7 +1099,7 @@ export function ShopInfoGeneral({ form }) { > - + ); }) @@ -1001,7 +1126,7 @@ export function ShopInfoGeneral({ form }) { {renderListOrEmpty(fields, t("bodyshop.actions.add_adjuster"), () => fields.map((field, index) => { return ( - + fields.map((field, index) => { return ( - - + @@ -1166,7 +1293,7 @@ export function ShopInfoGeneral({ form }) { key={`${index}actax`} name={[field.name, "actax"]} > - + - + - + - + - + - + - + - + - + - + ); }) @@ -1260,8 +1387,13 @@ export function ShopInfoGeneral({ form }) { {renderListOrEmpty(fields, t("bodyshop.actions.add_jobline_preset"), () => fields.map((field, index) => { return ( - - + @@ -1355,7 +1487,7 @@ export function ShopInfoGeneral({ form }) { key={`${index}mod_lb_hrs`} name={[field.name, "mod_lb_hrs"]} > - + - + - + - + ); }) @@ -1444,8 +1576,10 @@ export function ShopInfoGeneral({ form }) { {renderListOrEmpty(fields, t("bodyshop.actions.add_parts_order_comment"), () => fields.map((field, index) => { return ( - - + - + ); }) @@ -1538,8 +1672,10 @@ export function ShopInfoGeneral({ form }) { {renderListOrEmpty(fields, t("bodyshop.actions.add_to_email_preset"), () => fields.map((field, index) => { return ( - - + @@ -1601,7 +1737,7 @@ export function ShopInfoGeneral({ form }) { > - + ); }) @@ -1614,3 +1750,23 @@ export function ShopInfoGeneral({ form }) {
); } + +function getDuplicateIndexSetByNormalizedName(list, key) { + const indexes = new Set(); + const firstIndexByValue = new Map(); + + (Array.isArray(list) ? list : []).forEach((item, index) => { + const normalizedValue = (item?.[key] ?? "").toString().trim().toLowerCase(); + if (!normalizedValue) return; + + if (firstIndexByValue.has(normalizedValue)) { + indexes.add(firstIndexByValue.get(normalizedValue)); + indexes.add(index); + return; + } + + firstIndexByValue.set(normalizedValue, index); + }); + + return indexes; +} diff --git a/client/src/components/shop-info/shop-info.intake.component.jsx b/client/src/components/shop-info/shop-info.intake.component.jsx index 182b4c878..cd0d80033 100644 --- a/client/src/components/shop-info/shop-info.intake.component.jsx +++ b/client/src/components/shop-info/shop-info.intake.component.jsx @@ -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 }) { - ({ + value: TemplateListGenerated[i].key, + label: TemplateListGenerated[i].title + }))} + /> + - , - - - 2 - 3 - - , - - {() => { - return ( - - - {t("bodyshop.labels.2tiername")} - {t("bodyshop.labels.2tiersource")} - - - ); - }} - , - - - , - - - , - - {ReceivableCustomFieldSelect} - , - - {ReceivableCustomFieldSelect} - , - - {ReceivableCustomFieldSelect} - , - { - return { - required: getFieldValue("enforce_class"), - //message: t("general.validation.required"), - type: "array" - }; - } - ]} - > - - , - - - , - InstanceRenderManager({ - imex: ( - - - - ) - }), - - - , - ...(HasFeatureAccess({ featureName: "bills", bodyshop }) - ? [ - InstanceRenderManager({ - imex: ( - - - - ) - }), - - - , - - - - ] - : []), + <> - ] - : []), - ...(HasFeatureAccess({ featureName: "bills", bodyshop }) - ? [ + + - - , - - + - ] - : []), - ...(HasFeatureAccess({ featureName: "export", bodyshop }) - ? [ - ...(ClosingPeriod.treatment === "on" - ? [ - - + + {InstanceRenderManager({ + imex: ( + + + + + + ) + })} + + + + + + {HasFeatureAccess({ featureName: "export", bodyshop }) && + ClosingPeriod.treatment === "on" && ( + + + + + + )} + {HasFeatureAccess({ featureName: "export", bodyshop }) && + ADPPayroll.treatment === "on" && ( + + + + + + )} + {HasFeatureAccess({ featureName: "export", bodyshop }) && + ADPPayroll.treatment === "on" && ( + + + + + + )} + {HasFeatureAccess({ featureName: "export", bodyshop }) && !hasDMSKey && ( + <> + + + + 2 + 3 + + + + + + {() => { + return ( + + + {t("bodyshop.labels.2tiername")} + {t("bodyshop.labels.2tiersource")} + + + ); + }} + + + + { + return { + required: getFieldValue("enforce_class"), + //message: t("general.validation.required"), + type: "array" + }; + } + ]} + > + - - ] - : []), - ...(ADPPayroll.treatment === "on" - ? [ - - - - ] - : []) - ] - : []) - ]} +
+ {InstanceRenderManager({ + imex: ( +
+ {t("bodyshop.labels.qbo_usa")} + + {() => ( + + + + )} + +
+ ) + })} +
+ } + > + + + + + {ReceivableCustomFieldSelect} + + + {ReceivableCustomFieldSelect} + + + {ReceivableCustomFieldSelect} + + + )} + + {HasFeatureAccess({ featureName: "bills", bodyshop }) && ( + + {InstanceRenderManager({ + imex: ( + + + + ) + })} + + + + + + + + )} + + {hasDMSKey && ( @@ -465,7 +509,7 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) { label={t("bodyshop.fields.dms.sendmaterialscosting")} name={["cdk_configuration", "sendmaterialscosting"]} > - +
{bodyshop.pbs_serialnumber && ( fields.map((field, index) => { return ( - - + @@ -804,7 +853,7 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) { )} - + ); }) @@ -830,8 +879,13 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) { {renderListOrEmpty(fields, t("bodyshop.actions.add_profit_center"), () => fields.map((field, index) => { return ( - - + @@ -974,7 +1028,7 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) { /> ]} - + ); }) @@ -3277,90 +3331,110 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) { )} - - - - - - - - - - {hasDMSKey && ( - + - - - )} - - - - {InstanceRenderManager({ - imex: [ - , + - , + - , - hasDMSKey ? ( + + + + + {hasDMSKey && ( - ) : null, - - - - ], - rome: null - })} + )} + + + {InstanceRenderManager({ + imex: ( + + + + + + + + + + + + + + {hasDMSKey && ( + + + + )} + + ), + rome: null + })} + {DmsAp.treatment === "on" && ( @@ -3400,7 +3474,7 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) { rules={[{ required: true }]} name={["md_responsibility_centers", "taxes", "federal_itc", "rate"]} > - + )} @@ -3541,8 +3615,13 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) { {renderListOrEmpty(fields, t("bodyshop.actions.newsalestaxcode"), () => fields.map((field, index) => { return ( - - + - + ); }) diff --git a/client/src/components/shop-info/shop-info.responsibilitycenters.taxes.component.jsx b/client/src/components/shop-info/shop-info.responsibilitycenters.taxes.component.jsx index cfa0f589d..d742628eb 100644 --- a/client/src/components/shop-info/shop-info.responsibilitycenters.taxes.component.jsx +++ b/client/src/components/shop-info/shop-info.responsibilitycenters.taxes.component.jsx @@ -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"]} > - + - + ); }} @@ -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"]} > - + - + ); }} @@ -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"]} > - + - + ); }} @@ -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"]} > - + - + ); }} @@ -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"]} > - + - + ); }} @@ -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"]} > - + - + ); }} @@ -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"]} > - + - + ); }} @@ -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"]} > - + - + ); }} @@ -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"]} > - + - + ); }} @@ -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"]} > - + - + ); }} @@ -2318,7 +2318,7 @@ function getTierTaxFormItems({ typeNum, typeNumIterator, t }) { ]} name={["md_responsibility_centers", "taxes", `tax_ty${typeNum}`, `ty${typeNum}_rate${typeNumIterator}`]} > - + , - + ]; } diff --git a/client/src/components/shop-info/shop-info.roguard.component.jsx b/client/src/components/shop-info/shop-info.roguard.component.jsx index 117f5e92a..66638a9f5 100644 --- a/client/src/components/shop-info/shop-info.roguard.component.jsx +++ b/client/src/components/shop-info/shop-info.roguard.component.jsx @@ -32,7 +32,7 @@ export default function ShopInfoRoGuard({ form }) { } ]} > - + [...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 }) => { { - 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} > + + + {labelText} {closable ? ( { ); }; -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 ; }; + const statusSelectOptions = isTagsMode + ? knownStatuses.map((status) => ({ + value: status, + label: status + })) + : options; + + if (statuses.length === 0) { + return ( + - - +
+ { + dragRectRef.current = null; + }} + onDragEnd={handleStatusSortEnd} + onDragMove={({ active, delta }) => { + dragRectRef.current = getTranslatedDragRect(active, delta); + }} + sensors={tagSensors} + > + + + - + - + - - - - - - - - - - + + + option?.searchLabel?.toLowerCase().includes(input.toLowerCase())} onChange={handleSectionChange} />
@@ -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 ( + 0 ? "shop-info-section-navigator__option--subsection" : null + ] + .filter(Boolean) + .join(" ")} + > + {title} + + ); +} + 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 + ); }); } diff --git a/client/src/components/shop-info/shop-info.section-navigator.styles.scss b/client/src/components/shop-info/shop-info.section-navigator.styles.scss index eff4d1a0f..ba9a1b9d0 100644 --- a/client/src/components/shop-info/shop-info.section-navigator.styles.scss +++ b/client/src/components/shop-info/shop-info.section-navigator.styles.scss @@ -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, diff --git a/client/src/components/shop-info/shop-info.speedprint.component.jsx b/client/src/components/shop-info/shop-info.speedprint.component.jsx index 33b5e55fe..4498170d1 100644 --- a/client/src/components/shop-info/shop-info.speedprint.component.jsx +++ b/client/src/components/shop-info/shop-info.speedprint.component.jsx @@ -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 ( - - + @@ -140,7 +147,7 @@ export default function ShopInfoSpeedPrint() { }))} /> - +
); }) diff --git a/client/src/components/shop-info/shop-info.task-presets.component.jsx b/client/src/components/shop-info/shop-info.task-presets.component.jsx index d3d3cb66a..987451f5e 100644 --- a/client/src/components/shop-info/shop-info.task-presets.component.jsx +++ b/client/src/components/shop-info/shop-info.task-presets.component.jsx @@ -257,7 +257,7 @@ export function ShopInfoTaskPresets({ bodyshop }) { ]} name={[field.name, "percent"]} > - + { 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 }) { form.submit()} disabled={isTeamHydrating} style={{ minWidth: 190 }}> + } @@ -219,7 +265,16 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) { {isTeamHydrating ? ( ) : ( -
+ { + updateDirtyState(teamForm.isFieldsTouched()); + }} + > + { return ( - + - @@ -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 }) { }} - + ); }) diff --git a/client/src/components/shop-teams/shop-employee-teams.form.component.test.jsx b/client/src/components/shop-teams/shop-employee-teams.form.component.test.jsx index da20b74c5..9208b6a64 100644 --- a/client/src/components/shop-teams/shop-employee-teams.form.component.test.jsx +++ b/client/src/components/shop-teams/shop-employee-teams.form.component.test.jsx @@ -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() })); diff --git a/client/src/components/shop-teams/shop-employee-teams.list.jsx b/client/src/components/shop-teams/shop-employee-teams.list.jsx index 8cd351201..6acb89f45 100644 --- a/client/src/components/shop-teams/shop-employee-teams.list.jsx +++ b/client/src/components/shop-teams/shop-employee-teams.list.jsx @@ -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 { diff --git a/client/src/components/shop-teams/shop-teams.container.jsx b/client/src/components/shop-teams/shop-teams.container.jsx index 997cc309d..b55534b2d 100644 --- a/client/src/components/shop-teams/shop-teams.container.jsx +++ b/client/src/components/shop-teams/shop-teams.container.jsx @@ -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 ; @@ -30,11 +51,16 @@ function ShopTeamsContainer() { .join(" ")} >
- +
{hasSelectedTeam ? (
- +
) : null} diff --git a/client/src/hooks/useConfirmDirtyFormNavigation.jsx b/client/src/hooks/useConfirmDirtyFormNavigation.jsx new file mode 100644 index 000000000..5b77bbf88 --- /dev/null +++ b/client/src/hooks/useConfirmDirtyFormNavigation.jsx @@ -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]); +} diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 428894541..b4cfffcf4 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -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", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 4a12af3c8..55de91164 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -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": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index b0391e190..76030002e 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -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": "",