diff --git a/client/src/components/job-detail-lines/job-lines.component.jsx b/client/src/components/job-detail-lines/job-lines.component.jsx index d6a0320e3..f0d2d54ef 100644 --- a/client/src/components/job-detail-lines/job-lines.component.jsx +++ b/client/src/components/job-detail-lines/job-lines.component.jsx @@ -9,10 +9,10 @@ import { WarningFilled } from "@ant-design/icons"; import { PageHeader } from "@ant-design/pro-layout"; -import { useMutation } from "@apollo/client"; -import { Button, Dropdown, Input, Space, Table, Tag } from "antd"; +import { gql, useMutation } from "@apollo/client"; +import { Button, Dropdown, Input, Modal, Select, Space, Table, Tag, Typography } from "antd"; import axios from "axios"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; @@ -47,6 +47,19 @@ import JobLinesExpander from "./job-lines-expander.component"; import JobLinesPartPriceChange from "./job-lines-part-price-change.component"; import JobLinesExpanderSimple from "./jobs-lines-expander-simple.component"; import { logImEXEvent } from "../../firebase/firebase.utils"; +import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; + +const UPDATE_JOB_LINES_LOCATION_BULK = gql` + mutation UPDATE_JOB_LINES_LOCATION_BULK($ids: [uuid!]!, $location: String!) { + update_joblines(where: { id: { _in: $ids } }, _set: { location: $location }) { + affected_rows + returning { + id + location + } + } + } +`; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -83,6 +96,9 @@ export function JobLinesComponent({ isPartsEntry }) { const [deleteJobLine] = useMutation(DELETE_JOB_LINE_BY_PK); + const [bulkUpdateLocations] = useMutation(UPDATE_JOB_LINES_LOCATION_BULK); + const notification = useNotification(); + const { treatments: { Enhanced_Payroll } } = useSplitTreatments({ @@ -103,9 +119,83 @@ export function JobLinesComponent({ } }); + // Bulk location modal state + const [bulkLocationOpen, setBulkLocationOpen] = useState(false); + const [bulkLocation, setBulkLocation] = useState(null); + const [bulkLocationSaving, setBulkLocationSaving] = useState(false); + const { t } = useTranslation(); const jobIsPrivate = bodyshop.md_ins_cos.find((c) => c.name === job.ins_co_nm)?.private; + const selectedLineIds = useMemo(() => selectedLines.map((l) => l?.id).filter(Boolean), [selectedLines]); + + const commonSelectedLocation = useMemo(() => { + const locs = selectedLines + .map((l) => (typeof l?.location === "string" ? l.location : "")) + .map((x) => x.trim()) + .filter(Boolean); + + if (locs.length === 0) return null; + + const uniq = _.uniq(locs); + return uniq.length === 1 ? uniq[0] : null; + }, [selectedLines]); + + const openBulkLocationModal = () => { + setBulkLocation(commonSelectedLocation); + setBulkLocationOpen(true); + logImEXEvent("joblines_bulk_location_open", { count: selectedLineIds.length }); + }; + + const closeBulkLocationModal = () => { + setBulkLocationOpen(false); + setBulkLocation(null); + }; + + const saveBulkLocation = async () => { + if (selectedLineIds.length === 0) return; + + setBulkLocationSaving(true); + + try { + const locationToSave = (bulkLocation ?? "").toString(); + + const result = await bulkUpdateLocations({ + variables: { + ids: selectedLineIds, + location: locationToSave + } + }); + + if (!result?.errors) { + // Keep UI selection consistent without waiting for refetch + setSelectedLines((prev) => + prev.map((l) => (l && selectedLineIds.includes(l.id) ? { ...l, location: locationToSave } : l)) + ); + + notification["success"]({ message: t("joblines.successes.saved") }); + + logImEXEvent("joblines_bulk_location_saved", { + count: selectedLineIds.length, + location: locationToSave + }); + + closeBulkLocationModal(); + if (refetch) refetch(); + } else { + notification["error"]({ + message: t("joblines.errors.saving", { error: JSON.stringify(result.errors) }) + }); + } + } catch (error) { + notification["error"]({ + message: t("joblines.errors.saving", { error: error?.message || String(error) }) + }); + } finally { + setBulkLocationSaving(false); + } + }; + const columns = [ { title: "#", @@ -171,46 +261,16 @@ export function JobLinesComponent({ ? ["PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG", "PAO"] : ["PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG"] }, - { - text: t("joblines.fields.part_types.PAN"), - value: ["PAN"] - }, - { - text: t("joblines.fields.part_types.PAP"), - value: ["PAP"] - }, - { - text: t("joblines.fields.part_types.PAL"), - value: ["PAL"] - }, - { - text: t("joblines.fields.part_types.PAA"), - value: ["PAA"] - }, - { - text: t("joblines.fields.part_types.PAG"), - value: ["PAG"] - }, - { - text: t("joblines.fields.part_types.PAS"), - value: ["PAS"] - }, - { - text: t("joblines.fields.part_types.PASL"), - value: ["PASL"] - }, - { - text: t("joblines.fields.part_types.PAC"), - value: ["PAC"] - }, - { - text: t("joblines.fields.part_types.PAR"), - value: ["PAR"] - }, - { - text: t("joblines.fields.part_types.PAM"), - value: ["PAM"] - }, + { text: t("joblines.fields.part_types.PAN"), value: ["PAN"] }, + { text: t("joblines.fields.part_types.PAP"), value: ["PAP"] }, + { text: t("joblines.fields.part_types.PAL"), value: ["PAL"] }, + { text: t("joblines.fields.part_types.PAA"), value: ["PAA"] }, + { text: t("joblines.fields.part_types.PAG"), value: ["PAG"] }, + { text: t("joblines.fields.part_types.PAS"), value: ["PAS"] }, + { text: t("joblines.fields.part_types.PASL"), value: ["PASL"] }, + { text: t("joblines.fields.part_types.PAC"), value: ["PAC"] }, + { text: t("joblines.fields.part_types.PAR"), value: ["PAR"] }, + { text: t("joblines.fields.part_types.PAM"), value: ["PAM"] }, ...(isPartsEntry ? [ { @@ -220,7 +280,6 @@ export function JobLinesComponent({ ] : []) ], - onFilter: (value, record) => value.includes(record.part_type), render: (text, record) => (record.part_type ? t(`joblines.fields.part_types.${record.part_type}`) : null) }, @@ -246,7 +305,6 @@ export function JobLinesComponent({ title: t("joblines.fields.mod_lbr_ty"), dataIndex: "mod_lbr_ty", key: "mod_lbr_ty", - sorter: (a, b) => alphaSort(a.mod_lbr_ty, b.mod_lbr_ty), sortOrder: state.sortedInfo.columnKey === "mod_lbr_ty" && state.sortedInfo.order, render: (text, record) => (record.mod_lbr_ty ? t(`joblines.fields.lbr_types.${record.mod_lbr_ty}`) : null) @@ -255,7 +313,6 @@ export function JobLinesComponent({ title: t("joblines.fields.mod_lb_hrs"), dataIndex: "mod_lb_hrs", key: "mod_lb_hrs", - sorter: (a, b) => a.mod_lb_hrs - b.mod_lb_hrs, sortOrder: state.sortedInfo.columnKey === "mod_lb_hrs" && state.sortedInfo.order }, @@ -310,18 +367,12 @@ export function JobLinesComponent({ sorter: (a, b) => alphaSort(a.status, b.status), sortOrder: state.sortedInfo.columnKey === "status" && state.sortedInfo.order, filteredValue: state.filteredInfo.status || null, - filters: (jobLines && jobLines .map((l) => l.status) .filter(onlyUnique) - .map((s) => { - return { - text: s || t("dashboard.errors.status"), - value: [s] - }; - })) || + .map((s) => ({ text: s || t("dashboard.errors.status"), value: [s] }))) || [], onFilter: (value, record) => value.includes(record.status), render: (text, record) => @@ -376,9 +427,7 @@ export function JobLinesComponent({ }); } }); - await axios.post("/job/totalsssu", { - id: job.id - }); + await axios.post("/job/totalsssu", { id: job.id }); if (refetch) refetch(); }} > @@ -448,6 +497,36 @@ export function JobLinesComponent({ return (
+ + + + + {t("general.labels.selected")}: {selectedLineIds.length} + + + setLocation(null)} - onSelect={handleChange} - onBlur={handleSave} - > - {bodyshop.md_parts_locations.map((loc, idx) => ( - - {loc} - - ))} - - +
+