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 5aed82b8e..8218597b9 100644 --- a/client/src/components/shop-employees/shop-employees-form.component.jsx +++ b/client/src/components/shop-employees/shop-employees-form.component.jsx @@ -4,7 +4,7 @@ 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 queryString from "query-string"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { useLocation, useNavigate } from "react-router-dom"; @@ -52,6 +52,7 @@ const mapDispatchToProps = () => ({ }); export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDirty }) { + const submitActionRef = useRef("save"); const { t } = useTranslation(); const [internalIsDirty, setInternalIsDirty] = useState(false); const resolvedIsDirty = typeof isDirty === "boolean" ? isDirty : internalIsDirty; @@ -128,55 +129,113 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi }); }, [clearEmployeeFormMeta, currentEmployeeData, form]); + const syncEmployeeFormToSavedData = useCallback( + (employeeData) => { + if (employeeData) { + form.setFieldsValue(employeeData); + } + + window.requestAnimationFrame(() => { + clearEmployeeFormMeta(); + }); + }, + [clearEmployeeFormMeta, form] + ); + useEffect(() => { resetEmployeeFormToCurrentData(); }, [resetEmployeeFormToCurrentData, search.employeeId]); const [updateEmployee] = useMutation(UPDATE_EMPLOYEE); const [insertEmployees] = useMutation(INSERT_EMPLOYEES); + const saveAndResetSubmitAction = useCallback(() => { + const submitAction = submitActionRef.current; + submitActionRef.current = "save"; + return submitAction; + }, []); + const submitEmployeeForm = useCallback( + (submitAction = "save") => { + submitActionRef.current = submitAction; + form.submit(); + }, + [form] + ); + const navigateToEmployee = useCallback( + (employeeId) => { + history({ + search: queryString.stringify({ + ...search, + employeeId + }) + }); + }, + [history, search] + ); + + const handleFinish = async (values) => { + const submitAction = saveAndResetSubmitAction(); + const normalizedValues = { + ...values, + user_email: values.user_email === "" ? null : values.user_email + }; - const handleFinish = (values) => { if (search.employeeId && search.employeeId !== "new") { //Update a record. logImEXEvent("shop_employee_update"); - updateEmployee({ - variables: { - id: search.employeeId, - employee: { - ...values, - user_email: values.user_email === "" ? null : values.user_email + try { + const result = await updateEmployee({ + variables: { + id: search.employeeId, + employee: normalizedValues } - } - }) - .then(() => { - updateDirtyState(false); - void refetch(); - notification.success({ - title: t("employees.successes.save") - }); - }) - .catch((error) => { - notification.error({ - title: t("employees.errors.save", { - message: JSON.stringify(error) - }) - }); }); - } else { - //New record, insert it. - logImEXEvent("shop_employee_insert"); - insertEmployees({ - variables: { employees: [{ ...values, shopid: bodyshop.id }] }, - refetchQueries: ["QUERY_EMPLOYEES"] - }).then((r) => { - updateDirtyState(false); - search.employeeId = r.data.insert_employees.returning[0].id; - history({ search: queryString.stringify(search) }); + syncEmployeeFormToSavedData(result?.data?.update_employees?.returning?.[0] ?? normalizedValues); + void refetch(); + if (submitAction === "saveAndNew") { + navigateToEmployee("new"); + } notification.success({ title: t("employees.successes.save") }); + } catch (error) { + notification.error({ + title: t("employees.errors.save", { + message: JSON.stringify(error) + }) + }); + } + + return; + } + + //New record, insert it. + logImEXEvent("shop_employee_insert"); + + try { + const result = await insertEmployees({ + variables: { employees: [{ ...normalizedValues, shopid: bodyshop.id }] }, + refetchQueries: ["QUERY_EMPLOYEES"] + }); + const savedEmployee = result?.data?.insert_employees?.returning?.[0]; + + syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues); + + if (submitAction === "saveAndNew") { + navigateToEmployee("new"); + } else if (savedEmployee?.id) { + navigateToEmployee(savedEmployee.id); + } + + notification.success({ + title: t("employees.successes.save") + }); + } catch (error) { + notification.error({ + title: t("employees.errors.save", { + message: JSON.stringify(error) + }) }); } }; @@ -240,13 +299,24 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi form.submit()} disabled={!resolvedIsDirty} style={{ minWidth: 170 }}> - {t("employees.actions.save_employee")} - + + + + } >
{ + const actual = await vi.importActual("@apollo/client/react"); + + return { + ...actual, + useApolloClient: vi.fn(), + useQuery: (...args) => useQueryMock(...args), + useMutation: (...args) => useMutationMock(...args) + }; +}); + +vi.mock("@splitsoftware/splitio-react", () => ({ + useTreatmentsWithConfig: () => ({ + treatments: { + Enhanced_Payroll: { + treatment: "off" + } + } + }) +})); + +vi.mock("react-router-dom", () => ({ + useLocation: () => ({ + search: "?employeeId=new" + }), + useNavigate: () => navigateMock +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key, values = {}) => { + const translations = { + "bodyshop.labels.employee_options": "Employee Options", + "bodyshop.labels.employee_rates": "Employee Rates", + "bodyshop.labels.employee_vacation": "Employee Vacation", + "bodyshop.labels.employees": "Employees", + "employees.actions.addrate": "Add Rate", + "employees.actions.addvacation": "Add Vacation", + "employees.actions.new": "New Employee", + "employees.actions.save_employee": "Save Employee", + "employees.fields.active": "Active", + "employees.fields.employee_number": "Employee Number", + "employees.fields.external_id": "External Id", + "employees.fields.first_name": "First Name", + "employees.fields.flat_rate": "Flat Rate", + "employees.fields.hire_date": "Hire Date", + "employees.fields.last_name": "Last Name", + "employees.fields.pin": "PIN", + "employees.fields.rate": "Rate", + "employees.fields.termination_date": "Termination Date", + "employees.fields.user_email": "User Email", + "employees.labels.active": "Active", + "employees.successes.save": "Saved", + "general.actions.saveandnew": "Save and New", + "general.labels.actions": "Actions" + }; + + if (key === "employees.errors.save") { + return `Save failed: ${values.message ?? ""}`; + } + + if (key === "employees.validation.unique_employee_number") { + return "Employee number must be unique"; + } + + if (key === "bodyshop.validation.useremailmustexist") { + return "User email must exist"; + } + + return translations[key] || key; + } + }) +})); + +vi.mock("../../contexts/Notifications/notificationContext.jsx", () => ({ + useNotification: () => notification +})); + +vi.mock("../../firebase/firebase.utils", () => ({ + logImEXEvent: vi.fn() +})); + +vi.mock("../alert/alert.component", () => ({ + default: ({ title }) =>
{title}
+})); + +vi.mock("../form-fields-changed-alert/form-fields-changed-alert.component.jsx", () => ({ + default: () => null +})); + +vi.mock("../form-date-time-picker/form-date-time-picker.component.jsx", () => ({ + default: ({ id, value, onChange }) => ( + onChange?.(event.target.value)} /> + ) +})); + +vi.mock("../form-items-formatted/email-form-item.component.jsx", () => ({ + default: ({ id, value, onChange }) => ( + onChange?.(event.target.value)} /> + ) +})); + +vi.mock("../layout-form-row/layout-form-row.component", () => ({ + default: ({ title, extra, actions, children }) => ( +
+ {title} + {extra} + {children} + {actions} +
+ ) +})); + +vi.mock("../layout-form-row/inline-validated-form-row.component.jsx", () => ({ + default: ({ title, extra, children }) => ( +
+ {title} + {extra} + {children} +
+ ) +})); + +vi.mock("../layout-form-row/config-list-empty-state.component.jsx", () => ({ + default: ({ actionLabel }) =>
{actionLabel}
+})); + +vi.mock("../form-list-move-arrows/form-list-move-arrows.component", () => ({ + default: () => null +})); + +vi.mock("../responsive-table/responsive-table.component", () => ({ + default: () => null +})); + +vi.mock("./shop-employees-add-vacation.component", () => ({ + default: () => null +})); + +vi.mock("../../utils/Ciecaselect", () => ({ + default: () => [] +})); + +const bodyshop = { + id: "shop-1", + imexshopid: "split-shop-1", + md_responsibility_centers: { + costs: [] + } +}; + +describe("ShopEmployeesFormComponent", () => { + let formInstance; + + beforeEach(() => { + vi.clearAllMocks(); + + useQueryMock.mockImplementation((query) => { + if (query === QUERY_EMPLOYEE_BY_ID) { + return { + error: null, + data: null, + refetch: vi.fn(), + loading: false + }; + } + + return { + error: null, + data: null, + loading: false + }; + }); + + useMutationMock.mockImplementation((mutation) => { + if (mutation === INSERT_EMPLOYEES) return [insertEmployeesMock]; + if (mutation === UPDATE_EMPLOYEE) return [updateEmployeeMock]; + if (mutation === DELETE_VACATION) return [deleteVacationMock]; + return [vi.fn()]; + }); + + useApolloClient.mockReturnValue({ + query: vi.fn().mockResolvedValue({ + data: { + employees_aggregate: { + aggregate: { + count: 0 + }, + nodes: [] + }, + users: [] + } + }) + }); + + insertEmployeesMock.mockResolvedValue({ + data: { + insert_employees: { + returning: [ + { + id: "employee-123", + first_name: "Jamie", + last_name: "Rivera", + employee_number: "42", + active: true, + termination_date: null, + hire_date: "2026-04-20", + flat_rate: false, + rates: [], + pin: "1234", + user_email: null + } + ] + } + } + }); + + function TestHarness({ onFormReady }) { + const [form] = Form.useForm(); + + useEffect(() => { + onFormReady(form); + }, [form, onFormReady]); + + return ; + } + + render( + { + formInstance = form; + }} + /> + ); + }); + + it("marks a new employee form clean after save", async () => { + fireEvent.change(screen.getByRole("textbox", { name: "First Name" }), { + target: { value: "Jamie" } + }); + fireEvent.change(screen.getByRole("textbox", { name: "Last Name" }), { + target: { value: "Rivera" } + }); + fireEvent.change(screen.getByRole("textbox", { name: "Employee Number" }), { + target: { value: "42" } + }); + fireEvent.change(screen.getByRole("textbox", { name: "PIN" }), { + target: { value: "1234" } + }); + fireEvent.change(screen.getByRole("textbox", { name: "Hire Date" }), { + target: { value: "2026-04-20" } + }); + + const saveButton = screen.getByRole("button", { name: "Save Employee" }); + + await waitFor(() => { + expect(saveButton.disabled).toBe(false); + }); + + fireEvent.click(saveButton); + + await waitFor(() => { + expect(insertEmployeesMock).toHaveBeenCalledWith({ + variables: { + employees: [ + expect.objectContaining({ + first_name: "Jamie", + last_name: "Rivera", + employee_number: "42", + pin: "1234", + hire_date: "2026-04-20", + shopid: "shop-1" + }) + ] + }, + refetchQueries: ["QUERY_EMPLOYEES"] + }); + }); + + await waitFor(() => { + expect(formInstance.isFieldsTouched()).toBe(false); + }); + + expect(notification.success).toHaveBeenCalledWith({ + title: "Saved" + }); + expect(navigateMock).toHaveBeenCalledWith({ + search: "employeeId=employee-123" + }); + }); + + it("saves a new employee and opens a fresh employee form when save and new is clicked", async () => { + fireEvent.change(screen.getByRole("textbox", { name: "First Name" }), { + target: { value: "Jamie" } + }); + fireEvent.change(screen.getByRole("textbox", { name: "Last Name" }), { + target: { value: "Rivera" } + }); + fireEvent.change(screen.getByRole("textbox", { name: "Employee Number" }), { + target: { value: "42" } + }); + fireEvent.change(screen.getByRole("textbox", { name: "PIN" }), { + target: { value: "1234" } + }); + fireEvent.change(screen.getByRole("textbox", { name: "Hire Date" }), { + target: { value: "2026-04-20" } + }); + + fireEvent.click(screen.getByRole("button", { name: "Save and New" })); + + await waitFor(() => { + expect(insertEmployeesMock).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(formInstance.isFieldsTouched()).toBe(false); + }); + + expect(navigateMock).toHaveBeenCalledWith({ + search: "employeeId=new" + }); + expect(notification.success).toHaveBeenCalledWith({ + title: "Saved" + }); + }); +}); diff --git a/client/src/components/shop-info/shop-info.general.component.jsx b/client/src/components/shop-info/shop-info.general.component.jsx index fa3a2c3af..db7294df3 100644 --- a/client/src/components/shop-info/shop-info.general.component.jsx +++ b/client/src/components/shop-info/shop-info.general.component.jsx @@ -1582,7 +1582,6 @@ export function ShopInfoGeneral({ form }) { form={form} errorNames={[["md_parts_order_comment", field.name, "label"]]} noDivider - titleOnly title={
diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index dc880f009..f1e34aacb 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -1810,6 +1810,7 @@ }, "jobs": { "actions": { + "addpayer": "Add Payer", "addDocuments": "Add Job Documents", "addNote": "Add Note", "addtopartsqueue": "Add to Parts Queue", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 53fdce430..b54798893 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -1810,6 +1810,7 @@ }, "jobs": { "actions": { + "addpayer": "", "addDocuments": "Agregar documentos de trabajo", "addNote": "AƱadir la nota", "addtopartsqueue": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index de47247c9..7a561ab31 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -1810,6 +1810,7 @@ }, "jobs": { "actions": { + "addpayer": "", "addDocuments": "Ajouter des documents de travail", "addNote": "Ajouter une note", "addtopartsqueue": "", diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index 402345a29..a73017a92 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -1165,6 +1165,7 @@ - notification_followers - state - md_order_statuses + - md_ro_statuses retry_conf: interval_sec: 10 num_retries: 0 @@ -1185,7 +1186,8 @@ "new": { "id": {{$body.event.data.new.id}}, "shopname": {{$body.event.data.new.shopname}}, - "md_order_statuses": {{$body.event.data.new.md_order_statuses}} + "md_order_statuses": {{$body.event.data.new.md_order_statuses}}, + "md_ro_statuses": {{$body.event.data.new.md_ro_statuses}} } }, "op": {{$body.event.op}}, diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 6f70a1d4e..fe5321176 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -2954,6 +2954,7 @@ exports.GET_BODYSHOP_BY_ID = ` query GET_BODYSHOP_BY_ID($id: uuid!) { bodyshops_by_pk(id: $id) { id + md_ro_statuses md_order_statuses shopname imexshopid diff --git a/server/rr/rr-export-logs.test.js b/server/rr/rr-export-logs.test.js new file mode 100644 index 000000000..5178595cc --- /dev/null +++ b/server/rr/rr-export-logs.test.js @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const mock = require("mock-require"); + +const graphqlRequestModuleId = require.resolve("graphql-request"); +const queriesModuleId = require.resolve("../graphql-client/queries"); +const rrLoggerModuleId = require.resolve("./rr-logger-event"); +const rrExportLogsModuleId = require.resolve("./rr-export-logs"); + +const loadExportLogs = ({ requests }) => { + mock.stopAll(); + + mock(graphqlRequestModuleId, { + GraphQLClient: class MockGraphQLClient { + constructor(endpoint) { + this.endpoint = endpoint; + this.headers = {}; + } + + setHeaders(headers) { + this.headers = headers; + return this; + } + + async request(query, variables) { + requests.push({ + endpoint: this.endpoint, + headers: this.headers, + query, + variables + }); + return {}; + } + } + }); + + mock(queriesModuleId, { + INSERT_EXPORT_LOG: "INSERT_EXPORT_LOG", + MARK_JOB_EXPORTED: "MARK_JOB_EXPORTED" + }); + + mock(rrLoggerModuleId, () => {}); + + delete require.cache[rrExportLogsModuleId]; + return require(rrExportLogsModuleId); +}; + +const socket = { + data: { authToken: "socket-token" }, + user: { email: "tech@example.com" } +}; + +const job = { + id: "job-1", + bodyshop: { + id: "bodyshop-1", + md_ro_statuses: { + default_exported: "Exported" + } + } +}; + +describe("server/rr/rr-export-logs", () => { + const originalEndpoint = process.env.GRAPHQL_ENDPOINT; + + afterEach(() => { + mock.stopAll(); + delete require.cache[rrExportLogsModuleId]; + process.env.GRAPHQL_ENDPOINT = originalEndpoint; + }); + + it("marks Reynolds full exports as exported using the shared DMS export mutation", async () => { + process.env.GRAPHQL_ENDPOINT = "https://graphql.example.test/v1/graphql"; + const requests = []; + const { markRRExportSuccess } = loadExportLogs({ requests }); + + await markRRExportSuccess({ + socket, + jobId: job.id, + job, + bodyshop: job.bodyshop, + result: { + success: true, + roStatus: { + status: "SUCCESS", + statusCode: "0", + message: "Finalized" + } + } + }); + + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + endpoint: "https://graphql.example.test/v1/graphql", + headers: { Authorization: "Bearer socket-token" }, + query: "MARK_JOB_EXPORTED", + variables: { + jobId: "job-1", + job: { + status: "Exported", + date_exported: expect.any(Date) + }, + log: { + bodyshopid: "bodyshop-1", + jobid: "job-1", + successful: true, + useremail: "tech@example.com" + }, + bill: { + exported: true, + exported_at: expect.any(Date) + } + } + }); + }); + + it("uses the separately loaded bodyshop statuses when job.bodyshop is missing", async () => { + process.env.GRAPHQL_ENDPOINT = "https://graphql.example.test/v1/graphql"; + const requests = []; + const { markRRExportSuccess } = loadExportLogs({ requests }); + + await markRRExportSuccess({ + socket, + jobId: job.id, + job: { id: job.id }, + bodyshop: job.bodyshop, + result: { + success: true, + roStatus: { + status: "SUCCESS", + statusCode: "0", + message: "Finalized" + } + } + }); + + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + query: "MARK_JOB_EXPORTED", + variables: { + jobId: "job-1", + job: { + status: "Exported", + date_exported: expect.any(Date) + }, + log: { + bodyshopid: "bodyshop-1", + jobid: "job-1", + successful: true, + useremail: "tech@example.com" + } + } + }); + }); + + it("does not mark Reynolds early RO creation as exported", async () => { + process.env.GRAPHQL_ENDPOINT = "https://graphql.example.test/v1/graphql"; + const requests = []; + const { markRRExportSuccess } = loadExportLogs({ requests }); + + await markRRExportSuccess({ + socket, + jobId: job.id, + job, + bodyshop: job.bodyshop, + result: { success: true }, + isEarlyRo: true + }); + + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + query: "INSERT_EXPORT_LOG", + variables: { + logs: [ + { + bodyshopid: "bodyshop-1", + jobid: "job-1", + successful: true, + useremail: "tech@example.com" + } + ] + } + }); + }); +});