Merged in feature/IO-3499-React-19 (pull request #2851)

Feature/IO-3499 React 19
This commit is contained in:
Dave Richer
2026-01-20 16:39:09 +00:00
11 changed files with 239 additions and 121 deletions

View File

@@ -124,7 +124,7 @@ export function BillFormComponent({
return ( return (
<div> <div>
<FormFieldsChanged form={form} /> <FormFieldsChanged form={form} />
<Form.Item style={{ display: "none" }} name="isinhouse" valuePropName="checked"> <Form.Item hidden name="isinhouse" valuePropName="checked">
<Switch /> <Switch />
</Form.Item> </Form.Item>
<LayoutFormRow grow> <LayoutFormRow grow>

View File

@@ -15,7 +15,20 @@ const mapStateToProps = createStructuredSelector({
}); });
const mapDispatchToProps = () => ({}); const mapDispatchToProps = () => ({});
const iconStyle = { marginLeft: ".3rem" }; const iconStyle = {
marginLeft: ".3rem"
};
const iconClickableStyle = {
marginLeft: ".3rem",
cursor: "pointer"
};
const iconDisabledStyle = {
marginLeft: ".3rem",
cursor: "not-allowed",
opacity: 0.5
};
export function JobEmployeeAssignments({ export function JobEmployeeAssignments({
bodyshop, bodyshop,
@@ -58,7 +71,7 @@ export function JobEmployeeAssignments({
const renderAssigner = (operation) => { const renderAssigner = (operation) => {
if (jobRO) { if (jobRO) {
return <PlusCircleFilled disabled style={iconStyle} />; return <PlusCircleFilled style={iconDisabledStyle} />;
} }
const popContent = ( const popContent = (
@@ -117,6 +130,7 @@ export function JobEmployeeAssignments({
<span <span
role="button" role="button"
tabIndex={0} tabIndex={0}
style={{ display: "inline-flex", alignItems: "center", cursor: "pointer" }}
onClick={(e) => { onClick={(e) => {
// Prevent the click from being re-interpreted as "outside" // Prevent the click from being re-interpreted as "outside"
e.preventDefault(); e.preventDefault();
@@ -129,9 +143,8 @@ export function JobEmployeeAssignments({
openFor(operation); openFor(operation);
} }
}} }}
style={{ display: "inline-flex", alignItems: "center" }}
> >
<PlusCircleFilled style={iconStyle} /> <PlusCircleFilled style={iconClickableStyle} />
</span> </span>
</Popover> </Popover>
); );

View File

@@ -34,13 +34,13 @@ export default function PartsOrderModalPriceChange({ form, field }) {
{ {
key: "custom", key: "custom",
label: ( label: (
<InputNumber <Space.Compact>
onClick={(e) => e.stopPropagation()} <InputNumber
addonAfter="%" onClick={(e) => e.stopPropagation()}
onKeyUp={(e) => { onKeyUp={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
const values = form.getFieldsValue(); const values = form.getFieldsValue();
const { parts_order_lines } = values; const { parts_order_lines } = values;
form.setFieldsValue({ form.setFieldsValue({
parts_order_lines: { parts_order_lines: {
@@ -56,10 +56,12 @@ export default function PartsOrderModalPriceChange({ form, field }) {
}); });
e.target.value = 0; e.target.value = 0;
} }
}} }}
min={0} min={0}
max={100} max={100}
/> />
<span style={{ padding: "0 11px", backgroundColor: "#fafafa", border: "1px solid #d9d9d9", borderLeft: 0 }}>%</span>
</Space.Compact>
) )
} }
], ],

View File

@@ -58,7 +58,7 @@ export function PartsOrderModalComponent({
return ( return (
<div> <div>
<Form.Item name="returnfrombill" style={{ display: "none" }}> <Form.Item name="returnfrombill" hidden>
<Input /> <Input />
</Form.Item> </Form.Item>
<LayoutFormRow grow noDivider> <LayoutFormRow grow noDivider>
@@ -199,7 +199,10 @@ export function PartsOrderModalComponent({
key={`${index}act_price`} key={`${index}act_price`}
name={[field.name, "act_price"]} name={[field.name, "act_price"]}
> >
<CurrencyInput addonBefore={<PartsOrderModalPriceChange form={form} field={field} />} /> <Space.Compact style={{ width: "100%" }}>
<PartsOrderModalPriceChange form={form} field={field} />
<CurrencyInput style={{ flex: 1 }} />
</Space.Compact>
</Form.Item> </Form.Item>
{isReturn && ( {isReturn && (
<Form.Item <Form.Item

View File

@@ -80,36 +80,75 @@ export function PartsOrderModalContainer({
const handleFinish = async ({ order_type, removefrompartsqueue, is_quote, ...values }) => { const handleFinish = async ({ order_type, removefrompartsqueue, is_quote, ...values }) => {
logImEXEvent("parts_order_insert"); logImEXEvent("parts_order_insert");
setSaving(true); setSaving(true);
let insertResult;
insertResult = await insertPartOrder({ // Force job_line_id from context so it never gets dropped by AntD form submission behavior.
variables: { const submittedLines = values?.parts_order_lines?.data ?? [];
po: [ const forcedLines = submittedLines.map((p, index) => {
{ const originalLine = linesToOrder?.[index];
...values, const jobLineId = isReturn ? originalLine?.joblineid : originalLine?.id;
order_date: dayjs().format("YYYY-MM-DD"),
orderedby: currentUser.email, return {
jobid: jobId, ...p,
user_email: currentUser.email, job_line_id: jobLineId
return: isReturn, };
status: is_quote
? bodyshop.md_order_statuses.default_quote || "Quote"
: bodyshop.md_order_statuses.default_ordered || "Ordered*"
}
]
},
refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID"]
}); });
if (insertResult.errors) {
const missingIdx = forcedLines.findIndex((l) => !l?.job_line_id);
if (missingIdx !== -1) {
notification.error({ notification.error({
title: t("parts_orders.errors.creating"), title: t("parts_orders.errors.creating"),
description: JSON.stringify(insertResult.errors) description: `Missing job_line_id for parts line #${missingIdx + 1}`
}); });
setSaving(false);
return; return;
} }
let insertResult;
try {
insertResult = await insertPartOrder({
variables: {
po: [
{
...values,
order_date: dayjs().format("YYYY-MM-DD"),
orderedby: currentUser.email,
jobid: jobId,
user_email: currentUser.email,
return: isReturn,
status: is_quote
? bodyshop.md_order_statuses.default_quote || "Quote"
: bodyshop.md_order_statuses.default_ordered || "Ordered*",
// override nested lines to guarantee linkage
parts_order_lines: { data: forcedLines }
}
]
},
refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID"]
});
if (insertResult.errors) {
notification.error({
title: t("parts_orders.errors.creating"),
description: JSON.stringify(insertResult.errors)
});
setSaving(false);
return;
}
} catch (err) {
notification.error({
title: t("parts_orders.errors.creating"),
description: err?.message || String(err)
});
console.error("Parts order insert error:", err);
setSaving(false);
return;
}
notification.success({ notification.success({
title: values.isReturn ? t("parts_orders.successes.return_created") : t("parts_orders.successes.created") title: isReturn ? t("parts_orders.successes.return_created") : t("parts_orders.successes.created")
}); });
insertAuditTrail({ insertAuditTrail({
jobid: jobId, jobid: jobId,
operation: isReturn operation: isReturn
@@ -118,36 +157,54 @@ export function PartsOrderModalContainer({
type: isReturn ? "jobspartsreturn" : "jobspartsorder" type: isReturn ? "jobspartsreturn" : "jobspartsorder"
}); });
const jobLinesResult = await updateJobLines({ // Use linesToOrder from context instead of form values to preserve job line ids
variables: { const jobLineIds = (linesToOrder ?? [])
ids: values.parts_order_lines.data.filter((item) => item.job_line_id).map((item) => item.job_line_id), .filter((line) => (isReturn ? line.joblineid : line.id))
status: isReturn .map((line) => (isReturn ? line.joblineid : line.id));
? bodyshop.md_order_statuses.default_returned || "Returned*"
: is_quote
? bodyshop.md_order_statuses.default_quote || "Quote"
: bodyshop.md_order_statuses.default_ordered || "Ordered*"
}
});
if (!isReturn && removefrompartsqueue) { try {
await updateJob({ const jobLinesResult = await updateJobLines({
variables: { variables: {
jobId: jobId, ids: jobLineIds,
job: { status: isReturn
queued_for_parts: false ? bodyshop.md_order_statuses.default_returned || "Returned*"
} : is_quote
? bodyshop.md_order_statuses.default_quote || "Quote"
: bodyshop.md_order_statuses.default_ordered || "Ordered*"
} }
}); });
if (jobLinesResult.errors) {
notification.error({
title: t("parts_orders.errors.updating_status"),
description: JSON.stringify(jobLinesResult.errors)
});
}
} catch (err) {
notification.error({
title: t("parts_orders.errors.updating_status"),
description: err?.message || String(err)
});
console.error("Job lines update error:", err);
} }
if (jobLinesResult.errors) { if (!isReturn && removefrompartsqueue) {
notification.error({ try {
title: t("parts_orders.errors.creating"), await updateJob({
description: JSON.stringify(jobLinesResult.errors) variables: {
}); jobId: jobId,
job: {
queued_for_parts: false
}
}
});
} catch (err) {
console.error("Update job queue error:", err);
}
} }
if (values.vendorid === bodyshop.inhousevendorid) { if (values.vendorid === bodyshop.inhousevendorid) {
// Use linesToOrder for joblineid, and use forcedLines for the user-entered fields
setBillEnterContext({ setBillEnterContext({
actions: { refetch: refetch }, actions: { refetch: refetch },
context: { context: {
@@ -159,11 +216,12 @@ export function PartsOrderModalContainer({
isinhouse: true, isinhouse: true,
date: dayjs(), date: dayjs(),
total: 0, total: 0,
billlines: values.parts_order_lines.data.map((p) => { billlines: forcedLines.map((p, index) => {
const originalLine = linesToOrder?.[index];
return { return {
joblineid: p.job_line_id, joblineid: isReturn ? originalLine?.joblineid : originalLine?.id,
actual_price: p.act_price, actual_price: p.act_price,
actual_cost: 0, //p.act_price, actual_cost: 0, // p.act_price,
line_desc: p.line_desc, line_desc: p.line_desc,
line_remarks: p.line_remarks, line_remarks: p.line_remarks,
part_type: p.part_type, part_type: p.part_type,
@@ -178,6 +236,8 @@ export function PartsOrderModalContainer({
} }
} }
}); });
setSaving(false);
toggleModalVisible(); toggleModalVisible();
return; return;
} }
@@ -187,9 +247,8 @@ export function PartsOrderModalContainer({
const Templates = TemplateList("partsorder", context); const Templates = TemplateList("partsorder", context);
if (sendType === "e") { if (sendType === "e") {
const matchingVendor = data.vendors.filter((item) => item.id === values.vendorid)[0]; const matchingVendor = data?.vendors?.find((item) => item.id === values.vendorid);
const vendorEmails = matchingVendor?.email && matchingVendor.email.split(RegExp("[;,]"));
let vendorEmails = matchingVendor?.email && matchingVendor.email.split(RegExp("[;,]"));
GenerateDocument( GenerateDocument(
{ {
@@ -233,7 +292,7 @@ export function PartsOrderModalContainer({
notification notification
); );
} else if (sendType === "oec") { } else if (sendType === "oec") {
//Send to Partner OEC. // Send to Partner OEC.
try { try {
const partsOrder = await client.query({ const partsOrder = await client.query({
query: QUERY_PARTS_ORDER_OEC, query: QUERY_PARTS_ORDER_OEC,
@@ -241,26 +300,21 @@ export function PartsOrderModalContainer({
id: insertResult.data.insert_parts_orders.returning[0].id id: insertResult.data.insert_parts_orders.returning[0].id
} }
}); });
let po; let po;
//Massage the data based on the split. Should they be able to overwrite OEC pricing? // Massage the data based on the split. Should they be able to overwrite OEC pricing?
if (OEConnection_PriceChange.treatment === "on") { if (OEConnection_PriceChange.treatment === "on") {
//Set the flag to include the override.
po = _.cloneDeep(partsOrder.data.parts_orders_by_pk); po = _.cloneDeep(partsOrder.data.parts_orders_by_pk);
po.parts_order_lines.forEach((pol) => { po.parts_order_lines.forEach((pol) => {
pol.priceChange = true; pol.priceChange = true;
}); });
} }
const oecResponse = await axios.post( const oecResponse = await axios.post("http://localhost:1337/oec/", po || partsOrder.data.parts_orders_by_pk, {
"http://localhost:1337/oec/", headers: {
Authorization: `Bearer ${await auth.currentUser.getIdToken()}`
po || partsOrder.data.parts_orders_by_pk,
{
headers: {
Authorization: `Bearer ${await auth.currentUser.getIdToken()}`
}
} }
); });
if (oecResponse.data && oecResponse.data.success === false) { if (oecResponse.data && oecResponse.data.success === false) {
notification.error({ notification.error({
@@ -269,17 +323,18 @@ export function PartsOrderModalContainer({
}) })
}); });
} }
} catch (error) { } catch (err) {
console.log("Error OEC.", error); console.log("Error OEC.", err);
notification.error({ notification.error({
title: t("parts_orders.errors.oec", { title: t("parts_orders.errors.oec", {
error: JSON.stringify(error.message) error: JSON.stringify(err?.message || String(err))
}) })
}); });
setSaving(false); setSaving(false);
return; return;
} }
} }
setSaving(false); setSaving(false);
toggleModalVisible(); toggleModalVisible();
}; };
@@ -290,7 +345,6 @@ export function PartsOrderModalContainer({
deliver_by: isReturn ? dayjs(new Date()) : null, deliver_by: isReturn ? dayjs(new Date()) : null,
vendorid: vendorId, vendorid: vendorId,
returnfrombill: returnFromBill, returnfrombill: returnFromBill,
parts_order_lines: { parts_order_lines: {
data: linesToOrder data: linesToOrder
? linesToOrder.reduce((acc, value) => { ? linesToOrder.reduce((acc, value) => {

View File

@@ -46,10 +46,10 @@ export function PartsReceiveModalComponent({ bodyshop, form }) {
{fields.map((field, index) => ( {fields.map((field, index) => (
<Form.Item required={false} key={field.key}> <Form.Item required={false} key={field.key}>
<div style={{ display: "flex", alignItems: "center" }}> <div style={{ display: "flex", alignItems: "center" }}>
<Form.Item style={{ display: "none" }} key={`${index}joblineid`} name={[field.name, "joblineid"]}> <Form.Item hidden key={`${index}joblineid`} name={[field.name, "joblineid"]}>
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item style={{ display: "none" }} key={`${index}id`} name={[field.name, "id"]}> <Form.Item hidden key={`${index}id`} name={[field.name, "id"]}>
<Input /> <Input />
</Form.Item> </Form.Item>
<LayoutFormRow grow style={{ flex: 1 }}> <LayoutFormRow grow style={{ flex: 1 }}>

View File

@@ -37,39 +37,85 @@ export function PartsReceiveModalContainer({ partsReceiveModal, toggleModalVisib
const handleFinish = async (values) => { const handleFinish = async (values) => {
logImEXEvent("parts_order_receive"); logImEXEvent("parts_order_receive");
setLoading(true); setLoading(true);
const result = await Promise.all(
values.partsorderlines.map((li) => {
return receivePartsLine({
variables: {
lineId: li.joblineid,
line: {
location: li.location,
status: bodyshop.md_order_statuses.default_received || "Received*"
},
orderLineId: li.id,
orderLine: {
status: bodyshop.md_order_statuses.default_received || "Received*"
}
}
});
})
);
result.forEach((jobLinesResult) => { try {
if (jobLinesResult.errors) { const submittedLines = values.partsorderlines ?? [];
// Preserve ids from modal context, merge editable fields from form submission (e.g. location)
const mergedLines = (partsorderlines ?? []).map((ctxLine, idx) => ({
...ctxLine,
...submittedLines[idx]
}));
// Optional: hard guard to catch the exact failure early with a better message
const missing = mergedLines
.map((l, idx) => ({
idx,
orderLineId: l?.id,
jobLineId: l?.joblineid // adjust if your ctx uses job_line_id instead
}))
.filter((x) => !x.orderLineId || !x.jobLineId);
if (missing.length) {
notification.error({ notification.error({
title: t("parts_orders.errors.creating"), title: t("parts_orders.errors.creating"),
description: JSON.stringify(jobLinesResult.errors) description: `Missing required ids for lines: ${missing
.map((m) => `#${m.idx + 1} (orderLineId=${m.orderLineId}, jobLineId=${m.jobLineId})`)
.join(", ")}`
}); });
return;
} }
});
notification.success({ const results = await Promise.allSettled(
title: t("parts_orders.successes.received") mergedLines.map((li) =>
}); receivePartsLine({
setLoading(false); variables: {
if (refetch) refetch(); lineId: li.joblineid,
toggleModalVisible(); line: {
location: li.location,
status: bodyshop.md_order_statuses.default_received || "Received*"
},
orderLineId: li.id,
orderLine: {
status: bodyshop.md_order_statuses.default_received || "Received*"
}
},
// Ensures GraphQL errors come back on the result when possible (instead of only throwing)
errorPolicy: "all"
})
)
);
const errors = [];
results.forEach((r, idx) => {
if (r.status === "rejected") {
errors.push({ idx, message: r.reason?.message ?? String(r.reason) });
return;
}
if (r.value?.errors?.length) {
errors.push({
idx,
message: r.value.errors.map((e) => e.message).join(" | ")
});
}
});
if (errors.length) {
errors.forEach((e) =>
notification.error({
title: t("parts_orders.errors.creating"),
description: `Line ${e.idx + 1}: ${e.message}`
})
);
return; // keep modal open so user can retry
}
notification.success({ title: t("parts_orders.successes.received") });
if (refetch) refetch();
toggleModalVisible();
} finally {
setLoading(false);
}
}; };
const initialValues = { const initialValues = {

View File

@@ -1500,7 +1500,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) {
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
style={{ display: "none" }} hidden
label={t("jobs.fields.parts_tax_rates.prt_tx_ty1")} label={t("jobs.fields.parts_tax_rates.prt_tx_ty1")}
name={["md_responsibility_centers", "parts_tax_rates", "PAN", "prt_tx_ty1"]} name={["md_responsibility_centers", "parts_tax_rates", "PAN", "prt_tx_ty1"]}
> >

View File

@@ -93,7 +93,10 @@ export default function TimeTicketCalculatorComponent({
</Form.Item> </Form.Item>
<Form.Item name="percent"> <Form.Item name="percent">
<InputNumber min={0} max={100} precision={1} addonAfter="%" /> <Space.Compact>
<InputNumber min={0} max={100} precision={1} />
<span style={{ padding: "0 11px", backgroundColor: "#fafafa", border: "1px solid #d9d9d9", borderLeft: 0, display: "flex", alignItems: "center" }}>%</span>
</Space.Compact>
</Form.Item> </Form.Item>
<Button htmlType="submit">Calculate</Button> <Button htmlType="submit">Calculate</Button>
</Form> </Form>

View File

@@ -96,7 +96,10 @@ export function TimeTicketListTeamPay({ bodyshop, context }) {
</Form.Item> </Form.Item>
<Form.Item name="percent"> <Form.Item name="percent">
<InputNumber min={0} max={100} precision={1} addonAfter="%" /> <Space.Compact>
<InputNumber min={0} max={100} precision={1} />
<span style={{ padding: "0 11px", backgroundColor: "#fafafa", border: "1px solid #d9d9d9", borderLeft: 0, display: "flex", alignItems: "center" }}>%</span>
</Space.Compact>
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>

View File

@@ -184,13 +184,7 @@ export function TimeTicketModalComponent({
}} }}
</Form.Item> </Form.Item>
<Form.Item <Form.Item name="flat_rate" label={t("timetickets.fields.flat_rate")} valuePropName="checked" noStyle hidden>
name="flat_rate"
label={t("timetickets.fields.flat_rate")}
valuePropName="checked"
noStyle
style={{ display: "none" }}
>
<Switch style={{ display: "none" }} /> <Switch style={{ display: "none" }} />
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>